clone

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

main
marbles / src / client / centerline.ts
centerline.ts 3.5K
1/**2 * The loop's centerline as a Three.js curve.3 *4 * The generator hands us piecewise CurveSegs at channel-floor height; we sample5 * them densely and refit a closed centripetal Catmull-Rom so the marble gets a6 * C1 path with cheap arc-length lookup.7 */8import * as THREE from "three";9import type { LoopPath } from "../shared/types.ts";10import { segLength, segPoint } from "../gen/geom.ts";1112/** Target spacing between samples, in grid units. */13const SAMPLE_SPACING = 0.25;14const EPS = 1e-4;1516export interface Centerline {17 curve: THREE.CatmullRomCurve3;18 /** Second lap variant taking every diamond's OTHER branch (if any). */19 altCurve?: THREE.CatmullRomCurve3;20 points: THREE.Vector3[];21 debugLine(): THREE.Line;22}2324function buildVariant(loop: LoopPath, useAlt: boolean): THREE.Vector3[] {25 const points: THREE.Vector3[] = [];2627 const push = (x: number, y: number, z: number) => {28 const last = points[points.length - 1];29 if (last) {30 const d2 = (last.x - x) ** 2 + (last.y - y) ** 2 + (last.z - z) ** 2;31 if (d2 < EPS * EPS) return;32 }33 points.push(new THREE.Vector3(x, y, z));34 };3536 for (const seg of loop.segments) {37 const curve = useAlt && seg.alt ? seg.alt.curve : seg.curve;38 for (const part of curve) {39 const len = segLength(part);40 const steps = Math.max(1, Math.ceil(len / SAMPLE_SPACING));41 // i from 0 keeps joints exact; `push` drops the duplicate at each seam.42 for (let i = 0; i <= steps; i++) {43 const p = segPoint(part, i / steps);44 push(p[0], p[1], p[2]);45 }46 }47 }4849 // Closed curve: drop the final point if it coincides with the first.50 if (points.length > 1) {51 const first = points[0];52 const last = points[points.length - 1];53 if (first.distanceToSquared(last) < EPS * EPS) points.pop();54 }55 if (points.length < 4) {56 // Degenerate loop — pad so CatmullRomCurve3 stays well-defined.57 while (points.length < 4) {58 points.push(points[points.length - 1].clone().addScalar(EPS));59 }60 }61 return points;62}6364function fit(points: THREE.Vector3[]): THREE.CatmullRomCurve3 {65 const curve = new THREE.CatmullRomCurve3(points, true, "centripetal", 0.5);66 curve.arcLengthDivisions = points.length * 8;67 curve.updateArcLengths();68 return curve;69}7071/**72 * Open-path variant (endless mode): same dense sampling + centripetal73 * Catmull-Rom refit, but the curve does not close — segment boundaries meet74 * at straight ports, so chained per-segment curves stay visually C1.75 */76export function buildOpenCurve(path: LoopPath): THREE.CatmullRomCurve3 {77 const points = buildVariant(path, false);78 const curve = new THREE.CatmullRomCurve3(points, false, "centripetal", 0.5);79 curve.arcLengthDivisions = points.length * 8;80 curve.updateArcLengths();81 return curve;82}8384export function buildCenterline(loop: LoopPath): Centerline {85 const points = buildVariant(loop, false);86 const curve = fit(points);87 const hasAlt = loop.segments.some((s) => s.alt);88 const altCurve = hasAlt ? fit(buildVariant(loop, true)) : undefined;8990 return {91 curve,92 altCurve,93 points,94 debugLine(): THREE.Line {95 const geom = new THREE.BufferGeometry().setFromPoints([96 ...points,97 points[0],98 ]);99 const mat = new THREE.LineBasicMaterial({100 color: 0xff00ff,101 depthTest: false,102 depthWrite: false,103 transparent: true,104 });105 const line = new THREE.Line(geom, mat);106 line.name = "centerline-debug";107 line.renderOrder = 999;108 return line;109 },110 };111}