Track Ribbon Geometry
Synthesizes a flat 3D track ribbon from a centerline: centres + scales the circuit, offsets +/- half-width along normals, builds surface/kerb/run-off/edge geometries + per-arc coloured overlays. three geometry only.
Reference implementation: this source keeps imports to modules from its home codebase. Read it for the technique, adapt the imports to yours; it is not drop-in compile-ready, on purpose.
import type { TrackOutline } from "@f1peak/viewer";
/**
* World→scene transform: centre the circuit on the origin and scale its longest
* span to ~100 scene units, so the camera framing is constant across circuits.
* World Y (up) maps to scene −Z so the track reads upright from above.
*/
export function trackTransform(o: TrackOutline) {
const { minX, minY, maxX, maxY } = o.bounds;
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const span = Math.max(maxX - minX, maxY - minY) || 1;
const scale = 100 / span;
const toScene = (x: number, y: number): [number, number] => [(x - cx) * scale, -(y - cy) * scale];
return { toScene, scale, span };
}
/**
* Synthesize a flat ribbon (triangle list, y=0) from the centerline by offsetting
* each point ±halfWidth along its normal. HONEST: the feed has no width and no
* elevation, so width is schematic and the ribbon is dead flat (captioned on screen).
* `segFrac` carries each vertex's lap fraction (0..1) for future mini-sector colouring.
*/
export function buildRibbon(o: TrackOutline, halfWidth: number, toScene: (x: number, y: number) => [number, number]) {
const n = o.x.length;
const left: [number, number][] = [];
const right: [number, number][] = [];
for (let i = 0; i < n; i++) {
const [ax, ay] = toScene(o.x[(i - 1 + n) % n], o.y[(i - 1 + n) % n]);
const [bx, by] = toScene(o.x[(i + 1) % n], o.y[(i + 1) % n]);
const [px, py] = toScene(o.x[i], o.y[i]);
let dx = bx - ax;
let dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
const nx = -dy;
const ny = dx;
left.push([px + nx * halfWidth, py + ny * halfWidth]);
right.push([px - nx * halfWidth, py - ny * halfWidth]);
}
const verts: number[] = [];
const frac: number[] = [];
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const l0 = left[i];
const r0 = right[i];
const l1 = left[j];
const r1 = right[j];
verts.push(l0[0], 0, l0[1], r0[0], 0, r0[1], l1[0], 0, l1[1]);
verts.push(r0[0], 0, r0[1], r1[0], 0, r1[1], l1[0], 0, l1[1]);
const f = i / n;
for (let k = 0; k < 6; k++) frac.push(f);
}
return { positions: new Float32Array(verts), segFrac: new Float32Array(frac) };
}
/** centerline as scene points (slightly above the ribbon to avoid z-fighting) */
export function centerlinePoints(o: TrackOutline, toScene: (x: number, y: number) => [number, number]): [number, number, number][] {
const n = o.x.length;
const pts: [number, number, number][] = [];
for (let i = 0; i <= n; i++) {
const [x, z] = toScene(o.x[i % n], o.y[i % n]);
pts.push([x, 0.06, z]);
}
return pts;
}
// ============================================================================
// Track 2 / Phase 1 — a track surface that READS as racing: real (schematic)
// width, kerb bands, a run-off apron, and mini-sector vertex-colour via segFrac.
// Still HONEST: the feed has no width/elevation, so width is schematic + the
// ribbon is dead flat (captioned on screen). Geometry only — colour in Track3D.
// ============================================================================
/** per-point scene position + unit normal (perpendicular to the local tangent) */
function frames(o: TrackOutline, toScene: (x: number, y: number) => [number, number]) {
const n = o.x.length;
const px: number[] = new Array(n);
const py: number[] = new Array(n);
const nx: number[] = new Array(n);
const ny: number[] = new Array(n);
for (let i = 0; i < n; i++) {
const [ax, ay] = toScene(o.x[(i - 1 + n) % n], o.y[(i - 1 + n) % n]);
const [bx, by] = toScene(o.x[(i + 1) % n], o.y[(i + 1) % n]);
const [cx, cy] = toScene(o.x[i], o.y[i]);
let dx = bx - ax;
let dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
px[i] = cx;
py[i] = cy;
nx[i] = -dy;
ny[i] = dx;
}
return { n, px, py, nx, ny };
}
/** triangle list for a flat band between two signed lateral offsets, at height y */
function band(
f: ReturnType<typeof frames>,
off1: number,
off2: number,
y: number,
): Float32Array {
const { n, px, py, nx, ny } = f;
const verts: number[] = [];
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const a0x = px[i] + nx[i] * off1, a0y = py[i] + ny[i] * off1;
const b0x = px[i] + nx[i] * off2, b0y = py[i] + ny[i] * off2;
const a1x = px[j] + nx[j] * off1, a1y = py[j] + ny[j] * off1;
const b1x = px[j] + nx[j] * off2, b1y = py[j] + ny[j] * off2;
verts.push(a0x, y, a0y, b0x, y, b0y, a1x, y, a1y);
verts.push(b0x, y, b0y, b1x, y, b1y, a1x, y, a1y);
}
return new Float32Array(verts);
}
export interface TrackGeo {
runoff: Float32Array;
surface: Float32Array;
segFrac: Float32Array; // per surface vertex, lap fraction 0..1
kerb: Float32Array;
kerbColors: Float32Array; // alternating red/white blocks
edge: Float32Array; // thin white track-limit lines
}
/**
* Layered flat track: run-off apron (widest, dark) → kerb bands (red/white) →
* asphalt surface → thin white edge lines. Heights are staggered to avoid z-fighting.
*/
export function buildTrack(
o: TrackOutline,
toScene: (x: number, y: number) => [number, number],
half = 1.6,
): TrackGeo {
const f = frames(o, toScene);
const K = half * 0.22; // kerb width
const R = half * 1.5; // run-off apron width beyond the kerb
const surface = band(f, -half, half, 0.02);
const segFrac = new Float32Array((surface.length / 3) | 0);
const per = segFrac.length / f.n; // 6 verts per segment
for (let i = 0; i < f.n; i++) for (let k = 0; k < per; k++) segFrac[i * per + k] = i / f.n;
// kerbs: a strip just outside each track limit, alternating red/white in blocks
const kerbL = band(f, half, half + K, 0.028);
const kerbR = band(f, -(half + K), -half, 0.028);
const kerb = new Float32Array(kerbL.length + kerbR.length);
kerb.set(kerbL, 0);
kerb.set(kerbR, kerbL.length);
const kerbColors = new Float32Array(kerb.length); // rgb per vertex
const block = 4; // segments per colour block
const paint = (base: Float32Array, off: number) => {
for (let i = 0; i < f.n; i++) {
const red = Math.floor(i / block) % 2 === 0;
const r = red ? 0.86 : 0.92, g = red ? 0.16 : 0.92, b = red ? 0.13 : 0.92;
for (let k = 0; k < 6; k++) {
const v = (off + (i * 6 + k)) * 3;
kerbColors[v] = r;
kerbColors[v + 1] = g;
kerbColors[v + 2] = b;
}
}
};
paint(kerbL, 0);
paint(kerbR, kerbL.length / 3);
const runoffL = band(f, half + K, half + K + R, 0.0);
const runoffR = band(f, -(half + K + R), -(half + K), 0.0);
const runoff = new Float32Array(runoffL.length + runoffR.length);
runoff.set(runoffL, 0);
runoff.set(runoffR, runoffL.length);
// thin white track-limit lines (just inside each kerb)
const edgeL = band(f, half - half * 0.04, half, 0.022);
const edgeR = band(f, -half, -(half - half * 0.04), 0.022);
const edge = new Float32Array(edgeL.length + edgeR.length);
edge.set(edgeL, 0);
edge.set(edgeR, edgeL.length);
return { runoff, surface, segFrac, kerb, kerbColors, edge };
}
/**
* A flat coloured strip ON the surface for one mini-sector arc [i0,i1) of the
* outline — for the live "alive track" tint overlay (rebuilt on uiVersion).
*/
export function arcStrip(
o: TrackOutline,
toScene: (x: number, y: number) => [number, number],
i0: number,
i1: number,
half: number,
y = 0.05,
): Float32Array {
const f = frames(o, toScene);
const verts: number[] = [];
for (let i = i0; i < i1; i++) {
const a = (i + f.n) % f.n;
const j = (i + 1 + f.n) % f.n;
const w = half * 0.92;
const a0x = f.px[a] + f.nx[a] * -w, a0y = f.py[a] + f.ny[a] * -w;
const b0x = f.px[a] + f.nx[a] * w, b0y = f.py[a] + f.ny[a] * w;
const a1x = f.px[j] + f.nx[j] * -w, a1y = f.py[j] + f.ny[j] * -w;
const b1x = f.px[j] + f.nx[j] * w, b1y = f.py[j] + f.ny[j] * w;
verts.push(a0x, y, a0y, b0x, y, b0y, a1x, y, a1y);
verts.push(b0x, y, b0y, b1x, y, b1y, a1x, y, a1y);
}
return new Float32Array(verts);
}