1
0

California gets roads, traffic, and a car to follow

This commit is contained in:
2026-08-11 18:24:53 -07:00
parent 9c9e78f6f9
commit fe58290728
43 changed files with 5593 additions and 68 deletions
+21
View File
@@ -13,6 +13,7 @@
# clean-clone a fresh checkout installs, builds and passes its tests
# zero-config-boot the API answers health when handed nothing at all
# 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
# not hide a licensing regression.
@@ -120,3 +121,23 @@ jobs:
- name: no binary art under src/
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
View File
@@ -1,6 +1,6 @@
# 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
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
├── src/interiors/ # the inside of the world — floorplans, desks, presence
├── 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.
│ ├── california.ts # state/corridor scale; LA ↔ SF continuity
│ ├── 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
│ └── workie.ts # Workie API -> Marker[]
└── 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
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
+92
View File
@@ -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
View File
@@ -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 1015 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.
+14 -5
View File
@@ -1,6 +1,6 @@
# 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
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
An engine plus data packs. The engine renders terrain, coastline, a built city
on real street grids, bridges, roads, markers and air traffic. A *city pack* is
An engine plus data packs. The default board joins Los Angeles and San Francisco
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
a city is a data contribution anyone can review, not a fork.
San Francisco ships today. Los Angeles / Orange County / Riverside is next; New
York after that.
California, the detailed Bay Area, and Los Angeles / Orange County / Riverside
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
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/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/tools/ instruments — god-only, dynamically imported, never statically
```
@@ -144,3 +151,5 @@ what silently undoes it.
## Licence
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
View File
@@ -60,7 +60,7 @@
<meta property="og:image:height" content="630" />
<meta
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" />
@@ -80,7 +80,7 @@
* 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
* 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.
*
@@ -666,6 +666,54 @@
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 -------------------------------------------------------
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
@@ -835,15 +883,15 @@
</style>
</head>
<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">
<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>
<div id="panel">
<section class="card">
<h1 id="title">San Francisco</h1>
<h1 id="title">California</h1>
<p id="subtitle">Tera · Lumbridge Simulate</p>
<p id="clock" class="clock" aria-live="polite"></p>
</section>
@@ -870,6 +918,7 @@
</div>
<div class="card hint" id="hint">
<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>O</kbd> office</span>
<span><kbd>M</kbd> plan</span>
@@ -886,6 +935,17 @@
</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>
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
@@ -899,6 +959,10 @@
<h2 id="shortcuts-title">Keyboard</h2>
<dl class="keys">
<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>O</kbd></dt><dd>Enter or leave the office</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>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>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>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
</dl>
+2
View File
@@ -11,6 +11,8 @@
"scripts": {
"dev": "vite",
"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\"",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
+64
View File
@@ -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)}`);
}
+70
View File
@@ -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;
}
+161
View File
@@ -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);
}
+171
View File
@@ -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);
}
+261
View File
@@ -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);
}
+40
View File
@@ -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";
+18
View File
@@ -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";
+589
View File
@@ -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();
}
}
+149
View File
@@ -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
View File
@@ -73,6 +73,20 @@ interface Box {
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][]) {
let minLat = Infinity;
let maxLat = -Infinity;
@@ -87,7 +101,10 @@ function polygonBounds(poly: [number, number][]) {
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[] = [];
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.
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({
x,
z,
y: world.groundAt(lat, lng),
w: LOT * fill,
d: LOT * fill * (0.85 + rand() * 0.3),
w: width,
d: depth,
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
rot: rotation,
color,
// A tower is an office whatever district it landed in.
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
* 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();
group.name = "landmarks";
for (const lm of world.city.landmarks) {
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 h = world.metres(lm.height);
const w = lm.footprint * world.lngScale * 2;
+406
View File
@@ -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;
};
}
+21
View File
@@ -10,6 +10,7 @@
*/
import * as THREE from "three";
import { createBuildingGlyph, type BuildingGlyphHandle } from "./buildingGlyph.ts";
import type { Marker, MarkerPalette } from "./types.ts";
import type { World } from "./world.ts";
@@ -38,6 +39,7 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
group.name = "markers";
const pickables: THREE.Object3D[] = [];
const anchors = new Map<string, THREE.Vector3>();
const buildings: BuildingGlyphHandle[] = [];
// One shared geometry per shape; colour varies per instance material, which
// 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() {
for (const building of buildings) building.dispose();
buildings.length = 0;
for (const child of [...group.children]) group.remove(child);
pickables.length = 0;
anchors.clear();
@@ -73,6 +77,23 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
const [x, z] = world.project(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();
pin.position.set(x, base + PIN_LIFT, z);
+256
View File
@@ -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);
},
};
}
+60 -3
View File
@@ -28,7 +28,7 @@
*/
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 { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
@@ -41,7 +41,17 @@ import {
type SatelliteLayer,
} from "./satellites.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 {
VehicleActionSnapshot,
VehicleControllerState,
} from "../transport/vehicleController.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
@@ -58,6 +68,17 @@ import { World, type FieldProgress } from "./world.ts";
export interface SceneOptions {
city: City;
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;
/**
* Element sets to propagate, if this deployment has any.
@@ -148,6 +169,12 @@ export interface SceneHandle {
flyTo(chapterId: string): void;
current(): string;
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;
/** Take this city off the stage and release everything it built. */
dispose(): void;
@@ -283,9 +310,19 @@ export async function createScene(
scene.add(createShorePlates(world));
scene.add(createTerrain(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(createLandmarks(world));
scene.add(createLandmarks(world, buildingReservations));
scene.add(createBridges(world));
/**
@@ -300,8 +337,14 @@ export async function createScene(
scene.add(clouds.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
markerLayer.setMarkers(options.markers ?? []);
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 flightTimer = 0;
if (options.flights) {
@@ -365,7 +408,15 @@ export async function createScene(
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
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) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
@@ -396,6 +447,7 @@ export async function createScene(
onExit: () => kit.resetPick(),
tick(dt) {
kit.tick(dt);
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
flightTimer -= dt;
@@ -444,6 +496,7 @@ export async function createScene(
clouds.dispose();
nightLights.dispose();
markerLayer.dispose();
roadTraffic?.dispose();
kit.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
@@ -481,6 +534,10 @@ export async function createScene(
onChapterChange(fn) {
chapterListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
+54 -2
View File
@@ -12,7 +12,7 @@ import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
/** 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[] = [];
for (let i = 0; i < path.length - 1; i++) {
const from = path[i];
@@ -40,12 +40,64 @@ function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Me
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 {
const group = new THREE.Group();
group.name = "roads";
for (const road of world.city.roads) {
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;
}
+33
View File
@@ -255,6 +255,37 @@ export interface Pin {
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. */
export interface Marker extends Pin {
lat: number;
@@ -265,6 +296,8 @@ export interface Marker extends Pin {
* is that it is real is worse than admitting the gap.
*/
located?: boolean;
/** Optional map-scale representation. Omit it for the ordinary pin. */
glyph?: BuildingGlyph;
}
/** Caller-supplied `colorKey` -> colour. */
+81
View File
@@ -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
View File
@@ -29,7 +29,7 @@
* +Z down the page.
*/
import type { Pin, View } from "../engine/types.ts";
import type { BuildingGlyph, Pin, View } from "../engine/types.ts";
// ---- Geometry -------------------------------------------------------------
@@ -248,6 +248,15 @@ export interface OfficeSite {
* has no name worth printing.
*/
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;
}
/**
+329
View File
@@ -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
View File
@@ -32,8 +32,16 @@ import type { Pose } from "./engine/scenekit.ts";
import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.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 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 {
createTeraClient,
describeLiveness,
@@ -78,6 +86,7 @@ import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts
import type { PoseEditor } from "./tools/poseEditor.ts";
const CITIES: { id: string; label: string; city: City }[] = [
{ id: "california", label: "California", city: CALIFORNIA },
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
{ id: "socal", label: "SoCal", city: SOCAL },
];
@@ -131,7 +140,7 @@ const stage = createStage(canvas);
const tera = createTeraClient({ fetch: authFetch });
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
* 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
* the click handler.
*/
let wantedCity = "sf";
let wantedCity = "california";
let office: OfficeScene | null = null;
let inside = false;
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
* 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
* 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
* 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) => ({
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
// placeholder, so it is drawn as a real address.
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
// entire sky projected ~590 km off the world and rendered as nothing at all.
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 =
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;
/**
@@ -717,9 +734,42 @@ async function mountCity(id: string) {
dial.setExtra(trafficDial?.extra() ?? 0);
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, {
city: entry.city,
markerPalette: palette,
markers: initialMarkers,
...(id === "california"
? {
roadTraffic: {
pack: CALIFORNIA_TRANSPORT,
routeId: "la-sf-us-101",
count: 14,
seed: 115,
},
}
: {}),
flights: dial.source,
...(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.
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());
/**
@@ -889,7 +917,7 @@ async function mountCity(id: string) {
},
});
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
// 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 planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
function showDetail(text: string | null) {
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
* is currently the answer. A second, separate office strip was the obvious
@@ -1332,8 +1361,8 @@ function renderCityPicker() {
for (const entry of entries) {
const b = document.createElement("button");
// `aria-pressed` rather than a class, because that is what these are: two
// buttons of which exactly one is on. The stylesheet keys off the attribute
// `aria-pressed` rather than a class, because these buttons choose exactly
// one active board. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart.
b.className = "city";
b.type = "button";
@@ -1449,6 +1478,7 @@ function renderLegend() {
minimap?.setChapters(city.chapters, city.current());
officePlan?.setActiveView(office?.current() ?? null);
renderOfficeBadge();
if (driveControls) driveControls.hidden = !routeDriveIsActive();
}
/**
@@ -1814,8 +1844,6 @@ panelToggle?.addEventListener("click", () => {
applyPanel();
});
planToggle?.addEventListener("click", () => togglePlan());
/**
* 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.
@@ -1855,6 +1883,98 @@ shortcutsCard?.addEventListener("click", (event) => {
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.
*
@@ -1902,6 +2022,25 @@ window.addEventListener("keydown", (event) => {
return;
}
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") {
togglePlan();
return;
@@ -2369,7 +2508,9 @@ async function boot() {
*/
const wanted = new URLSearchParams(location.search).get("city");
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
// stage and there is not one before this line.
+33
View File
@@ -33,6 +33,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
elevation: 188,
heading: 205,
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`. */
@@ -42,6 +53,17 @@ export const FRONTIER_VALLEY_SITE: OfficeSite = {
elevation: 4,
heading: 0,
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,
heading: 36,
label: "Arts District, Los Angeles",
exterior: {
kind: "building",
width: 36,
depth: 26,
height: 9,
storeys: 2,
heading: 36,
profile: "courtyard",
seed: 1781,
bodyColor: 0xa87960,
},
};
/**
+113
View File
@@ -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);
});
});
+59
View File
@@ -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 },
);
});
});
+33
View File
@@ -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);
});
});
+139
View File
@@ -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
View File
@@ -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.
*/
describe("the sites", () => {
it("are both declared", () => {
assert.ok(LUMBRIDGE_HQ.site, "Lumbridge HQ has no site");
assert.ok(FRONTIER_VALLEY.site, "Frontier Valley has no site");
const packs = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
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", () => {
@@ -465,15 +466,15 @@ describe("the sites", () => {
});
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;
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
}
});
it("are both on the board the city view draws", () => {
// Not a format requirement — an office may stand anywhere — but both of
// these are meant to be places in *this* product's San Francisco, and a
it("puts the Bay Area offices on the board the city view draws", () => {
// Not a format requirement — an office may stand anywhere — but these two
// are meant to be places in *this* product's San Francisco, and a
// coordinate typo that put one in Nevada would otherwise render fine.
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
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}`);
}
});
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}`);
}
}
});
});
/**
+83
View File
@@ -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();
});
});
+80
View File
@@ -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);
});
});
+183
View File
@@ -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);
});
});
+48
View File
@@ -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,
},
);
});
});
+102
View File
@@ -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]);
});
});
+131
View File
@@ -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);
});
});
+380
View File
@@ -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;
+94
View File
@@ -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[];
}
+436
View File
@@ -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 };
}
+253
View File
@@ -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;
}