ShiboSoftwareDev/linux-gameboy-advance

A multilayer PCB carrier board routing the MCU, USB‑C, battery/AA power selector and TPS63020 regulator, LCD/SD interfaces, UART/I²C/SPI service headers, controls, audio/status LEDs, and backlight with reviewed traces and vias.

Version
1.0.125
License
unset
Stars
0

scripts/check-assembly-collisions.mjs

import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import modeling from "@jscad/modeling"

const { booleans, geometries, measurements, transforms } = modeling
const { geom3, poly3 } = geometries

const assemblyPath = fileURLToPath(new URL("../assembly/linux-gameboy-advance-assembly.glb", import.meta.url))
const glb = readFileSync(assemblyPath)
if (glb.toString("ascii", 0, 4) !== "glTF") throw new Error("Expected a GLB assembly")

const jsonChunkLength = glb.readUInt32LE(12)
const gltf = JSON.parse(glb.subarray(20, 20 + jsonChunkLength).toString("utf8").trim())
const binaryChunkOffset = 20 + jsonChunkLength + 8

const readVec3Accessor = (accessorIndex) => {
  const accessor = gltf.accessors[accessorIndex]
  const view = gltf.bufferViews[accessor.bufferView]
  if (accessor.componentType !== 5126 || accessor.type !== "VEC3") {
    throw new Error(`Expected FLOAT VEC3 accessor, got ${accessor.componentType}/${accessor.type}`)
  }
  const offset = binaryChunkOffset + (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0)
  const stride = view.byteStride ?? 12
  return Array.from({ length: accessor.count }, (_, index) => [
    glb.readFloatLE(offset + index * stride),
    glb.readFloatLE(offset + index * stride + 4),
    glb.readFloatLE(offset + index * stride + 8),
  ])
}

const readIndexAccessor = (accessorIndex) => {
  const accessor = gltf.accessors[accessorIndex]
  const view = gltf.bufferViews[accessor.bufferView]
  const bytesPerIndex = accessor.componentType === 5121 ? 1 : accessor.componentType === 5123 ? 2 : 4
  const offset = binaryChunkOffset + (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0)
  const stride = view.byteStride ?? bytesPerIndex
  const read = accessor.componentType === 5121
    ? (at) => glb.readUInt8(at)
    : accessor.componentType === 5123
      ? (at) => glb.readUInt16LE(at)
      : (at) => glb.readUInt32LE(at)
  return Array.from({ length: accessor.count }, (_, index) => read(offset + index * stride))
}

const nodeByName = (name) => {
  const node = gltf.nodes.find((candidate) => candidate.name === name)
  if (!node) throw new Error(`Assembly is missing ${name}`)
  if (node.rotation || node.scale || node.matrix) {
    throw new Error(`${name} uses an unsupported node transform; bake it into the mesh before collision checking`)
  }
  return node
}

const geometryCache = new Map()
const geometryForNode = (name) => {
  if (geometryCache.has(name)) return geometryCache.get(name)
  const node = nodeByName(name)
  const translation = node.translation ?? [0, 0, 0]
  const polygons = []
  for (const primitive of gltf.meshes[node.mesh].primitives) {
    if ((primitive.mode ?? 4) !== 4) throw new Error(`${name} contains a non-triangle primitive`)
    const positions = readVec3Accessor(primitive.attributes.POSITION).map((point) =>
      point.map((coordinate, axis) => coordinate + translation[axis]))
    const indices = primitive.indices == null
      ? positions.map((_, index) => index)
      : readIndexAccessor(primitive.indices)
    let signedVolume = 0
    for (let index = 0; index < indices.length; index += 3) {
      const a = positions[indices[index]]
      const b = positions[indices[index + 1]]
      const c = positions[indices[index + 2]]
      signedVolume += (
        a[0] * (b[1] * c[2] - b[2] * c[1]) -
        a[1] * (b[0] * c[2] - b[2] * c[0]) +
        a[2] * (b[0] * c[1] - b[1] * c[0])
      ) / 6
    }
    const reverse = signedVolume < 0
    for (let index = 0; index < indices.length; index += 3) {
      const points = reverse
        ? [positions[indices[index + 2]], positions[indices[index + 1]], positions[indices[index]]]
        : [positions[indices[index]], positions[indices[index + 1]], positions[indices[index + 2]]]
      polygons.push(poly3.fromPoints(points))
    }
  }
  const geometry = geom3.create(polygons)
  geometryCache.set(name, geometry)
  return geometry
}

