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 */9import * as THREE from "three";10import type { Assets } from "./assets.ts";11import type { SceneCtx } from "./scene.ts";12import { createCameras } from "./camera.ts";13import { fitShadowsTo } from "./scene.ts";14import { AFFINE_IDENTITY, affineApply, composeTwist } from "../shared/frame.ts";15import { TRACK_SCALE } from "../gen/endless/scale.ts";16import { buildChuteMesh, extractChannelSection } from "./twistChute.ts";1718const CELLS_IN = 4;19const CELLS_TWIST = 8;20const CELLS_OUT = 4;21const STEP = 0.5;2223export 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);3334 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();8384 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));9091 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}