Back to Napplet Machines V8
SOURCE / PINNED RELEASE

Made of little things.

Napplet Machines V8

Release
b3f614e739f0…
Author-recorded commit
534f19acb4ba…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b5572362d4a

Archive hash verified: 0f7feb97e2c3d336…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/track.ts
import { closeRail, RoadDeck } from './road-surface.js';
export type Point = { x: number; y: number; z: number };
export type Knot = Point & {
  width: number;
  wall: boolean;
  wallThickness: number;
};
export type Track = { version: 3; name: string; points: Knot[] };
export type Sample = Point & {
  tx: number;
  ty: number;
  slope: number;
  width: number;
  leftEdge: Point;
  rightEdge: Point;
  leftNormal: Point;
  rightNormal: Point;
  wall: boolean;
  wallThickness: number;
  section: number;
  u: number;
  distance: number;
};
export const clamp = (v: number, lo: number, hi: number) =>
  Math.max(lo, Math.min(hi, v));
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
export const wrapAngle = (a: number) => Math.atan2(Math.sin(a), Math.cos(a));
export const copyTrack = (t: Track): Track => structuredClone(t);
export const TRACK_HALF_SIZE = 130;
export const PLAN_SPAN = 290;
export const MIN_POINT_WIDTH = 5;
export const MAX_POINT_WIDTH = 20.8;
export const presets: Track[] = [
  {
    version: 3,
    name: 'Pinefall Circuit',
    points: [
      [-34, -22, 3],
      [-8, -32, 3],
      [25, -26, 5],
      [39, -6, 9],
      [24, 9, 12],
      [35, 29, 7],
      [6, 34, 3],
      [-24, 25, 3],
      [-39, 8, 5],
      [-24, -4, 7],
    ].map(([x, y, z], i) => ({
      x,
      y,
      z,
      width: [11.7, 15.6, 14.3, 10.4, 9.1, 13, 16.9, 13, 10.4, 9.1][i],
      wall: ![3, 4, 7].includes(i),
      wallThickness: [0.6, 0.6, 1, 0.5, 0.5, 0.8, 0.6, 0.4, 1, 0.8][i],
    })),
  },
  {
    version: 3,
    name: 'Little Lagoon',
    points: [
      [-32, -22, 3],
      [0, -30, 3],
      [32, -22, 3],
      [40, 4, 4],
      [23, 28, 5],
      [-9, 30, 4],
      [-36, 18, 3],
    ].map(([x, y, z], i) => ({
      x,
      y,
      z,
      width: [14.3, 18.2, 15.6, 10.4, 9.1, 13, 15.6][i],
      wall: ![1, 4].includes(i),
      wallThickness: [0.6, 0.4, 0.8, 1.2, 0.5, 0.8, 0.6][i],
    })),
  },
  {
    version: 3,
    name: 'Skyline Sprint',
    points: [
      [-38, -24, 3],
      [-6, -29, 5],
      [31, -25, 12],
      [39, 0, 15],
      [16, 8, 13],
      [29, 28, 5],
      [-4, 31, 3],
      [-35, 21, 7],
      [-28, 0, 11],
    ].map(([x, y, z], i) => ({
      x,
      y,
      z,
      width: [13, 15.6, 11.7, 9.1, 7.8, 11.7, 15.6, 10.4, 9.1][i],
      wall: [0, 2, 3, 6].includes(i),
      wallThickness: [0.6, 0.4, 1.2, 1, 0.5, 0.4, 0.8, 0.5, 0.6][i],
    })),
  },
  {
    version: 3,
    name: 'Coastal Comet',
    points: [
      [-94, -58, 3, 15.6],
      [-48, -86, 3, 19.5],
      [20, -90, 3, 18.2],
      [85, -70, 4, 14.3],
      [110, -32, 5, 11.7],
      [98, 7, 6, 10.4],
      [66, 25, 7, 13],
      [32, 18, 8, 10.4],
      [12, 48, 7, 11.7],
      [36, 79, 4, 13],
      [-12, 97, 3, 18.2],
      [-70, 81, 3, 15.6],
      [-106, 42, 4, 11.7],
      [-110, -9, 4, 13],
    ].map(([x, y, z, width], i) => ({
      x,
      y,
      z,
      width,
      wall: ![1, 2, 7, 10].includes(i),
      wallThickness: i % 4 === 0 ? 1.1 : 0.6,
    })),
  },
  {
    version: 3,
    name: 'Switchback Summit',
    points: [
      [-108, -80, 3, 15.6],
      [-68, -92, 3, 18.2],
      [-10, -83, 5, 14.3],
      [56, -94, 8, 13],
      [104, -69, 12, 11.7],
      [98, -26, 16, 10.4],
      [58, -12, 19, 9.1],
      [22, -26, 20, 10.4],
      [-15, -34, 18, 11.7],
      [-52, -9, 16, 10.4],
      [-30, 18, 13, 11.7],
      [16, 17, 10, 15.6],
      [62, 30, 8, 13],
      [78, 65, 6, 10.4],
      [43, 98, 4, 11.7],
      [-7, 89, 3, 15.6],
      [-46, 60, 4, 13],
      [-92, 73, 6, 11.7],
      [-110, 29, 5, 10.4],
      [-92, -24, 3, 14.3],
    ].map(([x, y, z, width], i) => ({
      x,
      y,
      z,
      width,
      wall: ![1, 6, 7, 11, 15].includes(i),
      wallThickness: i % 3 === 0 ? 1.2 : 0.7,
    })),
  },
  {
    version: 3,
    name: 'Petal Park',
    points: [
      [-105, 0, 3, 14.3],
      [-100, -40, 3, 15.6],
      [-67, -61, 4, 13],
      [-31, -44, 5, 9.1],
      [-29, -83, 7, 11.7],
      [0, -110, 9, 15.6],
      [34, -84, 7, 13],
      [37, -46, 5, 9.1],
      [78, -57, 3, 13],
      [107, -29, 3, 16.9],
      [101, 13, 4, 13],
      [66, 32, 6, 9.1],
      [58, 77, 8, 11.7],
      [18, 101, 7, 16.9],
      [-24, 83, 5, 13],
      [-40, 42, 3, 9.1],
      [-76, 41, 3, 13],
    ].map(([x, y, z, width], i) => ({
      x,
      y,
      z,
      width,
      wall: ![2, 5, 8, 12, 16].includes(i),
      wallThickness: i % 4 === 3 ? 1.3 : 0.6,
    })),
  },
  {
    version: 3,
    name: 'Skybridge Eight',
    points: Array.from({ length: 20 }, (_, i) => {
      const a = -Math.PI / 2 + (i * Math.PI) / 10;
      return {
        x: Math.round(108 * Math.sin(a)),
        y: Math.round(82 * Math.sin(2 * a)),
        z: Math.round((10.5 + 7.5 * Math.cos(a)) * 2) / 2,
        width: [
          14.3, 15.6, 18.2, 15.6, 13, 11.7, 13, 15.6, 18.2, 15.6, 14.3,
          15.6, 16.9, 14.3, 10.4, 10.4, 11.7, 14.3, 16.9, 15.6,
        ][i],
        wall: ![2, 3, 8, 12, 13, 18].includes(i),
        wallThickness: i % 5 === 0 ? 1.2 : 0.7,
      };
    }),
  },
];
export function validateTrack(v: unknown): v is Track {
  if (!v || typeof v !== 'object') return false;
  const t = v as Track;
  return (
    t.version === 3 &&
    typeof t.name === 'string' &&
    t.name.length > 0 &&
    t.name.length <= 48 &&
    Array.isArray(t.points) &&
    t.points.length >= 4 &&
    t.points.length <= 32 &&
    t.points.every(
      (p) =>
        p &&
        [p.x, p.y, p.z, p.width, p.wallThickness].every(Number.isFinite) &&
        Math.abs(p.x) <= TRACK_HALF_SIZE &&
        Math.abs(p.y) <= TRACK_HALF_SIZE &&
        p.z >= 2 &&
        p.z <= 24 &&
        p.width >= MIN_POINT_WIDTH &&
        p.width <= MAX_POINT_WIDTH &&
        p.wallThickness >= 0.3 &&
        p.wallThickness <= 2 &&
        typeof p.wall === 'boolean',
    ) &&
    t.points.every(
      (p, i) =>
        Math.hypot(
          p.x - t.points[(i + 1) % t.points.length].x,
          p.y - t.points[(i + 1) % t.points.length].y,
        ) >= 3,
    )
  );
}
// Keep the existing storage key: old drafts upgrade in memory and on the next save.
export function decodeTrack(value: unknown): Track | null {
  if (validateTrack(value)) return copyTrack(value);
  if (!value || typeof value !== 'object') return null;
  const old = value as {
    version?: number;
    name?: unknown;
    width?: unknown;
    points?: unknown[];
  };
  if (![1, 2].includes(old.version ?? 0) || !Array.isArray(old.points))
    return null;
  const migrated = {
    version: 3,
    name: old.name,
    points: old.points.map((p) =>
      p && typeof p === 'object' && old.version === 1
        ? { ...p, width: old.width, wallThickness: 0.6 }
        : p,
    ),
  };
  // Validate at the old scale before widening; malformed drafts cannot become valid
  // through migration. Version 3 marks the one-time 30% increase, including v1 drafts.
  if (!validateTrack(migrated) || migrated.points.some((p) => p.width > 16))
    return null;
  return {
    ...migrated,
    points: migrated.points.map((p) => ({
      ...p,
      width: Math.round(p.width * 1.3 * 1e6) / 1e6,
    })),
  };
}
export const WALL_HEIGHT = 1.2;
const mixPoint = (a: Point, b: Point, t: number): Point => ({
  x: lerp(a.x, b.x, t),
  y: lerp(a.y, b.y, t),
  z: lerp(a.z, b.z, t),
});
export function roadEdge(s: Sample, offset: number): Point {
  const half = s.width / 2;
  if (offset > half) return roadBoundary(s, 1, offset - half);
  if (offset < -half) return roadBoundary(s, -1, -offset - half);
  return mixPoint(s.rightEdge, s.leftEdge, (offset + half) / s.width);
}
export function roadBoundary(s: Sample, side: number, outward = 0): Point {
  const p = side > 0 ? s.leftEdge : s.rightEdge,
    n = side > 0 ? s.leftNormal : s.rightNormal;
  const length = Math.hypot(n.x, n.y) || 1;
  return {
    x: p.x + (n.x / length) * outward,
    y: p.y + (n.y / length) * outward,
    z: p.z,
  };
}
export type WallPiece = {
  corners: [Point, Point, Point, Point];
  startCap: boolean;
  endCap: boolean;
  minX: number;
  maxX: number;
  minY: number;
  maxY: number;
};
type WallEdge = { a: Point; b: Point; nx: number; ny: number };
function projection(x: number, y: number, a: Point, b: Point) {
  return clamp(
    ((x - a.x) * (b.x - a.x) + (y - a.y) * (b.y - a.y)) /
      ((b.x - a.x) ** 2 + (b.y - a.y) ** 2 || 1),
    0,
    1,
  );
}
function cubic(a: number, b: number, c: number, d: number, t: number): number {
  return (
    0.5 *
    (2 * b +
      (-a + c) * t +
      (2 * a - 5 * b + 4 * c - d) * t * t +
      (-a + 3 * b - 3 * c + d) * t * t * t)
  );
}
export function spline(track: Track, u: number): Point {
  const n = track.points.length,
    s = ((u % n) + n) % n,
    i = Math.floor(s),
    t = s - i;
  const a = track.points[(i + n - 1) % n],
    b = track.points[i],
    c = track.points[(i + 1) % n],
    d = track.points[(i + 2) % n];
  return {
    x: cubic(a.x, b.x, c.x, d.x, t),
    y: cubic(a.y, b.y, c.y, d.y, t),
    z: cubic(a.z, b.z, c.z, d.z, t),
  };
}
export class Road {
  samples: Sample[] = [];
  length = 0;
  closedSections = new Set<number>();
  deck: RoadDeck;
  bounds = { minX: 0, maxX: 0, minY: 0, maxY: 0, maxZ: 0 };
  walls: WallPiece[] = [];
  private wallEdges: WallEdge[] = [];
  private wallBuckets = new Map<
    string,
    { walls: WallPiece[]; edges: WallEdge[] }
  >();
  constructor(public track: Track) {
    // Keep section boundaries and half-points exact; add detail where long
    // segments or fast-changing tangents need it. Both peers sample identically.
    const tangent = (u: number) => {
      const q = spline(track, u + 0.0005),
        r = spline(track, u - 0.0005);
      return Math.atan2(q.y - r.y, q.x - r.x);
    };
    const parameters = [0];
    const subdivide = (a: number, b: number, depth: number) => {
      const p = spline(track, a),
        q = spline(track, b),
        mid = (a + b) / 2;
      if (
        depth < 3 &&
        (Math.hypot(q.x - p.x, q.y - p.y) > 1.5 ||
          Math.abs(wrapAngle(tangent(mid) - tangent(a))) > 0.045 ||
          Math.abs(wrapAngle(tangent(b) - tangent(mid))) > 0.045)
      ) {
        subdivide(a, mid, depth + 1);
        subdivide(mid, b, depth + 1);
      } else parameters.push(b);
    };
    for (let i = 0; i < track.points.length * 28; i++)
      subdivide(i / 28, (i + 1) / 28, 0);
    for (const u of parameters) {
      const p = spline(track, u),
        q = spline(track, u + 0.0005),
        r = spline(track, u - 0.0005);
      const dl = Math.hypot(q.x - r.x, q.y - r.y) || 1;
      const previous = this.samples.at(-1);
      if (previous)
        this.length += Math.hypot(p.x - previous.x, p.y - previous.y);
      const section = Math.floor(u) % track.points.length,
        a = track.points[section],
        b = track.points[(section + 1) % track.points.length],
        t = u - Math.floor(u),
        width = lerp(a.width, b.width, t * t * (3 - 2 * t));
      this.samples.push({
        ...p,
        tx: (q.x - r.x) / dl,
        ty: (q.y - r.y) / dl,
        slope: (q.z - r.z) / dl,
        width,
        leftEdge: {
          x: p.x - (((q.y - r.y) / dl) * width) / 2,
          y: p.y + (((q.x - r.x) / dl) * width) / 2,
          z: p.z,
        },
        rightEdge: {
          x: p.x + (((q.y - r.y) / dl) * width) / 2,
          y: p.y - (((q.x - r.x) / dl) * width) / 2,
          z: p.z,
        },
        leftNormal: { x: -(q.y - r.y) / dl, y: (q.x - r.x) / dl, z: 0 },
        rightNormal: { x: (q.y - r.y) / dl, y: -(q.x - r.x) / dl, z: 0 },
        wall: a.wall,
        wallThickness: a.wallThickness,
        section,
        u,
        distance: this.length,
      });
    }
    const n = this.samples.length - 1;
    for (const side of [-1, 1]) {
      const { rail, sections } = closeRail(this.samples, side);
      for (const section of sections) this.closedSections.add(section);
      for (let i = 0; i < n; i++) {
        const previous = rail[(i + n - 1) % n],
          next = rail[(i + 1) % n],
          dx = next.x - previous.x,
          dy = next.y - previous.y,
          length = Math.hypot(dx, dy) || 1;
        const sample = this.samples[i],
          normal = { x: (-dy / length) * side, y: (dx / length) * side, z: 0 };
        if (side > 0) {
          sample.leftEdge = rail[i];
          sample.leftNormal = normal;
        } else {
          sample.rightEdge = rail[i];
          sample.rightNormal = normal;
        }
      }
    }
    Object.assign(this.samples[n], this.samples[0], {
      u: track.points.length,
      distance: this.length,
    });
    this.deck = new RoadDeck(this.samples);
    const edges = this.samples.flatMap((s) => [
      roadBoundary(s, -1, 1),
      roadBoundary(s, 1, 1),
    ]);
    this.bounds = {
      minX: Math.min(...edges.map((p) => p.x)),
      maxX: Math.max(...edges.map((p) => p.x)),
      minY: Math.min(...edges.map((p) => p.y)),
      maxY: Math.max(...edges.map((p) => p.y)),
      maxZ: Math.max(...this.samples.map((p) => p.z)),
    };
    // Geometry and physics consume the same wall strips and exposed boundaries.
    const ss = this.samples,
      countEdges = ss.length - 1;
    const addEdge = (a: Point, b: Point, inside: Point) => {
      const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
      let nx = -(b.y - a.y) / length,
        ny = (b.x - a.x) / length;
      if ((inside.x - a.x) * nx + (inside.y - a.y) * ny > 0) {
        nx *= -1;
        ny *= -1;
      }
      this.wallEdges.push({ a, b, nx, ny });
    };
    for (let i = 0; i < countEdges; i++) {
      const a = ss[i],
        b = ss[i + 1],
        prev = ss[(i + countEdges - 1) % countEdges],
        next = ss[(i + 1) % countEdges];
      if (!a.wall) continue;
      for (const side of [-1, 1]) {
        const corner = (s: Sample, outer: number) =>
          roadBoundary(s, side, (outer * a.wallThickness) / 2);
        const corners: WallPiece['corners'] = [
          corner(a, -1),
          corner(b, -1),
          corner(b, 1),
          corner(a, 1),
        ];
        const startCap = !prev.wall || prev.wallThickness !== a.wallThickness,
          endCap = !next.wall || next.wallThickness !== a.wallThickness;
        this.walls.push({
          corners,
          startCap,
          endCap,
          minX: Math.min(...corners.map((p) => p.x)),
          maxX: Math.max(...corners.map((p) => p.x)),
          minY: Math.min(...corners.map((p) => p.y)),
          maxY: Math.max(...corners.map((p) => p.y)),
        });
        const center = {
          x: (corners[0].x + corners[2].x) / 2,
          y: (corners[0].y + corners[2].y) / 2,
          z: 0,
        };
        addEdge(corners[0], corners[1], center);
        addEdge(corners[2], corners[3], center);
        // Only exposed steps are collidable when two walled sections meet.
        for (const [s, neighbor, inner, outer] of [
          [a, prev, corners[0], corners[3]],
          [b, next, corners[1], corners[2]],
        ] as const) {
          if (!neighbor.wall) addEdge(inner, outer, center);
          else if (a.wallThickness > neighbor.wallThickness) {
            addEdge(
              inner,
              roadBoundary(s, side, -neighbor.wallThickness / 2),
              center,
            );
            addEdge(
              roadBoundary(s, side, neighbor.wallThickness / 2),
              outer,
              center,
            );
          }
        }
      }
    }
    // Static broad phase: larger circuits should not scan every distant wall
    // for each bumper, every physics tick. Preserve insertion order per cell.
    const bucket = (
      minX: number,
      maxX: number,
      minY: number,
      maxY: number,
      add: (b: { walls: WallPiece[]; edges: WallEdge[] }) => void,
    ) => {
      for (
        let x = Math.floor((minX - 2) / 8);
        x <= Math.floor((maxX + 2) / 8);
        x++
      )
        for (
          let y = Math.floor((minY - 2) / 8);
          y <= Math.floor((maxY + 2) / 8);
          y++
        ) {
          const key = `${x},${y}`;
          let b = this.wallBuckets.get(key);
          if (!b) {
            b = { walls: [], edges: [] };
            this.wallBuckets.set(key, b);
          }
          add(b);
        }
    };
    for (const w of this.walls)
      bucket(w.minX, w.maxX, w.minY, w.maxY, (b) => b.walls.push(w));
    for (const e of this.wallEdges)
      bucket(
        Math.min(e.a.x, e.b.x),
        Math.max(e.a.x, e.b.x),
        Math.min(e.a.y, e.b.y),
        Math.max(e.a.y, e.b.y),
        (b) => b.edges.push(e),
      );
  }
  wallContact(x: number, y: number, z: number, radius = 0.65) {
    const bucket = this.wallBuckets.get(
      `${Math.floor(x / 8)},${Math.floor(y / 8)}`,
    );
    const nearbyWalls = radius <= 2 ? (bucket?.walls ?? []) : this.walls;
    const nearbyEdges = radius <= 2 ? (bucket?.edges ?? []) : this.wallEdges;
    let inside = false;
    for (const w of nearbyWalls) {
      if (x < w.minX || x > w.maxX || y < w.minY || y > w.maxY) continue;
      const [a, b] = w.corners,
        t = projection(x, y, a, b),
        height = lerp(a.z, b.z, t);
      if (z < height - 0.3 || z > height + WALL_HEIGHT + 0.65) continue;
      const signs = w.corners.map((p, i) => {
        const q = w.corners[(i + 1) % 4];
        return (q.x - p.x) * (y - p.y) - (q.y - p.y) * (x - p.x);
      });
      if (signs.every((v) => v >= -1e-8) || signs.every((v) => v <= 1e-8))
        inside = true;
    }
    let best = Infinity,
      contact: { nx: number; ny: number; depth: number } | null = null;
    for (const { a, b, nx, ny } of nearbyEdges) {
      if (
        !inside &&
        (x < Math.min(a.x, b.x) - radius ||
          x > Math.max(a.x, b.x) + radius ||
          y < Math.min(a.y, b.y) - radius ||
          y > Math.max(a.y, b.y) + radius)
      )
        continue;
      const t = projection(x, y, a, b),
        height = lerp(a.z, b.z, t);
      if (z < height - 0.3 || z > height + WALL_HEIGHT + 0.65) continue;
      const dx = x - lerp(a.x, b.x, t),
        dy = y - lerp(a.y, b.y, t),
        d = Math.hypot(dx, dy);
      if (d >= best || (!inside && d >= radius)) continue;
      best = d;
      contact = {
        nx: inside || d < 1e-8 ? nx : dx / d,
        ny: inside || d < 1e-8 ? ny : dy / d,
        depth: inside ? radius + d : radius - d,
      };
    }
    return contact;
  }
  nearest(x: number, y: number, z?: number) {
    let best = Infinity,
      index = 0,
      t = 0;
    for (let i = 0; i < this.samples.length - 1; i++) {
      const a = this.samples[i],
        b = this.samples[i + 1],
        dx = b.x - a.x,
        dy = b.y - a.y;
      const u = clamp(
        ((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy || 1),
        0,
        1,
      );
      const d =
        (x - lerp(a.x, b.x, u)) ** 2 +
        (y - lerp(a.y, b.y, u)) ** 2 +
        (z === undefined
          ? 0
          : Math.max(0, Math.abs(z - lerp(a.z, b.z, u)) - 2) ** 2 * 0.6);
      if (d < best) {
        best = d;
        index = i;
        t = u;
      }
    }
    const a = this.samples[index],
      b = this.samples[index + 1];
    const p = {
      ...a,
      x: lerp(a.x, b.x, t),
      y: lerp(a.y, b.y, t),
      z: lerp(a.z, b.z, t),
      tx: Math.cos(
        Math.atan2(a.ty, a.tx) +
          wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
      ),
      ty: Math.sin(
        Math.atan2(a.ty, a.tx) +
          wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
      ),
      slope: lerp(a.slope, b.slope, t),
      width: lerp(a.width, b.width, t),
      leftEdge: mixPoint(a.leftEdge, b.leftEdge, t),
      rightEdge: mixPoint(a.rightEdge, b.rightEdge, t),
      leftNormal: mixPoint(a.leftNormal, b.leftNormal, t),
      rightNormal: mixPoint(a.rightNormal, b.rightNormal, t),
      u: lerp(a.u, b.u, t),
      distance: lerp(a.distance, b.distance, t),
    };
    return { ...p, index, offset: (x - p.x) * -p.ty + (y - p.y) * p.tx };
  }
  at(distance: number): Sample {
    const d = ((distance % this.length) + this.length) % this.length;
    let lo = 0,
      hi = this.samples.length - 1;
    while (lo + 1 < hi) {
      const mid = (lo + hi) >> 1;
      if (this.samples[mid].distance < d) lo = mid;
      else hi = mid;
    }
    const a = this.samples[lo],
      b = this.samples[hi],
      t = (d - a.distance) / (b.distance - a.distance || 1);
    return {
      ...a,
      x: lerp(a.x, b.x, t),
      y: lerp(a.y, b.y, t),
      z: lerp(a.z, b.z, t),
      tx: Math.cos(
        Math.atan2(a.ty, a.tx) +
          wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
      ),
      ty: Math.sin(
        Math.atan2(a.ty, a.tx) +
          wrapAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)) * t,
      ),
      slope: lerp(a.slope, b.slope, t),
      width: lerp(a.width, b.width, t),
      leftEdge: mixPoint(a.leftEdge, b.leftEdge, t),
      rightEdge: mixPoint(a.rightEdge, b.rightEdge, t),
      leftNormal: mixPoint(a.leftNormal, b.leftNormal, t),
      rightNormal: mixPoint(a.rightNormal, b.rightNormal, t),
      u: lerp(a.u, b.u, t),
      distance: d,
    };
  }
}