const moved = (geometry, delta) => transforms.translate(delta, geometry)
const rotatedAroundX = (geometry, degrees, pivot) => transforms.translate(
  pivot,
  transforms.rotateX(degrees * Math.PI / 180,
    transforms.translate(pivot.map((value) => -value), geometry)),
)
const overlapDetails = (leftName, rightName, leftDelta = [0, 0, 0], rightDelta = [0, 0, 0], leftRotation) => {
  let left = leftDelta.some(Boolean) ? moved(geometryForNode(leftName), leftDelta) : geometryForNode(leftName)
  if (leftRotation) left = rotatedAroundX(left, leftRotation.degrees, leftRotation.pivot)
  const right = rightDelta.some(Boolean) ? moved(geometryForNode(rightName), rightDelta) : geometryForNode(rightName)
  const intersection = booleans.intersect(left, right)
  const signedVolume = measurements.measureVolume(intersection)
  return {
    // Imported GLB triangles are reoriented per primitive above, but JSCAD's
    // intersection can still return a globally inverted solid when one input
    // contains nested subtractive shells.  Collision volume is scalar, so a
    // signed result must never be allowed to pass the release gate.
    volume: Math.abs(signedVolume),
    signedVolume,
    bounds: measurements.measureBoundingBox(intersection),
  }
}

