The Kit/Utils

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.

ribbon.ts
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);
}
← The whole kit