1
0

feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul

The build the studios needed, across eight workstreams and one strict file
partition.

**The render rig was the quality ceiling.** The renderer ran three's
NoToneMapping default while atmosphere drove the sun to 2.35 and assets set
emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is
why walls blew out and every fitting looked like a white rectangle. ACES filmic
tone mapping and an explicit output colour space land in `stage.ts`, and the
atmosphere intensity table and palette headroom are re-tuned against the new
curve rather than left tuned for the clipping we removed.

`engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally,
so nothing binary is committed. There was no environment map anywhere before, so
every `metalness > 0` role had nothing to reflect and rendered dull grey — a
defect the code already documented against itself in `office/optimus.ts`, where a
whole material role was abandoned over it, and worked around in `modelX.ts` with
a fake emissive that this change deletes. Atmosphere remains the sole light
owner; the rig derives from the `LightingState` it already produced.

**Studio hardware exists.** There was no device concept anywhere in the product:
no type, no route, no state. `devices/types.ts` fixes a declaration/state/
capability/command contract that a smart light, a thermostat, a door sensor and a
charger all fit without a schema change, and both studios now carry a desk mic
and a computer speaker with deterministic simulated behaviour behind an adapter
seam a real API can occupy later. Reads are the demo and are open; commands are a
signed-in action and are kept off the read body entirely, because a shared cache
replaying a GET that turned a microphone on is exactly what the fail-closed
cache default exists to prevent.

**The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the
response was served publicly cacheable, and the attribution hardcoded adsb.lol
regardless of where the endpoint pointed — one env var away from republishing
non-redistributable data under an open-terms credit. The host is now allowlisted,
the credit is derived from the host actually configured, public cacheability is
conditional on redistributability, and a refused endpoint demotes to simulated
flights and says so in `degraded[]`. The gate is on the source, not the feature:
live aircraft and their detail cards stay open to anonymous visitors.

**The LA studio was never the smaller pack** — 16 rooms and 248 props against
SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were
ceiling troffers, it bound no props to seats, placed none of the habitat kit, and
12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather
than more instances, because `furnish.ts` draws once per kind and folds colour
into the batch key, so repeat instances add nothing the eye can read.

**The interface stops being forty imperative mutations.** Every visibility
decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the
chrome has coverage for the first time. Deleted: ~100 lines of CSS and two
bindings targeting elements that no longer exist, and a `body:has()` rule that
shifted the desktop layout by 160px for touch controls hidden there. Fixed: the
office picker tabs that drew their label and their badge on top of each other.
Added: a first-run flow, because the product is two verbs and neither was ever
stated on screen. Mobile is designed on its own terms instead of being the
desktop with things hidden — the plan view comes back, and the keyboard-only
shortcuts button is replaced by touch controls.

`arena/studioOps.ts` frames the whole thing as the multi-variable environment it
is, wrapping the same simulators the renderer drives rather than a headless copy.

Also removed `input/vehicle.ts`, which nothing but its own test imported.

Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six
matrix cells, no-binaries, provenance, dependency licences, zero-config boot and
arena source hashes all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:44:24 -07:00
parent 8738367258
commit db074e9cf7
150 changed files with 36237 additions and 2586 deletions
+249 -82
View File
@@ -5,15 +5,145 @@
* Roads follow the terrain: each path is resampled far more finely than it is
* written in the city pack, and every sample takes its height from the ground,
* so a street climbs out of the flats instead of burrowing through the hill.
*
* ### Everything here is batched, and it has to be
*
* The city ran at 616 draw calls against a budget of 650 while the office spent
* 8% of its triangle budget: quality is nearly free indoors and is not free at
* all out here, so anything this module can hand back is headroom the exterior
* vehicles and the aircraft get to spend. Batching the corridor and the bridges
* took the California board to 557 measured — 59 calls, from 59 freeway meshes
* down to 20 plus the four extra shadow-pass draws the guardrails and sign
* posts used to cost.
*
* Two rules keep it honest, and both were broken before:
*
* 1. **Materials are cached by colour**, in a `Batch` that lives as long as
* the build call. Twelve identical asphalt decks used to be twelve
* `MeshLambertMaterial`s, which is twelve things that can never merge, and
* a single suspension bridge minted a fresh material for its deck, each
* tower, each brace, each cable and each hanger — about thirty-four.
* 2. **Geometry is merged per material.** Every helper below returns a
* `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
* named bucket; one mesh comes out per bucket at the end.
*
* The cache is deliberately *not* module-level. `createScene().dispose()` walks
* the scene and disposes every material it finds, so a cache that outlived one
* build would hand the next board a disposed material and render it black.
*
* The corollary for anyone adding a helper here: give every geometry the **same
* attribute set** — position, normal, uv, indexed — or `mergeGeometries`
* refuses the bucket and silently drops it. That is why the ribbons below carry
* UVs they have no texture for.
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
import type { TransportPack } from "../transport/types.ts";
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
// ---- Batching -------------------------------------------------------------
/**
* The three ways a surface out here is shaded.
*
* `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip
* that has to survive being looked at from underneath on a bridge approach, and
* a tower is a closed solid where a back face is a waste.
*
* `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the
* one thing in the frame whose job is to be a fixed, known white — it is
* retroreflective, it is what a driver navigates by, and putting it through the
* ACES shoulder with everything else turns a lane line into a grey smear at
* midday and loses it entirely at dusk.
*/
type SurfaceKind = "deck" | "solid" | "marking";
interface Bucket {
readonly name: string;
readonly material: THREE.Material;
readonly castShadow: boolean;
readonly receiveShadow: boolean;
readonly parts: THREE.BufferGeometry[];
}
/**
* One build's worth of materials and geometry, merged on the way out.
*
* Buckets are keyed on **name and material together** rather than on the
* material alone. Sharing the material is what saves the draw call; keeping the
* name is what lets somebody looking at the scene graph still find the
* guardrails, and the one extra call it costs where two classes happen to share
* a material is worth being able to debug the thing.
*/
class Batch {
private readonly materials = new Map<string, THREE.Material>();
private readonly buckets = new Map<string, Bucket>();
/** The one material for a kind and colour in this build. */
material(kind: SurfaceKind, color: number): THREE.Material {
const key = `${kind}:${color.toString(16)}`;
const hit = this.materials.get(key);
if (hit) return hit;
const made =
kind === "marking"
? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
: new THREE.MeshLambertMaterial({
color,
side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
});
made.name = key;
this.materials.set(key, made);
return made;
}
add(
name: string,
geometry: THREE.BufferGeometry,
material: THREE.Material,
shadows: { cast?: boolean; receive?: boolean } = {},
): void {
const key = `${material.uuid}|${name}`;
const bucket = this.buckets.get(key);
if (bucket) {
bucket.parts.push(geometry);
return;
}
this.buckets.set(key, {
name,
material,
castShadow: shadows.cast ?? false,
receiveShadow: shadows.receive ?? true,
parts: [geometry],
});
}
/** Merge every bucket and hang the results off `into`. */
flush(into: THREE.Group): void {
for (const bucket of this.buckets.values()) {
const merged =
bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
// `mergeGeometries` returns null when the attribute sets disagree. Losing
// the bucket silently is exactly the failure the module comment warns
// about, so say so rather than rendering a road with no markings on it.
if (!merged) {
console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`);
continue;
}
if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
const mesh = new THREE.Mesh(merged, bucket.material);
mesh.name = bucket.name;
mesh.castShadow = bucket.castShadow;
mesh.receiveShadow = bucket.receiveShadow;
into.add(mesh);
}
this.buckets.clear();
}
}
/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
const out: THREE.Vector3[] = [];
@@ -35,25 +165,32 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14
return out;
}
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
/** A tube swept along a path — a bridge deck, a cable, a barrier. */
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
const curve = new THREE.CatmullRomCurve3(points);
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
mesh.receiveShadow = true;
return mesh;
return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
}
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
function roadRibbon(
/**
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
*
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
* the sane convention if anyone ever puts a surface texture on a road. Right
* now nothing does, and they are here for a duller reason: `mergeGeometries`
* only merges geometries whose attribute sets match exactly, so a strip without
* UVs cannot share a bucket with the tube barriers beside it.
*/
function roadRibbonGeometry(
points: readonly THREE.Vector3[],
width: number,
color: number,
lift = 0,
): THREE.Mesh {
): THREE.BufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
const half = width / 2;
let along = 0;
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
@@ -65,11 +202,13 @@ function roadRibbon(
const length = Math.hypot(dx, dz) || 1;
const nx = -dz / length;
const nz = dx / length;
if (index > 0) along += point.distanceTo(previous);
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);
uvs.push(0, along, 1, along);
if (index < points.length - 1) {
const a = index * 2;
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
@@ -79,14 +218,10 @@ function roadRibbon(
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
return mesh;
return geometry;
}
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
@@ -100,15 +235,15 @@ function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vec
});
}
/** Merge alternating path spans into one dashed marking mesh. */
function dashedRibbon(
/** Merge alternating path spans into one dashed marking geometry. */
function dashedRibbonGeometry(
points: readonly THREE.Vector3[],
offset: number,
width: number,
color: number,
): THREE.Mesh {
): THREE.BufferGeometry {
const shifted = offsetPath(points, offset);
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (let index = 0; index < shifted.length - 1; index += 2) {
const a = shifted[index];
@@ -127,18 +262,15 @@ function dashedRibbon(
b.x + nx, b.y + 0.035, b.z + nz,
b.x - nx, b.y + 0.035, b.z - nz,
);
uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }),
);
mesh.name = "freeway:lane-dashes";
return mesh;
return geometry;
}
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
@@ -178,6 +310,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const plan = buildFreewayWorldPlan(pack);
group.userData.planSeed = plan.seed;
const batch = new Batch();
const asphalt = [0x353a3d, 0x303538];
const shoulder = [0x555759, 0x4e5153];
const berm = [0x64705c, 0x74674c];
@@ -211,6 +344,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
let poleCount = 0;
let siloCount = 0;
const dummy = new THREE.Object3D();
const reflectorMatrices: THREE.Matrix4[] = [];
world.city.roads.forEach((road, roadIndex) => {
if (road.kind !== "freeway") return;
@@ -219,39 +353,58 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
// Broad earthwork under separate decks makes grade and curve changes read.
group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09));
batch.add(
"freeway:berm",
roadRibbonGeometry(path, 2.75, -0.09),
batch.material("deck", berm[identityIndex] ?? berm[0]!),
);
for (const side of [-1, 1] as const) {
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004));
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012));
batch.add(
"freeway:shoulder",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
);
batch.add(
"freeway:carriageway",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
);
// Inner yellow edge, two lane dividers, outer white shoulder edge.
group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038));
group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038));
group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec));
group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec));
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
batch.material("deck", 0xf0c84f),
);
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
batch.material("deck", 0xe8ece8),
);
const dashes = batch.material("marking", 0xf4f4ec);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
const guardPath = offsetPath(path, side * 1.27);
const guard = new THREE.Mesh(
batch.add(
"freeway:outer-guardrail",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
guardMaterial,
{ cast: true },
);
guard.name = "freeway:outer-guardrail";
guard.castShadow = true;
group.add(guard);
}
// Low concrete median walls keep both carriageways visually independent.
for (const side of [-1, 1] as const) {
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
const median = new THREE.Mesh(
batch.add(
"freeway:median-barrier",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
barrierMaterial,
);
median.name = "freeway:median-barrier";
group.add(median);
}
// Retroreflectors are instanced and restrained, never roadside light blobs.
// The matrices are collected across every corridor and committed to one
// `InstancedMesh` after the loop, because two corridors' worth of the same
// 0.018 m box is two draw calls for something nobody can resolve.
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4);
reflectors.name = "freeway:reflectors";
let reflectorIndex = 0;
for (const pointIndex of reflectorPoints.keys()) {
const point = reflectorPoints[pointIndex];
if (!point) continue;
@@ -261,11 +414,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
dummy.updateMatrix();
reflectors.setMatrixAt(reflectorIndex++, dummy.matrix);
reflectorMatrices.push(dummy.matrix.clone());
}
}
reflectors.count = reflectorIndex;
group.add(reflectors);
if (!route || !routePath) return;
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
@@ -278,17 +429,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
const ground = world.groundAt(sample.lat, sample.lng);
if (feature.kind === "route-sign") {
const sign = new THREE.Group();
sign.name = `freeway:sign:${route.shield}`;
const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial);
post.position.y = 0.31;
const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial);
board.position.y = 0.69;
board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0);
sign.add(post, board);
sign.position.set(px, ground + 0.08, pz);
sign.userData.routeId = route.routeId;
group.add(sign);
// Baked into world space rather than parented under a per-sign `Group`.
// Nine signs used to be nine groups of two meshes; they are now two
// meshes for the whole route, and the shield's own name survives on the
// board so the scene graph still says which route it belongs to.
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
post.translate(px, ground + 0.08 + 0.31, pz);
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
const board = new THREE.PlaneGeometry(0.42, 0.31);
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
board.translate(px, ground + 0.08 + 0.69, pz);
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
continue;
}
const visualScale = feature.scale * 0.58;
@@ -309,6 +461,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
}
}
});
batch.flush(group);
const reflectors = new THREE.InstancedMesh(
reflectorGeometry,
reflectorMaterial,
Math.max(1, reflectorMatrices.length),
);
reflectors.name = "freeway:reflectors";
reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
reflectors.count = reflectorMatrices.length;
group.add(reflectors);
trunks.count = trunkCount;
poles.count = poleCount;
silos.count = siloCount;
@@ -321,16 +485,22 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";
const batch = new Batch();
for (const road of world.city.roads) {
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
const path = drapePath(world, road.path);
group.add(roadRibbon(path, road.width, color));
batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", 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));
batch.add(
"road:median-stroke",
roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
batch.material("deck", 0xd7c27c),
);
}
}
batch.flush(group);
return group;
}
@@ -347,32 +517,37 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
const deckY = world.metres(bridge.deckHeight);
const towerY = world.metres(bridge.towerHeight);
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
/**
* One material for the whole bridge, and one mesh out of it.
*
* This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
* and be called once per part, so the Golden Gate arrived as about
* thirty-four meshes with thirty-four identical materials — thirty-four draw
* calls the sorter had to keep apart, for one orange object. Everything a
* bridge is made of is painted the same colour, so everything a bridge is made
* of belongs in one bucket.
*/
const batch = new Batch();
const paint = batch.material("solid", bridge.color);
const part = (geometry: THREE.BufferGeometry) =>
batch.add(bridge.name, geometry, paint, { cast: true });
const deckPoints = bridge.path.map(([lat, lng]) => {
const [x, z] = world.project(lat, lng);
return new THREE.Vector3(x, deckY, z);
});
const deck = ribbon(deckPoints, 0.5, bridge.color);
deck.castShadow = true;
group.add(deck);
part(tubeGeometry(deckPoints, 0.5));
const towerTops: THREE.Vector3[] = [];
for (const [lat, lng] of bridge.towers) {
const [x, z] = world.project(lat, lng);
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
geo.translate(0, towerY / 2, 0);
const tower = new THREE.Mesh(geo, material());
tower.position.set(x, 0, z);
tower.castShadow = true;
group.add(tower);
part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));
// Cross-braces, which is most of what you see of a tower at distance.
for (const frac of [0.55, 0.82]) {
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
brace.position.set(x, towerY * frac, z);
group.add(brace);
part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
}
towerTops.push(new THREE.Vector3(x, towerY, z));
}
@@ -392,12 +567,7 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
p.y -= Math.sin(t * Math.PI) * sag;
pts.push(p);
}
group.add(
new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
material(),
),
);
part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));
// Vertical hangers down to the deck.
for (let s = 2; s < 18; s += 2) {
@@ -406,14 +576,11 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
const top = p.y - Math.sin(t * Math.PI) * sag;
if (top <= deckY + 0.2) continue;
const h = top - deckY;
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
geo.translate(0, h / 2, 0);
const hanger = new THREE.Mesh(geo, material());
hanger.position.set(p.x, deckY, p.z);
group.add(hanger);
part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
}
}
batch.flush(group);
return group;
}