clone

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

main
marbles / src / gen / assemble.ts
assemble.ts 15.6K
1/**2 * Assembly: turns realized windows into world placements (track + supports)3 * and the closed loop centerline. Pure data out — the seam with the renderer.4 */5import type {6 CurveSeg,7 Dir,8 LoopPath,9 LoopSegment,10 Placement,11 Vec3,12} from "../shared/types.ts";13import type { Skeleton } from "./topology.ts";14import type { ElevationPlan } from "./elevation.ts";15import type { StyleField } from "./style.ts";16import type { RealizedWindow } from "./wfc.ts";17import {18 applyXform,19 segLength,20 segPoint,21 windowXform,22 type Xform,23 xformSeg,24} from "./geom.ts";25import { windowCells } from "./registry.ts";26import { emitSupportStack } from "./supports.ts";2728export interface AssembledTrack {29 placements: Placement[];30 loop: LoopPath;31 trackCells: Set<string>;32 maxDeckY: number;33}3435export interface AssembleOptions {36 /**37 * Open path in the void (endless mode): no ground exists, so support38 * stacks, the goal banner, and solid ground-variants are all skipped —39 * those are ground concepts. Also disables the ring-wrap adjacency used40 * by solid support checks. Default (closed loop) is unaffected.41 */42 open?: boolean;43 /**44 * Endless ground vocabulary: local ground height at a cell index — an45 * island run cell riding its own terrace (null off-island or bridging46 * above it). When every cell of a window is grounded, solid piece47 * forms come back in open mode: an island top IS local ground.48 */49 localGroundY?: (cellIdx: number) => number | null;50 /**51 * Endless: island deck height at a world cell ("x,z" key) — the52 * planner's mass-claim record. Extends the solid neighbor check53 * beyond ring cells (the island deck abutting a shoreline turn).54 */55 deckAt?: (cellKey: string) => number | undefined;56}5758const key = (x: number, z: number) => `${x},${z}`;5960/**61 * Supports mimic real structural falsework rather than one-pillar-per-cell:62 * every contiguous elevated span gets a pillar at each end (abutments) and63 * periodic piers whenever the deck has run SUPPORT_SPAN since the last one.64 * A global clearance keeps pillars from doubling up where spans run close65 * together (hairpin columns, U-turn arms) — a nearby pillar visually carries66 * both decks.67 */68const SUPPORT_SPAN = 2.4;69const SUPPORT_CLEARANCE = 1.0;7071export function assemble(72 skeleton: Skeleton,73 elevation: ElevationPlan,74 style: StyleField,75 windows: RealizedWindow[],76 opts: AssembleOptions = {},77): AssembledTrack {78 const placements: Placement[] = [];79 const segments: LoopSegment[] = [];80 const trackCells = new Set<string>();81 let totalLength = 0;82 const n = skeleton.cells.length;8384 // Deck base height per RING cell — used to decide whether a solid85 // variant's extended surface actually abuts same-height deck. Ring cells86 // only: footprint extras (funnel flanks, bulge margins) are mostly void,87 // not deck, and must not count as support.88 const cellHeight = new Map<string, number>();89 skeleton.cells.forEach((c, i) =>90 cellHeight.set(key(c.x, c.z), elevation.baseY[i])91 );9293 /**94 * A solid turn block reads as intended only when its extended surface95 * connects: some orthogonal neighbor of the footprint (not the two96 * ring-port cells, which touch every piece by construction) carries track97 * at the same base height.98 */99 const solidSupported = (win: RealizedWindow, xf: Xform): boolean => {100 const own = new Set<string>();101 const cells: [number, number][] = [];102 for (const [fx, fz] of win.def.footprint) {103 const w = applyXform(xf, [fx, 0, fz]);104 const cx = Math.round(w[0]);105 const cz = Math.round(w[2]);106 own.add(key(cx, cz));107 cells.push([cx, cz]);108 }109 // Open paths have no seam: clamp the port cells instead of wrapping110 // (wrapped indices would name bogus cells at both ends).111 const prevIdx = opts.open ? win.start - 1 : (win.start - 1 + n) % n;112 const nextIdx = opts.open113 ? win.start + win.def.moves.length114 : (win.start + win.def.moves.length) % n;115 const ports = new Set<string>();116 for (const idx of [prevIdx, nextIdx]) {117 const c = skeleton.cells[idx];118 if (c) ports.add(key(c.x, c.z));119 }120 const baseY = elevation.baseY[win.start % n];121 for (const [cx, cz] of cells) {122 for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {123 const k = key(cx + dx, cz + dz);124 if (own.has(k) || ports.has(k)) continue;125 if (cellHeight.get(k) === baseY) return true;126 // Island deck abutting the footprint counts too (endless).127 if (opts.deckAt?.(k) === baseY) return true;128 }129 }130 return false;131 };132133 const supportPrefix = style.supportColor ? "support-color" : "support";134 const supportCandidates: {135 x: number;136 z: number;137 height: number;138 dir: Dir;139 cradle: boolean;140 stretch: number;141 /** Stack base elevation (raised viaduct piers stand on the deck). */142 baseAt: number;143 }[] = [];144 let stretch = 0;145146 // Twice-visited cells (crossings, bridge decks): the ground there belongs147 // to the lower channel — a pillar would stand in it. Piers land on the148 // neighboring deck cells instead. The goal banner also skips them.149 const crossingCells = new Set(150 skeleton.crossings.map(({ a }) =>151 key(skeleton.cells[a].x, skeleton.cells[a].z)152 ),153 );154155 for (const win of windows) {156 const cell = skeleton.cells[win.start];157 const baseY = elevation.baseY[win.start];158 const xf: Xform = windowXform(cell.x, cell.z, cell.dirIn, baseY);159 const def = win.def;160161 // Solids are ground-only (user rule). Closed loops: world ground at162 // baseY <= 0. Open paths: an island top IS local ground — the window163 // must sit entirely on its terrace (localGroundY matches every cell).164 const grounded = opts.open165 ? opts.localGroundY !== undefined &&166 Array.from(167 { length: def.moves.length },168 (_, j) => win.start + j,169 ).every((idx) => opts.localGroundY!(idx) === elevation.baseY[idx]) &&170 style.solidAt(0)171 : style.solidAt(baseY);172 const useSolid = grounded && def.solidId !== undefined &&173 (!def.solidExtends || solidSupported(win, xf));174 // −1 for noMesh windows: their loop segment has no placement of its own.175 const placementIndex = def.noMesh ? -1 : placements.length;176 const rotation = ((xf.r + def.meshRot) % 4) as Dir;177 // noMesh windows (a crossing's second visit) contribute centerline and178 // occupancy only — the shared cell's mesh comes from the first visit.179 if (!def.noMesh) {180 placements.push({181 pieceId: useSolid ? def.solidId! : (def.meshId ?? def.id),182 pos: applyXform(xf, def.meshOffset),183 rotation,184 ...(def.mirrored ? { mirrored: true } : {}),185 role: "track",186 });187 }188 for (const overlay of def.overlays ?? []) {189 // Overlay offsets are absolute entry-frame positions of the overlay190 // mesh origin (not relative to the base mesh).191 placements.push({192 pieceId: overlay.id,193 pos: applyXform(xf, overlay.offset),194 rotation: ((xf.r + (overlay.rot ?? 0)) % 4) as Dir,195 ...(overlay.mirrored ? { mirrored: true } : {}),196 role: "track",197 });198 }199200 // Track cells (full footprint) for occupancy-aware decor later.201 for (const [fx, fz] of def.footprint) {202 const w = applyXform(xf, [fx, 0, fz]);203 trackCells.add(key(Math.round(w[0]), Math.round(w[2])));204 }205206 // Centerline segments in world space.207 const curve: CurveSeg[] = def.centerline.map((s) => xformSeg(xf, s));208 const length = curve.reduce((acc, s) => acc + segLength(s), 0);209 const alt = def.altCenterline210 ? (() => {211 const altCurve = def.altCenterline!.map((s) => xformSeg(xf, s));212 return {213 curve: altCurve,214 length: altCurve.reduce((acc, s) => acc + segLength(s), 0),215 };216 })()217 : undefined;218 segments.push({219 placementIndex,220 curve,221 length,222 ...(alt ? { alt } : {}),223 ...(def.holes224 ? { holes: def.holes.map((h) => applyXform(xf, h)) }225 : {}),226 ...(def.junction ? { junction: def.junction } : {}),227 });228 totalLength += length;229230 // Supports: flat elevated pieces get a stack per channel cell; climbs get231 // one stack at whichever end is elevated (entry for descents, exit-side232 // handled by the neighboring flat window). Void mode: piers exist ONLY233 // where the planner recorded a DECK below the whole window (raised234 // viaduct crossings, USER round) — the stacks stand on the island top;235 // everywhere else the void line floats.236 const deckBelow = opts.open && opts.deckAt !== undefined237 ? (() => {238 const cells = windowCells(def);239 let drop: number | undefined;240 for (let j = 0; j < cells.length; j++) {241 const idx = win.start + j;242 const c = skeleton.cells[idx % skeleton.cells.length];243 const top = opts.deckAt!(key(c.x, c.z));244 if (top === undefined) return undefined;245 const d = elevation.baseY[idx] - top;246 if (d < 0.499) return undefined;247 if (drop === undefined) drop = d;248 else if (Math.abs(drop - d) > 1e-6) return undefined;249 }250 return drop;251 })()252 : undefined;253 if (254 def.deckDelta === 0 &&255 (opts.open ? deckBelow !== undefined : baseY > 0)256 ) {257 const stackH = opts.open ? deckBelow! : baseY;258 const stackBase = opts.open ? baseY - deckBelow! : 0;259 const cells = windowCells(def);260 for (let j = 0; j < cells.length; j++) {261 const c = skeleton.cells[(win.start + j) % skeleton.cells.length];262 if (crossingCells.has(key(c.x, c.z))) continue;263 if (c.move === "F" && !def.columnSupports) {264 supportCandidates.push({265 x: c.x,266 z: c.z,267 height: stackH,268 dir: c.dirIn,269 cradle: true,270 stretch,271 baseAt: stackBase,272 });273 } else {274 // Turn cells and off-center channels (waves, bends, wide): a275 // pillar is only allowed where it actually touches deck, so anchor276 // it under the channel by snapping to the nearest centerline277 // point. Plain column — the cradle can't align to a diagonal278 // tangent or displaced lane.279 const p = nearestOnCurve(curve, c.x, c.z);280 supportCandidates.push({281 x: p[0],282 z: p[2],283 height: stackH,284 dir: c.dirIn,285 cradle: false,286 stretch,287 baseAt: stackBase,288 });289 }290 }291 // Per-branch diamond supports (M13): an elevated diamond's window292 // cells follow the MAIN branch, so its columns covered only that293 // lane — the OTHER branch deck hung unsupported. Snap a second294 // candidate line onto the alt centerline; the span/clearance greedy295 // thins both lanes together.296 // The alt lane runs PARALLEL to the main lane, so it must be its297 // own greedy group (stretch + 0.5): measured against main-lane298 // pillars it never accumulates a full span of gap and every299 // candidate is skipped. Clearance still dedupes the split/merge300 // mouths where the lanes converge.301 if (alt) {302 for (let j = 0; j < cells.length; j++) {303 const c = skeleton.cells[(win.start + j) % skeleton.cells.length];304 if (crossingCells.has(key(c.x, c.z))) continue;305 const p = nearestOnCurve(alt.curve, c.x, c.z);306 supportCandidates.push({307 x: p[0],308 z: p[2],309 height: stackH,310 dir: c.dirIn,311 cradle: false,312 stretch: stretch + 0.5,313 baseAt: stackBase,314 });315 }316 }317 } else {318 // Ground-level or climbing window: closes any elevated stretch.319 stretch++;320 }321 }322323 // Goal gate: a banner straddling the channel marks the lap line — its324 // post feet land exactly on the rail tops (post centers ±0.425 = wall-top325 // centers, measured; cloth underside at +0.75 clears the marble by 0.6).326 // First plain straight window in ring order keeps it deterministic.327 // Endless mode has no lap line (segments hold no ceremony).328 for (const win of opts.open ? [] : windows) {329 if (win.def.id !== "straight") continue;330 const cell = skeleton.cells[win.start % n];331 if (crossingCells.has(key(cell.x, cell.z))) continue;332 placements.push({333 pieceId: "banner-high",334 pos: [cell.x, elevation.baseY[win.start % n] + 0.5, cell.z],335 rotation: ((1 - cell.dirIn + 4) % 4) as Dir,336 role: "track",337 goal: true,338 });339 break;340 }341342 // Structural density rule (deterministic, no RNG): per elevated stretch,343 // keep the two ends plus piers at SUPPORT_SPAN intervals, subject to the344 // global clearance.345 const kept: { x: number; z: number }[] = [];346 const nearKept = (x: number, z: number, d: number) =>347 kept.some((k) => Math.hypot(k.x - x, k.z - z) < d);348 const byStretch = new Map<number, typeof supportCandidates>();349 for (const cand of supportCandidates) {350 const list = byStretch.get(cand.stretch) ?? [];351 list.push(cand);352 byStretch.set(cand.stretch, list);353 }354 for (const group of byStretch.values()) {355 let last: { x: number; z: number } | null = null;356 for (let i = 0; i < group.length; i++) {357 const cand = group[i];358 const isEnd = i === 0 || i === group.length - 1;359 const gap = last360 ? Math.hypot(cand.x - last.x, cand.z - last.z)361 : Infinity;362 if (!isEnd && gap < SUPPORT_SPAN) continue;363 if (nearKept(cand.x, cand.z, SUPPORT_CLEARANCE)) continue;364 kept.push({ x: cand.x, z: cand.z });365 last = cand;366 emitSupportStack(367 placements,368 supportPrefix,369 style.supportKind,370 cand.x,371 cand.z,372 cand.height,373 cand.dir,374 cand.cradle,375 cand.baseAt,376 // M19c pattern pool: LOOP mode only — endless (open) viaduct377 // stacks keep the classic pool (the golden tripwire).378 !opts.open,379 );380 }381 }382383 return {384 placements,385 loop: { segments, totalLength },386 trackCells,387 maxDeckY: elevation.maxBaseY,388 };389}390391/** Closest point on a window's world centerline to (x, z), horizontally. */392function nearestOnCurve(393 curve: readonly CurveSeg[],394 x: number,395 z: number,396): Vec3 {397 let best: Vec3 = [x, 0, z];398 let bestD = Infinity;399 for (const seg of curve) {400 for (let i = 0; i <= 24; i++) {401 const p = segPoint(seg, i / 24);402 const d = (p[0] - x) ** 2 + (p[2] - z) ** 2;403 if (d < bestD) {404 bestD = d;405 best = p;406 }407 }408 }409 return best;410}411412/** Constant-speed sampling along the loop (s in world units, wraps). */413export function sampleLoop(414 loop: LoopPath,415 s: number,416): { pos: Vec3; tangent: Vec3 } {417 const total = loop.totalLength;418 let d = ((s % total) + total) % total;419 for (const seg of loop.segments) {420 if (d > seg.length) {421 d -= seg.length;422 continue;423 }424 // Locate within this segment's curve parts.425 for (const part of seg.curve) {426 const len = segLength(part);427 if (d > len) {428 d -= len;429 continue;430 }431 const t = len === 0 ? 0 : d / len;432 const p = segPoint(part, t);433 const q = segPoint(part, Math.min(1, t + 0.01));434 const tangent: Vec3 = norm([q[0] - p[0], q[1] - p[1], q[2] - p[2]]);435 return { pos: p, tangent };436 }437 break;438 }439 // Numeric edge (d ≈ total): return the loop start.440 const first = loop.segments[0].curve[0];441 const p = segPoint(first, 0);442 const q = segPoint(first, 0.01);443 return { pos: p, tangent: norm([q[0] - p[0], q[1] - p[1], q[2] - p[2]]) };444}445446function norm(v: Vec3): Vec3 {447 const l = Math.hypot(v[0], v[1], v[2]) || 1;448 return [v[0] / l, v[1] / l, v[2] / l];449}