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
+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);
},
};
}
+61 -4
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;
kit.flyTo(chapterPose(ch));
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
roadTraffic.setFollowing(true);
} else {
roadTraffic?.setFollowing(false);
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
if (currentChapter !== chapterId) {
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;
}