clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

Twist chute IS the kit channel: sliced section + static bench

Dami committed 5631179
User goal: twists must read as the existing track being bent, smooth
and indistinguishable. The chute's cross-section is no longer authored
anywhere — twistChute.ts SLICES the kit 'straight' piece at its
mid-plane (per-segment endpoints with interpolated colormap UVs,
orientation from the source triangles) and extrudes that section with
the kit's SHARED materials, so palette variations and chapter pipe
recolors apply exactly like a placed piece, and a 0-degree twist is
literally the same surface as the piece it replaces.

- Bench: ?demo=twist — four kit straights, the chute over eight cells,
  four exit straights presented under the exit frame, flush at both
  ends; keys 1/2/3/4 = 0/+90/-90/180 degrees. The 0-degree A/B reads as
  one unbroken channel.
- Bench finding → fix: long twisted quads are NON-planar; flat
  per-triangle normals over the wide floor band read as a dark
  checkerboard mid-roll. Slice segments subdivide to <=0.16 local width
  and each band extrudes as an INDEXED strip — smooth normals along the
  roll, hard swatch edges across bands (coplanar straight runs still
  shade flat, the kit look).
- Endless integration: rows end EXACTLY on the run's boundary stations
  (identical surfaces would z-fight over any overlap), section floor
  height measured from the slice (no hardcoded 0.25), chapter recolor
  via assets.applyPipeColor, and segment disposal keeps the SHARED kit
  materials alive (geometry-only dispose).

