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 */16import * as THREE from "three";17import type { Assets } from "./assets.ts";1819/** One sliced cross-section segment: endpoints in piece-local (x, y)20 * with their colormap UVs, oriented so (b − a) × extrusion points OUT. */21export 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}3233export 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}4041let cached: ChannelSection | null = null;4243/**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 */49export 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}185186/** Station placement callback: writes the world position of a section187 * point (piece-local lateral x, up y) at station `i` into `out`. */188export type SectionPlacer = (189 i: number,190 x: number,191 y: number,192 out: THREE.Vector3,193) => void;194195/**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 */202export 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}