BytorioGet started

Source reference

examples/kart3d/tools/assets.mjs

import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { deflateSync } from "node:zlib";
import { chunk, signature } from "../../../tools/sdk/cartridge.mjs";

const assetDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../assets");
const atlasSize = 16;
const tileSize = 4;
const paint = Object.freeze({
  red: 0,
  redShade: 1,
  charcoal: 2,
  steel: 3,
  tire: 4,
  purple: 5,
  purpleShade: 6,
  skin: 7,
  ivory: 8,
  gold: 9,
  leaf: 10,
  leafShade: 11,
  bark: 12,
  stone: 13,
  hub: 14,
  asphalt: 15,
});
const palette = [
  [224, 47, 35],
  [153, 27, 34],
  [35, 39, 48],
  [150, 169, 178],
  [31, 32, 37],
  [102, 51, 197],
  [63, 30, 128],
  [240, 176, 118],
  [244, 235, 200],
  [255, 191, 45],
  [100, 169, 39],
  [53, 114, 32],
  [108, 70, 40],
  [174, 169, 145],
  [178, 190, 194],
  [107, 109, 113],
];

function atlas() {
  const rows = Buffer.alloc(atlasSize * (atlasSize * 4 + 1));
  for (let y = 0; y < atlasSize; y++) {
    for (let x = 0; x < atlasSize; x++) {
      const tile = Math.floor(x / tileSize) + Math.floor(y / tileSize) * 4;
      const u = x % tileSize;
      const v = y % tileSize;
      let shade;
      if (tile === paint.tire) {
        shade = (u + v * 2) % 5 < 2 ? 1.4 : 0.86;
      } else if (tile === paint.stone) {
        shade = v % 4 === 0 || (u + (v < 4 ? 0 : 4)) % 8 === 0 ? 0.68 : 1;
      } else if (tile === paint.bark) {
        shade = u % 3 === 0 ? 0.7 : 1.08;
      } else if (tile === paint.asphalt) {
        shade = 0.91 + ((u * 11 + v * 7 + u * v) % 7) * 0.025;
      } else if (tile === paint.leaf || tile === paint.leafShade) {
        shade = (u + v * 3) % 7 < 2 ? 1.12 : 0.97;
      } else if (tile === paint.hub) {
        shade = u > 0 && u < 3 && v > 0 && v < 3 ? 1 : 0.2;
      } else {
        shade = v === 1 ? 1.12 : v > 5 ? 0.89 : 1;
      }
      const offset = y * (atlasSize * 4 + 1) + 1 + x * 4;
      for (let channel = 0; channel < 3; channel++) {
        rows[offset + channel] = Math.min(255, Math.round(palette[tile][channel] * shade));
      }
      rows[offset + 3] = 255;
    }
  }
  const header = Buffer.alloc(13);
  header.writeUInt32BE(atlasSize, 0);
  header.writeUInt32BE(atlasSize, 4);
  header[8] = 8;
  header[9] = 6;
  return Buffer.concat([
    signature,
    chunk("IHDR", header),
    chunk("IDAT", deflateSync(rows)),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}

// Author in the game's +Z-forward frame, on a 1/64-unit grid. The glTF writer
// reflects Z and reverses winding; the importer restores the authored frame.
const snap = (value) => Math.round(value * 64) / 64;
const point = (values) => values.map(snap);

class Mesh {
  positions = [];
  uv = [];
  indices = [];
  vertices = new Map();

  face(corners, tile, inset = 0.5, localUV = null) {
    const left = ((tile % 4) * tileSize + inset) / atlasSize;
    const right = ((tile % 4) * tileSize + tileSize - inset) / atlasSize;
    const top = (Math.floor(tile / 4) * tileSize + inset) / atlasSize;
    const bottom = (Math.floor(tile / 4) * tileSize + tileSize - inset) / atlasSize;
    const textured = [paint.tire, paint.stone, paint.bark, paint.hub, paint.asphalt].includes(tile);
    const mapping =
      localUV ??
      (textured
        ? [
            [0, 1],
            [1, 1],
            [1, 0],
            [0, 0],
          ]
        : [
            [0.5, 0.5],
            [0.5, 0.5],
            [0.5, 0.5],
            [0.5, 0.5],
          ]);
    // Solid faces sample a texel center, never a boundary between two texels.
    // Perspective interpolation can round a boundary to either neighbor on
    // different GPU drivers, producing speckling even when UVs are constant.
    const centerU = ((tile % 4) * tileSize + Math.floor(tileSize / 2) + 0.5) / atlasSize;
    const centerV = (Math.floor(tile / 4) * tileSize + Math.floor(tileSize / 2) + 0.5) / atlasSize;
    const coordinates =
      !textured && localUV === null
        ? corners.map(() => [centerU, centerV])
        : mapping.map(([u, v]) => [left + (right - left) * u, top + (bottom - top) * v]);
    const ids = corners.map((corner, index) => {
      const position = point(corner);
      const uv = coordinates[index].map((value) => Math.round(value * 256) / 256);
      const key = [...position, ...uv].join(",");
      if (!this.vertices.has(key)) {
        this.vertices.set(key, this.positions.length / 3);
        this.positions.push(position[0], position[1], -position[2]);
        this.uv.push(...uv);
      }
      return this.vertices.get(key);
    });
    for (let index = 1; index < ids.length - 1; index++) {
      this.indices.push(ids[0], ids[index + 1], ids[index]);
    }
  }

  box(x, y, z, width, height, depth, tile, bottom = false) {
    const a = x - width / 2;
    const b = x + width / 2;
    const c = z - depth / 2;
    const d = z + depth / 2;
    const t = y + height;
    this.face(
      [
        [a, t, c],
        [a, t, d],
        [b, t, d],
        [b, t, c],
      ],
      tile,
    );
    this.face(
      [
        [a, y, c],
        [b, y, c],
        [b, t, c],
        [a, t, c],
      ],
      tile,
    );
    this.face(
      [
        [b, y, d],
        [a, y, d],
        [a, t, d],
        [b, t, d],
      ],
      tile,
    );
    this.face(
      [
        [a, y, d],
        [a, y, c],
        [a, t, c],
        [a, t, d],
      ],
      tile,
    );
    this.face(
      [
        [b, y, c],
        [b, y, d],
        [b, t, d],
        [b, t, c],
      ],
      tile,
    );
    if (bottom) {
      this.face(
        [
          [a, y, c],
          [a, y, d],
          [b, y, d],
          [b, y, c],
        ],
        tile,
      );
    }
  }

  crown(x, y, z, width, height, depth, tile, sides = 6) {
    for (let side = 0; side < sides; side++) {
      const angle = (side * Math.PI * 2) / sides;
      const next = ((side + 1) * Math.PI * 2) / sides;
      const a = [
        x + (Math.cos(angle) * width) / 2,
        y + height * 0.4,
        z + (Math.sin(angle) * depth) / 2,
      ];
      const b = [
        x + (Math.cos(next) * width) / 2,
        y + height * 0.4,
        z + (Math.sin(next) * depth) / 2,
      ];
      this.face([a, [x, y + height, z], b], tile);
      this.face([b, [x, y, z], a], tile);
    }
  }

  spike(base, tip, width, depth, tile) {
    const [x, y, z] = base;
    const ring = [
      [x - width / 2, y, z - depth / 2],
      [x - width / 2, y, z + depth / 2],
      [x + width / 2, y, z + depth / 2],
      [x + width / 2, y, z - depth / 2],
    ];
    for (let side = 0; side < 4; side++) {
      this.face([ring[side], tip, ring[(side + 1) % 4]], tile);
    }
  }
}

function kartBody() {
  const mesh = new Mesh();
  const outline = [
    [-0.58, -1.14],
    [-0.78, -0.91],
    [-0.78, 0.72],
    [-0.54, 1.22],
    [0.54, 1.22],
    [0.78, 0.72],
    [0.78, -0.91],
    [0.58, -1.14],
  ];
  for (let side = 0; side < outline.length; side++) {
    const [x, z] = outline[side];
    const [nx, nz] = outline[(side + 1) % outline.length];
    mesh.face(
      [
        [x, 0.25, z],
        [nx, 0.25, nz],
        [nx, 0.48, nz],
        [x, 0.48, z],
      ],
      paint.redShade,
    );
    mesh.face(
      [
        [x, 0.48, z],
        [nx, 0.48, nz],
        [nx * 0.84, 0.63, nz * 0.9],
        [x * 0.84, 0.63, z * 0.9],
      ],
      paint.red,
    );
  }
  // The long cream hood stripe, raised cockpit and split rear bumper read from
  // the chase camera even at 320×180. Hidden bottoms are intentionally omitted.
  mesh.box(0, 0.48, 0.72, 1.16, 0.25, 0.75, paint.red);
  mesh.face(
    [
      [-0.12, 0.75, 0.35],
      [-0.12, 0.75, 1.1],
      [0.12, 0.75, 1.1],
      [0.12, 0.75, 0.35],
    ],
    paint.ivory,
  );
  mesh.box(0, 0.52, -0.49, 0.77, 0.51, 0.24, paint.charcoal);
  mesh.box(0, 0.48, -0.92, 0.7, 0.34, 0.36, paint.steel);
  mesh.face(
    [
      [-0.28, 0.83, -1.05],
      [-0.28, 0.83, -0.77],
      [0.28, 0.83, -0.77],
      [0.28, 0.83, -1.05],
    ],
    paint.charcoal,
  );
  mesh.box(0, 0.23, -1.2, 1.65, 0.2, 0.22, paint.charcoal);
  mesh.face(
    [
      [-0.43, 0.28, -1.33],
      [0.43, 0.28, -1.33],
      [0.43, 0.51, -1.33],
      [-0.43, 0.51, -1.33],
    ],
    paint.red,
  );
  mesh.face(
    [
      [-0.24, 0.31, -1.34],
      [0.24, 0.31, -1.34],
      [0.24, 0.42, -1.34],
      [-0.24, 0.42, -1.34],
    ],
    paint.ivory,
  );
  for (const side of [-1, 1]) {
    mesh.box(side * 0.53, 0.56, -0.96, 0.17, 0.17, 0.53, paint.steel);
    mesh.face(
      [
        [side * 0.53 - 0.06, 0.58, -1.24],
        [side * 0.53 + 0.06, 0.58, -1.24],
        [side * 0.53 + 0.06, 0.7, -1.24],
        [side * 0.53 - 0.06, 0.7, -1.24],
      ],
      paint.charcoal,
    );
    mesh.face(
      [
        [side * 0.68, 0.46, -0.86],
        [side * 0.68, 0.46, 0.48],
        [side * 0.56, 0.72, 0.48],
        [side * 0.56, 0.72, -0.86],
      ],
      paint.red,
    );
    mesh.face(
      [
        [side * 0.56, 0.5, -1.16],
        [side * 0.72, 0.5, -1.16],
        [side * 0.72, 0.63, -1.16],
        [side * 0.56, 0.63, -1.16],
      ],
      paint.gold,
    );
  }
  return mesh;
}

function driver() {
  const mesh = new Mesh();
  mesh.box(0, 0.73, -0.15, 0.59, 0.57, 0.41, paint.purpleShade);
  mesh.crown(0, 1.29, -0.13, 0.72, 0.67, 0.68, paint.skin, 6);
  // Sculpted hair silhouette: broad asymmetric locks instead of a smooth helmet.
  mesh.crown(0, 1.49, -0.25, 1.02, 0.66, 0.96, paint.purple, 7);
  const locks = [
    [[-0.32, 1.78, -0.22], [-0.76, 2.02, -0.27], 0.43, 0.42],
    [[0.3, 1.78, -0.17], [0.7, 2.16, -0.13], 0.44, 0.46],
    [[-0.13, 1.98, -0.19], [-0.31, 2.43, -0.13], 0.4, 0.44],
    [[0.19, 1.98, -0.3], [0.4, 2.34, -0.49], 0.43, 0.41],
    [[0, 1.8, -0.57], [-0.08, 2.05, -0.93], 0.58, 0.39],
  ];
  for (const [base, tip, width, depth] of locks) {
    mesh.spike(base, tip, width, depth, paint.purple);
  }
  for (const side of [-1, 0, 1]) {
    mesh.face(
      [
        [side * 0.24 - 0.24, 1.83, -0.53],
        [side * 0.3, 1.34, -0.48],
        [side * 0.24 + 0.24, 1.83, -0.53],
      ],
      paint.purpleShade,
    );
  }
  for (const side of [-1, 1]) {
    mesh.face(
      [
        [side * 0.24, 1.22, 0],
        [side * 0.5, 0.97, 0.46],
        [side * 0.4, 0.88, 0.55],
        [side * 0.13, 1.09, 0.08],
      ],
      paint.skin,
    );
    mesh.face(
      [
        [side * 0.22, 1.29, -0.13],
        [side * 0.43, 1.12, 0.13],
        [side * 0.34, 1.02, 0.22],
        [side * 0.12, 1.18, 0.02],
      ],
      paint.ivory,
    );
  }
  mesh.face(
    [
      [-0.36, 0.94, 0.48],
      [0.36, 0.94, 0.48],
      [0.29, 1.04, 0.38],
      [-0.29, 1.04, 0.38],
    ],
    paint.charcoal,
  );
  return mesh;
}

function cape() {
  const mesh = new Mesh();
  // Local origin is the neck. Three folded panels trail behind the kart and
  // remain a single rigid draw that the game can yaw or flutter independently.
  const neckLeft = [-0.28, 0, 0];
  const neckRight = [0.28, 0, 0];
  const ridge = [0.03, -0.35, -0.63];
  const left = [-0.73, -0.51, -1.04];
  const right = [0.63, -0.61, -1.12];
  const tail = [-0.13, -0.76, -1.36];
  mesh.face([neckLeft, left, ridge], paint.red);
  mesh.face([neckRight, neckLeft, ridge], paint.red);
  mesh.face([neckRight, ridge, right], paint.redShade);
  mesh.face([left, tail, ridge], paint.redShade);
  mesh.face([ridge, tail, right], paint.red);
  return mesh;
}

function wheel(sides = 8, radius = 0.39, halfWidth = 0.23) {
  const mesh = new Mesh();
  for (let side = 0; side < sides; side++) {
    const angle = (side * Math.PI * 2) / sides;
    const next = ((side + 1) * Math.PI * 2) / sides;
    const y = Math.sin(angle) * radius;
    const z = Math.cos(angle) * radius;
    const ny = Math.sin(next) * radius;
    const nz = Math.cos(next) * radius;
    mesh.face(
      [
        [-halfWidth, y, z],
        [halfWidth, y, z],
        [halfWidth, ny, nz],
        [-halfWidth, ny, nz],
      ],
      paint.tire,
    );
    for (const end of [-1, 1]) {
      const x = end * halfWidth;
      mesh.face(
        [
          [x, 0, 0],
          [x, y, z],
          [x, ny, nz],
        ],
        paint.hub,
        0.5,
        [
          [0.5, 0.5],
          [0.5 + y / (2 * radius), 0.5 + z / (2 * radius)],
          [0.5 + ny / (2 * radius), 0.5 + nz / (2 * radius)],
        ],
      );
    }
  }
  return mesh;
}

function tree() {
  const mesh = new Mesh();
  mesh.box(0, 0, 0, 0.42, 2, 0.42, paint.bark);
  mesh.crown(0, 1.13, 0, 3.2, 3.1, 2.9, paint.leaf, 7);
  mesh.crown(-0.72, 1.65, -0.15, 1.9, 2, 1.85, paint.leafShade, 5);
  mesh.crown(0.7, 1.8, 0.1, 1.8, 2, 1.9, paint.leaf, 5);
  return mesh;
}

function bodyDetail() {
  const mesh = new Mesh();
  // Engine cooling fins and a slim seat hoop are visible above the rear shell.
  for (const z of [-1.02, -0.94, -0.86, -0.78]) {
    mesh.box(0, 0.84, z, 0.59, 0.045, 0.035, paint.steel);
  }
  for (const side of [-1, 1]) {
    mesh.box(side * 0.39, 0.73, -0.58, 0.07, 0.54, 0.07, paint.steel);
    mesh.box(side * 0.69, 0.28, 0.3, 0.12, 0.08, 1.34, paint.charcoal);
    mesh.box(side * 0.48, 0.37, -1.26, 0.12, 0.24, 0.12, paint.red);
  }
  mesh.box(0, 1.22, -0.58, 0.85, 0.07, 0.07, paint.steel);
  // A real open steering rim replaces the low model's broad steering bar.
  for (let side = 0; side < 8; side++) {
    const angle = (side * Math.PI) / 4;
    const next = ((side + 1) * Math.PI) / 4;
    const rim = (a, scale) => [
      Math.sin(a) * 0.35 * scale,
      1.02 + Math.cos(a) * 0.1 * scale,
      0.43 + Math.cos(a) * 0.2 * scale,
    ];
    mesh.face([rim(angle, 1), rim(next, 1), rim(next, 0.76), rim(angle, 0.76)], paint.charcoal);
  }
  return mesh;
}

function driverDetail() {
  const mesh = new Mesh();
  for (const side of [-1, 1]) {
    mesh.box(side * 0.39, 0.9, 0.43, 0.17, 0.15, 0.2, paint.ivory);
    mesh.spike([side * 0.38, 1.79, -0.25], [side * 0.69, 1.52, -0.36], 0.29, 0.36, paint.purple);
    mesh.face(
      [
        [side * 0.17 - 0.045, 1.59, 0.24],
        [side * 0.17 + 0.045, 1.59, 0.24],
        [side * 0.17 + 0.045, 1.71, 0.24],
        [side * 0.17 - 0.045, 1.71, 0.24],
      ],
      paint.charcoal,
    );
  }
  return mesh;
}

function combine(...meshes) {
  const result = new Mesh();
  for (const mesh of meshes) {
    const offset = result.positions.length / 3;
    result.positions.push(...mesh.positions);
    result.uv.push(...mesh.uv);
    result.indices.push(...mesh.indices.map((index) => index + offset));
  }
  return result;
}

function kartNodes() {
  return [
    { name: "body", mesh: 0, children: [1, 3, 4, 5, 6] },
    { name: "driver", mesh: 1, children: [2] },
    { name: "cape", mesh: 2, translation: [0, 1.34, -0.43] },
    { name: "wheel_front_left", mesh: 3, translation: [-0.89, 0.39, 0.72] },
    { name: "wheel_front_right", mesh: 3, translation: [0.89, 0.39, 0.72] },
    { name: "wheel_rear_left", mesh: 3, translation: [-0.94, 0.39, -0.76] },
    { name: "wheel_rear_right", mesh: 3, translation: [0.94, 0.39, -0.76] },
  ];
}

function kartLODs() {
  const body = kartBody();
  const rider = driver();
  const cloth = cape();
  const lowWheel = wheel();
  const bodyExtra = bodyDetail();
  const driverExtra = driverDetail();
  const highWheel = wheel(16);
  const low = gltf([body, rider, cloth, lowWheel], kartNodes());
  const high = gltf(
    [combine(body, bodyExtra), combine(rider, driverExtra), cloth, highWheel],
    kartNodes(),
  );
  const lodNodes = kartNodes();
  // Detached extra nodes keep the seven animation nodes identical. The game
  // selects these meshes explicitly and supplies the corresponding body,
  // driver or wheel world transform. Drawing every node is not an LOD policy.
  lodNodes.push(
    { name: "body_detail", mesh: 4 },
    { name: "driver_detail", mesh: 5 },
    { name: "wheel_high", mesh: 6 },
  );
  const lod = gltf([body, rider, cloth, lowWheel, bodyExtra, driverExtra, highWheel], lodNodes);
  return { "kart-low": low, "kart-high": high, "kart-lod": lod };
}

function castle() {
  const mesh = new Mesh();
  mesh.box(0, 0, 0, 7, 5.5, 4.8, paint.stone);
  mesh.box(0, 5.5, 0, 3.3, 5.1, 3.3, paint.stone);
  mesh.spike([0, 10.6, 0], [0, 13.6, 0], 4.2, 4.2, paint.redShade);
  for (const side of [-1, 1]) {
    mesh.box(side * 3.9, 0, 0.6, 2.7, 7.1, 2.7, paint.stone);
    mesh.spike([side * 3.9, 7.1, 0.6], [side * 3.9, 10.2, 0.6], 3.5, 3.5, paint.redShade);
    for (const level of [3.1, 5.3]) {
      mesh.face(
        [
          [side * 3.9 - 0.26, level, 1.96],
          [side * 3.9 + 0.26, level, 1.96],
          [side * 3.9 + 0.26, level + 1, 1.96],
          [side * 3.9 - 0.26, level + 1, 1.96],
        ],
        paint.charcoal,
      );
    }
    mesh.box(side * 1.3, 5.5, 2, 0.8, 0.8, 0.8, paint.stone);
  }
  mesh.face(
    [
      [-0.8, 0, 2.42],
      [0.8, 0, 2.42],
      [0.8, 2.5, 2.42],
      [-0.8, 2.5, 2.42],
    ],
    paint.charcoal,
  );
  mesh.face(
    [
      [-0.8, 2.5, 2.42],
      [0.8, 2.5, 2.42],
      [0, 3.2, 2.42],
    ],
    paint.charcoal,
  );
  mesh.face(
    [
      [-0.25, 7.5, 1.67],
      [0.25, 7.5, 1.67],
      [0.25, 8.7, 1.67],
      [-0.25, 8.7, 1.67],
    ],
    paint.charcoal,
  );
  mesh.face(
    [
      [0, 13.4, 0],
      [0, 15.2, 0],
      [0.1, 15.2, 0],
      [0.1, 13.4, 0],
    ],
    paint.charcoal,
  );
  mesh.face(
    [
      [0.1, 15.1, 0],
      [1.6, 14.65, 0],
      [0.1, 14.25, 0],
    ],
    paint.red,
  );
  return mesh;
}

function roadside() {
  const mesh = new Mesh();
  mesh.crown(0, 0, 0, 1.45, 0.95, 1.25, paint.stone, 5);
  for (const [x, z] of [
    [-0.8, 0.3],
    [0.7, -0.2],
    [0.3, 0.7],
  ]) {
    mesh.face(
      [
        [x - 0.15, 0, z],
        [x, 0.7, z],
        [x + 0.1, 0, z],
      ],
      paint.leafShade,
    );
    mesh.face(
      [
        [x, 0, z - 0.15],
        [x, 0.55, z],
        [x, 0, z + 0.18],
      ],
      paint.leaf,
    );
    mesh.face(
      [
        [x - 0.15, 0.5, z],
        [x, 0.7, z],
        [x + 0.15, 0.5, z],
        [x, 0.35, z],
      ],
      paint.gold,
    );
  }
  return mesh;
}

function gltf(meshes, nodes) {
  const bufferViews = [];
  const accessors = [];
  const buffers = [];
  let byteLength = 0;
  function stream(values, width, componentType, type) {
    const alignment = (4 - (byteLength % 4)) % 4;
    if (alignment) {
      buffers.push(Buffer.alloc(alignment));
      byteLength += alignment;
    }
    const bytes = Buffer.alloc(values.length * width);
    values.forEach((value, index) => {
      if (componentType === 5126) {
        bytes.writeFloatLE(value, index * width);
      } else {
        bytes.writeUInt16LE(value, index * width);
      }
    });
    const view = bufferViews.length;
    bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: bytes.length });
    buffers.push(bytes);
    byteLength += bytes.length;
    const components = type === "VEC3" ? 3 : type === "VEC2" ? 2 : 1;
    const accessor = { bufferView: view, componentType, count: values.length / components, type };
    if (type === "VEC3") {
      accessor.min = Array.from({ length: 3 }, (_, axis) =>
        Math.min(...values.filter((_, index) => index % 3 === axis)),
      );
      accessor.max = Array.from({ length: 3 }, (_, axis) =>
        Math.max(...values.filter((_, index) => index % 3 === axis)),
      );
    }
    accessors.push(accessor);
    return accessors.length - 1;
  }
  const geometry = meshes.map((mesh) => ({
    primitives: [
      {
        attributes: {
          POSITION: stream(mesh.positions, 4, 5126, "VEC3"),
          TEXCOORD_0: stream(mesh.uv, 4, 5126, "VEC2"),
        },
        indices: stream(mesh.indices, 2, 5123, "SCALAR"),
        material: 0,
      },
    ],
  }));
  const children = new Set(nodes.flatMap((node) => node.children ?? []));
  return {
    asset: { version: "2.0", generator: "Sunline Sprint original asset workshop" },
    scene: 0,
    scenes: [{ nodes: nodes.map((_, index) => index).filter((index) => !children.has(index)) }],
    nodes: nodes.map((node) => ({
      ...node,
      ...(node.translation
        ? {
            translation: [node.translation[0], node.translation[1], -node.translation[2]].map(snap),
          }
        : {}),
    })),
    meshes: geometry,
    accessors,
    bufferViews,
    buffers: [
      {
        byteLength,
        uri: `data:application/octet-stream;base64,${Buffer.concat(buffers).toString("base64")}`,
      },
    ],
    images: [{ uri: "atlas.png" }],
    samplers: [{ magFilter: 9728, minFilter: 9728, wrapS: 33071, wrapT: 33071 }],
    textures: [{ sampler: 0, source: 0 }],
    materials: [
      {
        name: "sunline_painted_atlas",
        pbrMetallicRoughness: {
          baseColorTexture: { index: 0 },
          metallicFactor: 0,
          roughnessFactor: 1,
        },
        alphaMode: "OPAQUE",
      },
    ],
  };
}

export async function generateAssets() {
  await mkdir(assetDirectory, { recursive: true });
  await writeFile(resolve(assetDirectory, "atlas.png"), atlas());
  const models = {
    ...kartLODs(),
    tree: gltf([tree()], [{ name: "tree", mesh: 0 }]),
    castle: gltf([castle()], [{ name: "castle", mesh: 0 }]),
    roadside: gltf([roadside()], [{ name: "roadside", mesh: 0 }]),
  };
  for (const [name, model] of Object.entries(models)) {
    await writeFile(resolve(assetDirectory, `${name}.gltf`), JSON.stringify(model, null, 2) + "\n");
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  await generateAssets();
}