Verified on the bench (0/90/180) and in-game: biome-3 (green chapter)
and meow (yellow) twists read as the track itself corkscrewing.
154 tests, check clean.
commit
5631179d5fabf95a46692550721ccde774394a58
parent
dd7b658d490f6e95243d6fd8d8f03590d0dc87f1
author
Dami <dami.etoile@gmail.com>
4 files changed, +393 -108
Msrc/client/endless.ts132+++------------
Msrc/client/main.ts5+-
Asrc/client/twistChute.ts261++++++++++++++++++++++++++++++
Asrc/client/twistDemo.ts103++++++++++++
M src/client/endless.ts +25 -107
@@ -86,6 +86,7 @@ import {
8686 } from "../gen/endless/proxies.ts";8787 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";8888 import { FanField, fanVolumesOf } from "./fanField.ts";89+import { buildChuteMesh, extractChannelSection } from "./twistChute.ts";8990 import { Atmosphere, drawZone } from "./atmosphere.ts";9091 import type { DecorCatalog } from "../gen/decor.ts";9192 import { genVersion } from "../gen/version.ts";
@@ -1188,9 +1189,11 @@ class Session {
11881189 disposables.push({11891190 meshes: [],11901191 dispose: () => {1192+ // Geometry only — the chute SHARES the kit's materials1193+ // (that's what makes it indistinguishable); disposing them1194+ // would strip every placed piece.11911195 this.root.remove(mesh);11921196 mesh.geometry.dispose();1193- for (const m of mesh.material as THREE.Material[]) m.dispose();11941197 },11951198 } as unknown as InstancedBuild);11961199 }
@@ -1245,135 +1248,50 @@ class Session {
12451248 return seg;12461249 }12471250 1248- /** The reorient run's chute: the channel cross-section extruded along1249- * the rail's FRAMED stations — each ring rolls a little further, so1250- * the chute corkscrews from the entry frame into the exit frame. NOT1251- * a paper ribbon (user report): the section is the track's closed1252- * U — the collision profile as the blue interior, an offset outer1253- * shell + rim tops + end caps in the chapter's pipe color, exactly1254- * the bent version of the kit channel look. */1251+ /** The reorient run's chute: the kit channel's SLICED cross-section1252+ * (twistChute.ts — the kit `straight` piece's own section, UVs and1253+ * shared materials) extruded along the rail's FRAMED stations. Rows1254+ * end EXACTLY on the run's boundary stations so the chute sits flush1255+ * against the neighboring kit pieces (an overlap would z-fight — the1256+ * surfaces are identical by construction). Chapter pipe recolor1257+ * applies like any placed piece. */12551258 private buildTwistChute(12561259 rail: Rail,12571260 fr: SegmentFrame,12581261 shellRgb: readonly [number, number, number] | null,12591262 ): THREE.Mesh | null {1263+ const section = extractChannelSection(this.assets);1264+ if (!section) return null;12601265 const tw = fr.twist!;12611266 const frames = rail.frames;12621267 if (!Array.isArray(frames)) return null;12631268 const rows: number[] = [];12641269 for (let i = 0; i < rail.pts.length; i++) {12651270 const t = twistParam(fr, rail.pts[i]);1266- if (t < -0.6 || t > tw.len + 0.6) continue;1271+ if (t < -1e-6 || t > tw.len + 1e-6) continue;12671272 rows.push(i);12681273 }12691274 if (rows.length < 2) return null;1270-1271- // Closed section ring: inner U (the collision profile, left→right)1272- // + outer U offset outward by the wall thickness (right→left). The1273- // ring closes across the two wall tips — the flat rim tops.1274- const N = CHANNEL_PROFILE.length;1275- const T = 0.14;1276- const inner = CHANNEL_PROFILE;1277- const normals: [number, number][] = inner.map((_, i) => {1278- const a = inner[Math.max(0, i - 1)];1279- const b = inner[Math.min(N - 1, i + 1)];1280- const dx = b[0] - a[0];1281- const dy = b[1] - a[1];1282- const l = Math.hypot(dx, dy) || 1;1283- // Perp of the local direction, oriented AWAY from the channel1284- // interior (reference point on the centerline above the floor).1285- let nx = dy / l;1286- let ny = -dx / l;1287- if (nx * (inner[i][0] - 0) + ny * (inner[i][1] - 0.5) < 0) {1288- nx = -nx;1289- ny = -ny;1290- }1291- return [nx, ny];1292- });1293- const ring: [number, number][] = [1294- ...inner.map((p) => [p[0], p[1]] as [number, number]),1295- ...inner1296- .map((p, i): [number, number] => [1297- p[0] + normals[i][0] * T,1298- p[1] + normals[i][1] * T,1299- ])1300- .reverse(),1301- ];1302- const R = ring.length; // 2N1303-1304- const positions = new Float32Array((rows.length + 2) * R * 3);1275+ const S = TRACK_SCALE;13051276 const scratch: [number, number, number] = [0, 0, 0];1306- let vi = 0;1307- const writeRow = (i: number) => {1277+ const mesh = buildChuteMesh(section, rows.length, (r, x, y, out) => {1278+ const i = rows[r];13081279 const p = rail.pts[i];13091280 const tan = rail.tans[i];13101281 // The run is straight and flat in plan space — lateral is the1311- // horizontal perp of the tangent, up is +y.1282+ // horizontal perp of the tangent; the rail line rides the channel1283+ // floor, so the section drops by its own measured floor height.13121284 const nl = Math.hypot(tan[0], tan[2]) || 1;13131285 const ux = tan[2] / nl;13141286 const uz = -tan[0] / nl;1287+ scratch[0] = p[0] + ux * x * S;1288+ scratch[1] = p[1] + (y - section.floorY) * S;1289+ scratch[2] = p[2] + uz * x * S;13151290 const f = frames[i] ?? fr.entry;1316- for (const [lat, up] of ring) {1317- scratch[0] = p[0] + ux * lat;1318- scratch[1] = p[1] + up;1319- scratch[2] = p[2] + uz * lat;1320- const w = affineApply(f, scratch);1321- positions[vi++] = w[0];1322- positions[vi++] = w[1];1323- positions[vi++] = w[2];1324- }1325- };1326- for (const i of rows) writeRow(i);1327- // Cap rows: duplicated first/last rings so cap shading stays crisp.1328- writeRow(rows[0]);1329- writeRow(rows[rows.length - 1]);1330-1331- const innerIdx: number[] = [];1332- const shellIdx: number[] = [];1333- for (let r = 0; r < rows.length - 1; r++) {1334- for (let c = 0; c < R; c++) {1335- const c1 = (c + 1) % R;1336- const a = r * R + c;1337- const b = r * R + c1;1338- const a2 = (r + 1) * R + c;1339- const b2 = (r + 1) * R + c1;1340- const into = c < N - 1 ? innerIdx : shellIdx;1341- into.push(a, a2, b, b, a2, b2);1342- }1343- }1344- // End caps: triangulate the (simple, non-convex) section polygon.1345- const shape = ring.map(([x, y]) => new THREE.Vector2(x, y));1346- const tris = THREE.ShapeUtils.triangulateShape(shape, []);1347- const capA = rows.length * R;1348- const capB = capA + R;1349- for (const [a, b, c] of tris) {1350- shellIdx.push(capA + a, capA + c, capA + b); // entry cap faces back1351- shellIdx.push(capB + a, capB + b, capB + c);1352- }1353-1354- const geo = new THREE.BufferGeometry();1355- geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));1356- geo.setIndex([...innerIdx, ...shellIdx]);1357- geo.addGroup(0, innerIdx.length, 0);1358- geo.addGroup(innerIdx.length, shellIdx.length, 1);1359- geo.computeVertexNormals();1360- // Kit look: fixed channel-interior blue; outer shell in the chapter1361- // pipe color (stock kit plastic when the chapter kept the classic).1362- const shell = shellRgb ?? [136, 150, 166];1363- const matInner = new THREE.MeshStandardMaterial({1364- color: new THREE.Color(58 / 255, 106 / 255, 200 / 255),1365- roughness: 0.6,1366- metalness: 0.05,1367- side: THREE.DoubleSide,1368- });1369- const matShell = new THREE.MeshStandardMaterial({1370- color: new THREE.Color(shell[0] / 255, shell[1] / 255, shell[2] / 255),1371- roughness: 0.55,1372- metalness: 0.05,1373- side: THREE.DoubleSide,1291+ affineApply(f, scratch, scratch);1292+ out.set(scratch[0], scratch[1], scratch[2]);13741293 });1375- const mesh = new THREE.Mesh(geo, [matInner, matShell]);1376- mesh.name = "twist-chute";1294+ if (shellRgb) this.assets.applyPipeColor(mesh, shellRgb);13771295 return mesh;13781296 }13791297
M src/client/main.ts +4 -1
A src/client/twistChute.ts +261 -0
@@ -0,0 +1,261 @@
1+/**2+ * Twist chute (M16) — the reorient run's corkscrew, built to be3+ * INDISTINGUISHABLE from the kit channel (user goal): the cross-section4+ * is not authored here at all — it is SLICED from the kit `straight`5+ * piece itself (plane z=0 in piece-local space), every slice segment6+ * keeping its interpolated UVs into the kit colormap, and the extrusion7+ * reuses the kit's SHARED materials. Palette variations and chapter8+ * pipe recolors therefore apply exactly like any placed piece, and a 0°9+ * twist is the same surface as the piece it replaces (the demo's A/B).10+ *11+ * Segments extrude INDEPENDENTLY (no vertex welding): the kit look is12+ * flat swatch bands with hard edges, and non-indexed triangles +13+ * computeVertexNormals reproduce the flat shading. No end caps — the14+ * chute always sits flush between kit pieces.15+ */16+import * as THREE from "three";17+import type { Assets } from "./assets.ts";18+19+/** One sliced cross-section segment: endpoints in piece-local (x, y)20+ * with their colormap UVs, oriented so (b − a) × extrusion points OUT. */21+export interface SectionSeg {22+ ax: number;23+ ay: number;24+ au: number;25+ av: number;26+ bx: number;27+ by: number;28+ bu: number;29+ bv: number;30+ mat: number;31+}32+33+export interface ChannelSection {34+ segs: SectionSeg[];35+ /** Shared kit materials, indexed by SectionSeg.mat. */36+ materials: THREE.Material[];37+ /** Piece-local y of the channel floor (the rail line's height). */38+ floorY: number;39+}40+41+let cached: ChannelSection | null = null;42+43+/**44+ * Slice the kit `straight` piece at its mid-plane. Piece-local frame:45+ * channel runs along z, lateral is x, up is y; the channel floor sits at46+ * local y ≈ 0.25 (the measured kit fact) — recomputed here from the47+ * slice so the chute needs no hardcoded numbers.48+ */49+export function extractChannelSection(assets: Assets): ChannelSection | null {50+ if (cached) return cached;51+ const piece = assets.getPiece("straight");52+ if (!piece) return null;53+ piece.updateMatrixWorld(true);54+ const segs: SectionSeg[] = [];55+ const materials: THREE.Material[] = [];56+ const matIndexOf = (m: THREE.Material): number => {57+ let i = materials.indexOf(m);58+ if (i < 0) {59+ i = materials.length;60+ materials.push(m);61+ }62+ return i;63+ };64+ const va = new THREE.Vector3();65+ const vb = new THREE.Vector3();66+ const vc = new THREE.Vector3();67+ piece.traverse((o: THREE.Object3D) => {68+ const mesh = o as THREE.Mesh;69+ if (!mesh.isMesh) return;70+ const geo = mesh.geometry as THREE.BufferGeometry;71+ const pos = geo.getAttribute("position");72+ const uv = geo.getAttribute("uv");73+ const index = geo.getIndex();74+ const triCount = (index ? index.count : pos.count) / 3;75+ const groups = geo.groups.length > 0 ? geo.groups : [{76+ start: 0,77+ count: index ? index.count : pos.count,78+ materialIndex: 0,79+ }];80+ const matOf = (tri: number): THREE.Material => {81+ const at = tri * 3;82+ for (const g of groups) {83+ if (at >= g.start && at < g.start + g.count) {84+ const mats = Array.isArray(mesh.material)85+ ? mesh.material86+ : [mesh.material];87+ return mats[g.materialIndex ?? 0] ?? mats[0];88+ }89+ }90+ return Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;91+ };92+ const read = (tri: number, corner: number, out: THREE.Vector3): number => {93+ const i = index ? index.getX(tri * 3 + corner) : tri * 3 + corner;94+ out.fromBufferAttribute(pos as THREE.BufferAttribute, i);95+ mesh.localToWorld(out);96+ return i;97+ };98+ for (let t = 0; t < triCount; t++) {99+ const ia = read(t, 0, va);100+ const ib = read(t, 1, vb);101+ const ic = read(t, 2, vc);102+ const z = [va.z, vb.z, vc.z];103+ const pts = [va, vb, vc];104+ const idxs = [ia, ib, ic];105+ // Edges crossing z = 0 → up to one slice segment per triangle.106+ const hits: { x: number; y: number; u: number; v: number }[] = [];107+ for (let e = 0; e < 3; e++) {108+ const f = (e + 1) % 3;109+ if ((z[e] < 0) === (z[f] < 0)) continue;110+ const k = z[e] / (z[e] - z[f]);111+ hits.push({112+ x: pts[e].x + (pts[f].x - pts[e].x) * k,113+ y: pts[e].y + (pts[f].y - pts[e].y) * k,114+ u: uv.getX(idxs[e]) + (uv.getX(idxs[f]) - uv.getX(idxs[e])) * k,115+ v: uv.getY(idxs[e]) + (uv.getY(idxs[f]) - uv.getY(idxs[e])) * k,116+ });117+ }118+ if (hits.length !== 2) continue;119+ let [a, b] = hits;120+ const dx = b.x - a.x;121+ const dy = b.y - a.y;122+ if (Math.hypot(dx, dy) < 1e-5) continue;123+ // Orient so the extruded quad's outward normal ((b−a) × +z ∝124+ // (dy, −dx)) matches the source triangle's facing.125+ const e1 = vb.clone().sub(va);126+ const e2 = vc.clone().sub(va);127+ const n = e1.cross(e2);128+ if (dy * n.x - dx * n.y < 0) [a, b] = [b, a];129+ segs.push({130+ ax: a.x,131+ ay: a.y,132+ au: a.u,133+ av: a.v,134+ bx: b.x,135+ by: b.y,136+ bu: b.u,137+ bv: b.v,138+ mat: matIndexOf(matOf(t)),139+ });140+ }141+ });142+ if (segs.length === 0) return null;143+ // Subdivide wide slice segments: a twisted quad is NON-planar — flat144+ // per-triangle normals over a 1.5-unit floor band read as a dark145+ // checkerboard mid-roll (bench finding). Short bands + smooth-along-146+ // strip normals keep the roll continuous while straight runs stay147+ // visually flat (coplanar faces share identical normals anyway).148+ const MAX_W = 0.16;149+ const fine: SectionSeg[] = [];150+ for (const s of segs) {151+ const w = Math.hypot(s.bx - s.ax, s.by - s.ay);152+ const n = Math.max(1, Math.ceil(w / MAX_W));153+ for (let i = 0; i < n; i++) {154+ const t0 = i / n;155+ const t1 = (i + 1) / n;156+ fine.push({157+ ax: s.ax + (s.bx - s.ax) * t0,158+ ay: s.ay + (s.by - s.ay) * t0,159+ au: s.au + (s.bu - s.au) * t0,160+ av: s.av + (s.bv - s.av) * t0,161+ bx: s.ax + (s.bx - s.ax) * t1,162+ by: s.ay + (s.by - s.ay) * t1,163+ bu: s.au + (s.bu - s.au) * t1,164+ bv: s.av + (s.bv - s.av) * t1,165+ mat: s.mat,166+ });167+ }168+ }169+ segs.length = 0;170+ segs.push(...fine);171+ // Channel floor: the lowest slice point near the centerline (|x| small)172+ // that has surface ABOVE it is the floor line the rail rides — in173+ // practice the minimum y among near-center points of upward-facing174+ // segments. The kit fact says 0.25; measure it instead of trusting it.175+ let floorY = Infinity;176+ for (const s of segs) {177+ for (const [x, y] of [[s.ax, s.ay], [s.bx, s.by]] as const) {178+ if (Math.abs(x) < 0.2 && y > 0.05) floorY = Math.min(floorY, y);179+ }180+ }181+ if (!Number.isFinite(floorY)) floorY = 0.25;182+ cached = { segs, materials, floorY };183+ return cached;184+}185+186+/** Station placement callback: writes the world position of a section187+ * point (piece-local lateral x, up y) at station `i` into `out`. */188+export type SectionPlacer = (189+ i: number,190+ x: number,191+ y: number,192+ out: THREE.Vector3,193+) => void;194+195+/**196+ * Extrude the sliced section along `count` stations. Each section197+ * segment becomes its own INDEXED strip — vertices shared along the198+ * extrusion (smooth normals through the roll; coplanar straight runs199+ * still shade flat) but never across segments (hard swatch-band edges,200+ * the kit look); winding and UVs come straight from the slice.201+ */202+export function buildChuteMesh(203+ section: ChannelSection,204+ count: number,205+ place: SectionPlacer,206+): THREE.Mesh {207+ const segsByMat = new Map<number, SectionSeg[]>();208+ for (const s of section.segs) {209+ const list = segsByMat.get(s.mat) ?? [];210+ list.push(s);211+ segsByMat.set(s.mat, list);212+ }213+ const vertsPerStrip = count * 2;214+ const totalVerts = section.segs.length * vertsPerStrip;215+ const positions = new Float32Array(totalVerts * 3);216+ const uvs = new Float32Array(totalVerts * 2);217+ const indices: number[] = [];218+ const p = new THREE.Vector3();219+ let vi = 0;220+ const geo = new THREE.BufferGeometry();221+ const matsUsed: THREE.Material[] = [];222+ for (const [mat, segs] of segsByMat) {223+ const start = indices.length;224+ for (const s of segs) {225+ const base = vi;226+ for (let i = 0; i < count; i++) {227+ place(i, s.ax, s.ay, p);228+ positions[vi * 3] = p.x;229+ positions[vi * 3 + 1] = p.y;230+ positions[vi * 3 + 2] = p.z;231+ uvs[vi * 2] = s.au;232+ uvs[vi * 2 + 1] = s.av;233+ vi++;234+ place(i, s.bx, s.by, p);235+ positions[vi * 3] = p.x;236+ positions[vi * 3 + 1] = p.y;237+ positions[vi * 3 + 2] = p.z;238+ uvs[vi * 2] = s.bu;239+ uvs[vi * 2 + 1] = s.bv;240+ vi++;241+ }242+ for (let i = 0; i < count - 1; i++) {243+ const a0 = base + i * 2;244+ const b0 = a0 + 1;245+ const a1 = a0 + 2;246+ const b1 = a0 + 3;247+ // CCW with the outward normal encoded by the slice's a→b order.248+ indices.push(a0, b0, a1, a1, b0, b1);249+ }250+ }251+ geo.addGroup(start, indices.length - start, matsUsed.length);252+ matsUsed.push(section.materials[mat]);253+ }254+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));255+ geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));256+ geo.setIndex(indices);257+ geo.computeVertexNormals(); // shared along strips → smooth roll258+ const mesh = new THREE.Mesh(geo, matsUsed);259+ mesh.name = "twist-chute";260+ return mesh;261+}
A src/client/twistDemo.ts +103 -0
@@ -0,0 +1,103 @@
1+/**2+ * Twist bench (?demo=twist) — a static A/B rig for the reorient chute:3+ * four kit straights, the chute over eight cells, four more kit4+ * straights presented under the exit frame, connected FLUSH at both5+ * ends. Keys 1/2/3/4 set the roll (0° / +90° / −90° / 180°); 0° must6+ * read as one unbroken kit channel (the chute IS the sliced kit7+ * section), the rolls as that same channel bending.8+ */9+import * as THREE from "three";10+import type { Assets } from "./assets.ts";11+import type { SceneCtx } from "./scene.ts";12+import { createCameras } from "./camera.ts";13+import { fitShadowsTo } from "./scene.ts";14+import { AFFINE_IDENTITY, affineApply, composeTwist } from "../shared/frame.ts";15+import { TRACK_SCALE } from "../gen/endless/scale.ts";16+import { buildChuteMesh, extractChannelSection } from "./twistChute.ts";17+18+const CELLS_IN = 4;19+const CELLS_TWIST = 8;20+const CELLS_OUT = 4;21+const STEP = 0.5;22+23+export function buildTwistDemo(ctx: SceneCtx, assets: Assets): void {24+ const section = extractChannelSection(assets);25+ if (!section) {26+ console.warn("[twist-demo] no straight piece — cannot build");27+ return;28+ }29+ const S = TRACK_SCALE;30+ const group = new THREE.Group();31+ group.name = "twist-demo";32+ ctx.scene.add(group);33+34+ let angle = Math.PI / 2;35+ const rebuild = () => {36+ group.clear();37+ // Entry straights: cells −4..−1 (channel along +z, stock rotation).38+ for (let k = -CELLS_IN; k < 0; k++) {39+ const obj = assets.getPiece("straight");40+ if (!obj) continue;41+ obj.position.set(0, 0, k * S);42+ obj.scale.setScalar(S);43+ group.add(obj);44+ }45+ // The twist spans cell edges: from the entry run's last edge to the46+ // exit run's first, pivoting on the channel-floor centerline.47+ const zA = -S / 2;48+ const len = CELLS_TWIST * S;49+ const pivot: [number, number, number] = [0, section.floorY * S, zA];50+ const count = Math.round(len / STEP) + 1;51+ const scratch: [number, number, number] = [0, 0, 0];52+ const chute = buildChuteMesh(section, count, (i, x, y, out) => {53+ const z = zA + i * STEP;54+ const u = Math.min(1, Math.max(0, (z - zA) / len));55+ const f = u <= 0 ? AFFINE_IDENTITY : composeTwist(56+ AFFINE_IDENTITY,57+ [0, 0, 1],58+ angle * u,59+ pivot,60+ );61+ scratch[0] = x * S;62+ scratch[1] = y * S;63+ scratch[2] = z;64+ affineApply(f, scratch, scratch);65+ out.set(scratch[0], scratch[1], scratch[2]);66+ });67+ group.add(chute);68+ // Exit straights: cells +8.. under the full exit frame.69+ const exit = composeTwist(AFFINE_IDENTITY, [0, 0, 1], angle, pivot);70+ const post = new THREE.Group();71+ post.quaternion.set(exit.q[0], exit.q[1], exit.q[2], exit.q[3]);72+ post.position.set(exit.t[0], exit.t[1], exit.t[2]);73+ for (let k = CELLS_TWIST; k < CELLS_TWIST + CELLS_OUT; k++) {74+ const obj = assets.getPiece("straight");75+ if (!obj) continue;76+ obj.position.set(0, 0, k * S);77+ obj.scale.setScalar(S);78+ post.add(obj);79+ }80+ group.add(post);81+ };82+ rebuild();83+84+ const box = new THREE.Box3().setFromObject(group);85+ fitShadowsTo(ctx.sun, box);86+ ctx.scene.fog = null;87+ const cams = createCameras(ctx, null, null);88+ cams.frame(box);89+ ctx.onFrame.add((dt) => cams.update(dt));90+91+ addEventListener("keydown", (ev) => {92+ const pick: Record<string, number> = {93+ "1": 0,94+ "2": Math.PI / 2,95+ "3": -Math.PI / 2,96+ "4": Math.PI,97+ };98+ if (ev.key in pick) {99+ angle = pick[ev.key];100+ rebuild();101+ }102+ });103+}