const probes = [
  { label: "left shoulder at rest / rear shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_BASE" },
  { label: "right shoulder at rest / rear shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_BASE" },
  { label: "left shoulder at full stroke / rear shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_BASE", leftDelta: [0, 0, -0.65] },
  { label: "right shoulder at full stroke / rear shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_BASE", leftDelta: [0, 0, -0.65] },
  { label: "left shoulder at rest / front shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_LID" },
  { label: "right shoulder at rest / front shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_LID" },
  { label: "left shoulder at full stroke / front shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_LID", leftDelta: [0, 0, -0.65] },
  { label: "right shoulder at full stroke / front shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_LID", leftDelta: [0, 0, -0.65] },
  { label: "PCB / rear shell", left: "Box0", right: "GBA_CASE_BASE" },
  { label: "PCB / front shell", left: "Box0", right: "GBA_CASE_LID" },
  { label: "rear shell / front shell", left: "GBA_CASE_BASE", right: "GBA_CASE_LID" },
  { label: "LCD carrier / rear shell", left: "LCD_CARRIER", right: "GBA_CASE_BASE" },
  { label: "LCD carrier / front shell", left: "LCD_CARRIER", right: "GBA_CASE_LID" },
  { label: "left AA envelope / battery door", left: "BAT_AA_1", right: "GBA_BATTERY_DOOR" },
  { label: "right AA envelope / battery door", left: "BAT_AA_2", right: "GBA_BATTERY_DOOR" },
  { label: "battery door / rear shell", left: "GBA_BATTERY_DOOR", right: "GBA_CASE_BASE" },
  // The 0-4 degree transition is where the compliant latch deflects; its
  // surface strain is checked analytically in export-3d-assembly.mjs. These
  // rigid-body probes validate the tongue-guided remainder of the motion.
  ...[4, 6, 8].map((degrees) => ({
    label: `battery door ${degrees} degree installation sweep / rear shell`,
    left: "GBA_BATTERY_DOOR",
    right: "GBA_CASE_BASE",
    leftRotation: { degrees: -degrees, pivot: [0, -16.02, 34.15] },
  })),
  { label: "speaker retainer / rear shell", left: "SPK_RETAINER", right: "GBA_CASE_BASE" },
  ...["BAT_WIRE_NEG", "BAT_WIRE_POS"].flatMap((wire) => [
    { label: `${wire} / rear shell`, left: wire, right: "GBA_CASE_BASE" },
    { label: `${wire} / front shell`, left: wire, right: "GBA_CASE_LID" },
    { label: `${wire} / PCB substrate`, left: wire, right: "Box0" },
    { label: `${wire} / left AA envelope`, left: wire, right: "BAT_AA_1" },
    { label: `${wire} / right AA envelope`, left: wire, right: "BAT_AA_2" },
    { label: `${wire} / battery door`, left: wire, right: "GBA_BATTERY_DOOR" },
    { label: `${wire} / LCD carrier`, left: wire, right: "LCD_CARRIER" },
    ...["MH_TOP_LEFT", "MH_TOP_RIGHT", "MH_BOTTOM_LEFT", "MH_BOTTOM_RIGHT"].map((mount) => ({
      label: `${wire} / ${mount} service screw`, left: wire, right: `${mount}_M3X24`,
    })),
  ]),
  ...["SPK_WIRE_NEG", "SPK_WIRE_POS"].flatMap((wire) => [
    { label: `${wire} / rear shell`, left: wire, right: "GBA_CASE_BASE" },
    { label: `${wire} / front shell`, left: wire, right: "GBA_CASE_LID" },
    { label: `${wire} / PCB substrate`, left: wire, right: "Box0" },
    { label: `${wire} / speaker retainer`, left: wire, right: "SPK_RETAINER" },
    { label: `${wire} / left AA envelope`, left: wire, right: "BAT_AA_1" },
    { label: `${wire} / right AA envelope`, left: wire, right: "BAT_AA_2" },
    { label: `${wire} / battery door`, left: wire, right: "GBA_BATTERY_DOOR" },
    { label: `${wire} / LCD carrier`, left: wire, right: "LCD_CARRIER" },
    ...["MH_TOP_LEFT", "MH_TOP_RIGHT", "MH_BOTTOM_LEFT", "MH_BOTTOM_RIGHT"].map((mount) => ({
      label: `${wire} / ${mount} service screw`, left: wire, right: `${mount}_M3X24`,
    })),
  ]),
  { label: "battery harness conductor separation", left: "BAT_WIRE_NEG", right: "BAT_WIRE_POS" },
  { label: "speaker harness conductor separation", left: "SPK_WIRE_NEG", right: "SPK_WIRE_POS" },
  ...["A", "B", "X", "Y", "SELECT", "START"].flatMap((name) => [
    { label: `${name} cap at rest / front shell`, left: `BUTTON_CAP_${name}`, right: "GBA_CASE_LID" },
    { label: `${name} cap at full stroke / front shell`, left: `BUTTON_CAP_${name}`, right: "GBA_CASE_LID", leftDelta: [0, -0.65, 0] },
  ]),
  { label: "D-pad at rest / front shell", left: "DPAD_CAP", right: "GBA_CASE_LID" },
  { label: "D-pad at full nominal stroke / front shell", left: "DPAD_CAP", right: "GBA_CASE_LID", leftDelta: [0, -0.65, 0] },
]

const toleranceMm3 = 0.001
const failures = []
for (const { label, left, right, leftDelta, rightDelta, leftRotation } of probes) {
  const { volume, signedVolume, bounds } = overlapDetails(left, right, leftDelta, rightDelta, leftRotation)
  const status = volume <= toleranceMm3 ? "PASS" : "FAIL"
  const signNote = signedVolume < -toleranceMm3 ? ` (signed ${signedVolume.toFixed(6)})` : ""
  console.log(`[collision] ${status} ${label}: ${volume.toFixed(6)} mm^3${signNote}${volume > toleranceMm3 ? ` ${JSON.stringify(bounds)}` : ""}`)
  if (volume > toleranceMm3) failures.push(`${label}: ${volume.toFixed(6)} mm^3`)
}

if (failures.length > 0) {
  throw new Error(`Assembly interference detected:\n- ${failures.join("\n- ")}`)
}

console.log(`[collision] ${probes.length} swept-volume checks passed (tolerance ${toleranceMm3} mm^3)`)