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/export-3d-assembly.mjs

import { copyFileSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { createHash } from "node:crypto"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { getCloudArtifactSourceState } from "./cloud-artifact-sources.mjs"

const projectDirectory = fileURLToPath(new URL("../", import.meta.url))
const circuitPath = fileURLToPath(new URL("../dist/index/circuit.json", import.meta.url))
const generatedGlbPath = fileURLToPath(new URL("../dist/dist/index/3d.glb", import.meta.url))
const assemblyDirectory = fileURLToPath(new URL("../assembly/", import.meta.url))
const assemblyPath = fileURLToPath(new URL("../assembly/linux-gameboy-advance-assembly.glb", import.meta.url))
const metadataPath = fileURLToPath(new URL("../assembly/linux-gameboy-advance-assembly.json", import.meta.url))

const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex")
const sourceState = getCloudArtifactSourceState(projectDirectory)
if (statSync(circuitPath).mtimeMs < sourceState.newestSourceMtimeMs) {
  throw new Error(
    `${circuitPath} predates a board source file; run \`bun run build\` before exporting or reviewing the 3D assembly`,
  )
}
const circuitJson = JSON.parse(readFileSync(circuitPath, "utf8"))

// tsci currently reports a successful process exit even when a model download
// fails during GLB generation.  Remove any previous export first so a stale
// assembly can never satisfy the validation below.
rmSync(generatedGlbPath, { force: true })
const exportResult = spawnSync(
  "tsci",
  ["build", "dist/index/circuit.json", "--ignore-config", "--glbs", "--concurrency", "1"],
  { cwd: projectDirectory, stdio: "inherit" },
)

if (exportResult.error) throw exportResult.error
if (exportResult.status !== 0) {
  throw new Error(`tsci GLB export failed with exit code ${exportResult.status}`)
}

const glb = readFileSync(generatedGlbPath)
if (glb.toString("ascii", 0, 4) !== "glTF") throw new Error("Assembly is not a GLB file")
if (glb.readUInt32LE(4) !== 2) throw new Error("Assembly is not glTF 2.0")
if (glb.readUInt32LE(8) !== glb.byteLength) throw new Error("GLB header length is invalid")

const jsonChunkLength = glb.readUInt32LE(12)
if (glb.readUInt32LE(16) !== 0x4e4f534a) throw new Error("GLB has no JSON chunk")
const gltf = JSON.parse(glb.subarray(20, 20 + jsonChunkLength).toString("utf8").trim())
const binaryChunkOffset = 20 + jsonChunkLength + 8
const nodeNames = new Set((gltf.nodes ?? []).map((node) => node.name).filter(Boolean))
const enclosurePartNames = [
  "GBA_CASE_BASE", "GBA_CASE_LID", "GBA_BATTERY_DOOR",
  "LCD_CARRIER", "SPK_RETAINER", "DPAD_CAP", "BUTTON_CAP_A", "BUTTON_CAP_B",
  "BUTTON_CAP_X", "BUTTON_CAP_Y", "BUTTON_CAP_SELECT", "BUTTON_CAP_START",
  "SHOULDER_PADDLE_L", "SHOULDER_PADDLE_R",
]
const enclosureBodies = (gltf.nodes ?? []).filter((node) => enclosurePartNames.includes(node.name)).length

const sourceComponents = new Map(
  circuitJson
    .filter((element) => element.type === "source_component")
    .map((component) => [component.source_component_id, component]),
)

// jscad-fiber follows @jscad/modeling and stores rotation angles in radians.
// A degree-valued `90` still produces valid geometry, but it turns a straight
// actuator pocket into a diagonal cut. Reject implausibly large rotations in
// every printable enclosure part before a misleading GLB can pass the later
// envelope-only checks.
const printableSourceIds = new Set(
  [...sourceComponents.values()]
    .filter((component) => component.ftype === "printedpart")
    .map((component) => component.source_component_id),
)
const collectRotations = (operation, rotations = []) => {
  if (operation == null || typeof operation !== "object") return rotations
  if (operation.type === "rotate" && Array.isArray(operation.angles)) rotations.push(operation.angles)
  if (operation.shape) collectRotations(operation.shape, rotations)
  for (const shape of operation.shapes ?? []) collectRotations(shape, rotations)
  return rotations
}
for (const cad of circuitJson.filter((element) =>
  element.type === "cad_component" && printableSourceIds.has(element.source_component_id))) {
  for (const angles of collectRotations(cad.model_jscad)) {
    if (angles.some((angle) => Math.abs(angle) > 2 * Math.PI + 1e-6)) {
      const name = sourceComponents.get(cad.source_component_id)?.name ?? cad.source_component_id
      throw new Error(`${name} contains a non-radian JSCAD rotation: ${JSON.stringify(angles)}`)
    }
  }
}
const pcbComponents = new Map(
  circuitJson
    .filter((element) => element.type === "pcb_component")
    .map((component) => [component.source_component_id, component]),
)
const expectedNames = circuitJson
  .filter((element) => element.type === "cad_component")
  .map((cad) => sourceComponents.get(cad.source_component_id))
  .filter(Boolean)
  .filter((component) => component.ftype !== "simple_test_point")
  .filter((component) => !pcbComponents.get(component.source_component_id)?.do_not_place)
  .map((component) => component.name)

const missingNames = expectedNames.filter((name) => !nodeNames.has(name))
if (missingNames.length > 0) {
  throw new Error(`Assembly is missing fitted component bodies: ${missingNames.join(", ")}`)
}

for (const requiredName of [
  "U1", "J_LCD", "J_SD", "J_USB", "ER_TFT035IPS_6", "BAT_AA_1", "BAT_AA_2",
  "BAT_CONTACT_COMMON_210", "BATTERY_INSULATOR", "LCD_FRONT_GASKET", "LCD_REAR_GASKET", "SPK_20MM",
  "STATUS_LIGHT_PIPE", "BAT_WIRE_NEG", "BAT_WIRE_POS", "SPK_WIRE_NEG", "SPK_WIRE_POS",
  "BUTTON_PRELOAD_A", "BUTTON_PRELOAD_B", "BUTTON_PRELOAD_X", "BUTTON_PRELOAD_Y",
  "BUTTON_PRELOAD_SELECT", "BUTTON_PRELOAD_START", "DPAD_PIVOT_PAD",
  ...enclosurePartNames,
]) {
  if (!nodeNames.has(requiredName)) throw new Error(`Assembly is missing ${requiredName}`)
}
if (enclosureBodies !== enclosurePartNames.length) {
  throw new Error(`Assembly must contain all ${enclosurePartNames.length} printable mechanical bodies; found ${enclosureBodies}`)
}

// Validate the evaluated GLB, not only the JSCAD source coordinates. Rotating
// a cylinder in the enclosure DSL changes the evaluated rear-face datum, which
// previously left both shoulder bores 1.74 mm above their real actuator stems.
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]
  if (accessor.type !== "SCALAR" || ![5121, 5123, 5125].includes(accessor.componentType)) {
    throw new Error(`Expected unsigned SCALAR index accessor, got ${accessor.componentType}/${accessor.type}`)
  }
  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 node ${name}`)
  return node
}

const meshPositions = (node) => gltf.meshes[node.mesh].primitives.flatMap((primitive) =>
  readVec3Accessor(primitive.attributes.POSITION))
const translatedPrimitivePositions = (node, primitive) =>
  readVec3Accessor(primitive.attributes.POSITION).map((position) => position.map(
    (coordinate, axis) => coordinate + (node.translation?.[axis] ?? 0)))

// A printed part can look plausible in the assembled viewer while containing
// loose, unprintable islands.  Count connected triangle shells in the actual
// evaluated GLB and reject every multi-body printable part.
const countConnectedTriangleBodies = (node) => {
  const positions = []
  const triangles = []
  for (const primitive of gltf.meshes[node.mesh].primitives) {
    const primitivePositions = readVec3Accessor(primitive.attributes.POSITION)
    const baseVertex = positions.length
    positions.push(...primitivePositions)
    const indices = primitive.indices == null
      ? primitivePositions.map((_, index) => index)
      : readIndexAccessor(primitive.indices)
    for (let index = 0; index < indices.length; index += 3) {
      triangles.push([
        baseVertex + indices[index],
        baseVertex + indices[index + 1],
        baseVertex + indices[index + 2],
      ])
    }
  }

  const parent = triangles.map((_, index) => index)
  const find = (index) => parent[index] === index ? index : (parent[index] = find(parent[index]))
  const join = (left, right) => {
    const leftRoot = find(left)
    const rightRoot = find(right)
    if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot
  }
  const triangleAtPosition = new Map()
  const positionKey = (position) => position.map((coordinate) => Math.round(coordinate * 1000)).join(",")
  triangles.forEach((triangle, triangleIndex) => triangle.forEach((vertexIndex) => {
    const key = positionKey(positions[vertexIndex])
    if (triangleAtPosition.has(key)) join(triangleIndex, triangleAtPosition.get(key))
    else triangleAtPosition.set(key, triangleIndex)
  }))
  return new Set(triangles.map((_, index) => find(index))).size
}

const printableBodyConnectivity = enclosurePartNames.map((name) => {
  const bodyCount = countConnectedTriangleBodies(nodeByName(name))
  if (bodyCount !== 1) throw new Error(`${name} evaluates to ${bodyCount} disconnected printable bodies`)
  return { name, bodyCount }
})

const nodeBounds = (name) => {
  const node = nodeByName(name)
  const translation = node.translation ?? [0, 0, 0]
  const positions = meshPositions(node).map((position) => position.map(
    (coordinate, axis) => coordinate + translation[axis]))
  const min = [Infinity, Infinity, Infinity]
  const max = [-Infinity, -Infinity, -Infinity]
  for (const position of positions) {
    for (let axis = 0; axis < 3; axis += 1) {
      min[axis] = Math.min(min[axis], position[axis])
      max[axis] = Math.max(max[axis], position[axis])
    }
  }
  return { min, max }
}

// Screen-carrier clearance must cover every fitted PCB body, not a hand-picked
// list of parts that happened to collide in an earlier revision.  Convert the
// evaluated GLB bounds back to the enclosure datum (GLB X is mirrored, GLB Y
// is enclosure Z, GLB Z is enclosure Y), then conservatively test each body
// against every carrier boss and rail.  AABB contact is deliberately treated
// as a release blocker; the carrier should never depend on a component model's
// chamfer or decorative void for assembly clearance.
const carrierBosses = [
  { x: -48.75, y: 32.75, radius: 3.6, minZ: 0.8, maxZ: 7.15 },
  { x: 51, y: 33, radius: 3.6, minZ: 0.8, maxZ: 7.15 },
  { x: -49, y: -35.5, radius: 3.6, minZ: 0.8, maxZ: 7.15 },
  { x: 66, y: -31, radius: 3.6, minZ: 0.8, maxZ: 7.15 },
]
const carrierBeam = (from, to, width, minZ = 6.4, maxZ = 8.2) => ({ from, to, width, minZ, maxZ })
const carrierRails = [
  carrierBeam([-41.5, -11.2], [36.5, -11.2], 4),
  carrierBeam([-37.5, 41.2], [36.5, 41.2], 4),
  carrierBeam([-49, -35.5], [-42.8, -12.0], 5),
  carrierBeam([-42.8, 8.0], [-42.8, 31.8], 5),
  carrierBeam([-42.8, 31.8], [-35.0, 36.4], 5),
  carrierBeam([-35.0, 36.4], [-35.0, 41.2], 5),
  carrierBeam([-48.75, 32.75], [-42.8, 31.8], 5),
  carrierBeam([66, -31], [66, -20], 6),
  carrierBeam([66, -20], [50, -20], 5),
  carrierBeam([50, -20], [39, -11.2], 5),
  carrierBeam([39, -11.2], [39, 33], 5),
  carrierBeam([39, 33], [36.5, 33], 5),
  carrierBeam([36.5, 33], [51, 33], 5),
  carrierBeam([36.5, 33], [36.5, 41.2], 5),
]
const toEnclosureBounds = ({ min, max }) => ({
  minX: -max[0], maxX: -min[0], minY: min[2], maxY: max[2], minZ: min[1], maxZ: max[1],
})
const zOverlaps = (bounds, member) => bounds.minZ < member.maxZ - 0.02 && bounds.maxZ > member.minZ + 0.02
const pointToSegmentDistance2d = (point, start, end) => {
  const dx = end[0] - start[0]
  const dy = end[1] - start[1]
  const lengthSquared = dx * dx + dy * dy
  const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1,
    ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared))
  return Math.hypot(point[0] - (start[0] + t * dx), point[1] - (start[1] + t * dy))
}
const pointToBoundsDistance2d = (point, bounds) => Math.hypot(
  Math.max(bounds.minX - point[0], 0, point[0] - bounds.maxX),
  Math.max(bounds.minY - point[1], 0, point[1] - bounds.maxY),
)
const segmentsIntersect2d = (a, b, c, d) => {
  const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
  const c1 = cross(a, b, c)
  const c2 = cross(a, b, d)
  const c3 = cross(c, d, a)
  const c4 = cross(c, d, b)
  return c1 * c2 <= 0 && c3 * c4 <= 0
}
const capsuleToBoundsGap = (member, bounds) => {
  const corners = [
    [bounds.minX, bounds.minY], [bounds.minX, bounds.maxY],
    [bounds.maxX, bounds.minY], [bounds.maxX, bounds.maxY],
  ]
  const edges = [[corners[0], corners[1]], [corners[1], corners[3]],
    [corners[3], corners[2]], [corners[2], corners[0]]]
  if (pointToBoundsDistance2d(member.from, bounds) === 0 || pointToBoundsDistance2d(member.to, bounds) === 0 ||
      edges.some(([start, end]) => segmentsIntersect2d(member.from, member.to, start, end))) return 0
  return Math.min(
    pointToBoundsDistance2d(member.from, bounds),
    pointToBoundsDistance2d(member.to, bounds),
    ...corners.map((corner) => pointToSegmentDistance2d(corner, member.from, member.to)),
  )
}
const rectangleOverlaps = (bounds, member) => capsuleToBoundsGap(member, bounds) < member.width / 2 + 0.02
const circleToBoundsGap = (boss, bounds) => Math.hypot(
  Math.max(bounds.minX - boss.x, 0, boss.x - bounds.maxX),
  Math.max(bounds.minY - boss.y, 0, boss.y - bounds.maxY),
)
const fittedPcbBodyNames = expectedNames.filter((name) => {
  const ftype = [...sourceComponents.values()].find((component) => component.name === name)?.ftype
  return ftype !== "printedpart" && ftype !== "subassembly" && ftype !== "simple_test_point"
})
const carrierComponentCollisions = []
for (const name of fittedPcbBodyNames) {
  const bounds = toEnclosureBounds(nodeBounds(name))
  for (const [index, boss] of carrierBosses.entries()) {
    if (zOverlaps(bounds, boss) && circleToBoundsGap(boss, bounds) < boss.radius + 0.02) {
      carrierComponentCollisions.push({ name, member: `boss_${index + 1}`, bounds })
    }
  }
  for (const [index, rail] of carrierRails.entries()) {
    if (zOverlaps(bounds, rail) && rectangleOverlaps(bounds, rail)) {
      carrierComponentCollisions.push({ name, member: `rail_${index + 1}`, bounds })
    }
  }
}
if (carrierComponentCollisions.length > 0) {
  throw new Error(`LCD carrier intersects fitted component envelopes: ${JSON.stringify(carrierComponentCollisions)}`)
}

// Check the complete LCD compression stack from the exported meshes.  This
// catches the subtle but serious case where a gasket is visible in 3D yet is
// embedded inside the carrier or floating short of the panel.
const screenNode = nodeByName("ER_TFT035IPS_6")
const screenPrimitiveBounds = gltf.meshes[screenNode.mesh].primitives.map((primitive) => {
  const positions = translatedPrimitivePositions(screenNode, primitive)
  return {
    positions,
    min: [0, 1, 2].map((axis) => Math.min(...positions.map((position) => position[axis]))),
    max: [0, 1, 2].map((axis) => Math.max(...positions.map((position) => position[axis]))),
  }
})
const rigidRearPrimitive = screenPrimitiveBounds.find(({ min, max }) =>
  max[1] - min[1] < 0.01 && max[0] - min[0] > 60 && max[2] - min[2] > 50)
if (!rigidRearPrimitive) throw new Error("Could not isolate the real LCD rigid rear plane")
const screenRigidRearY = (rigidRearPrimitive.min[1] + rigidRearPrimitive.max[1]) / 2
const screenRigidFrontY = Math.max(...screenPrimitiveBounds.flatMap(({ positions }) =>
  positions.map(([, y]) => y)))
const carrierBounds = nodeBounds("LCD_CARRIER")
const rearGasketBounds = nodeBounds("LCD_REAR_GASKET")
const frontGasketBounds = nodeBounds("LCD_FRONT_GASKET")
const lidDisplayBearingVertices = meshPositions(nodeByName("GBA_CASE_LID")).filter(([x, y, z]) => {
  const inOuterRing = Math.abs(x - 2.18) <= 42.9 && Math.abs(z - 15) <= 28.2
  const inWindow = Math.abs(x - 2.18) < 37.2 && Math.abs(z - 15) < 24.95
  return inOuterRing && !inWindow && y > 10
})
if (lidDisplayBearingVertices.length === 0) throw new Error("Could not isolate the LCD bezel bearing surface")
const lidDisplayBearingY = Math.min(...lidDisplayBearingVertices.map(([, y]) => y))
const lcdStack = {
  rigidRearY: screenRigidRearY,
  rigidFrontY: screenRigidFrontY,
  carrierBearingY: carrierBounds.max[1],
  rearGasketToCarrierMm: rearGasketBounds.min[1] - carrierBounds.max[1],
  rearGasketCompressionMm: rearGasketBounds.max[1] - screenRigidRearY,
  frontGasketPanelCompressionMm: screenRigidFrontY - frontGasketBounds.min[1],
  frontGasketLidCompressionMm: frontGasketBounds.max[1] - lidDisplayBearingY,
}
if (Math.abs(lcdStack.rearGasketToCarrierMm) > 0.03 ||
    lcdStack.rearGasketCompressionMm < 0.08 || lcdStack.rearGasketCompressionMm > 0.12) {
  throw new Error(`Rear LCD gasket does not bridge carrier to panel: ${JSON.stringify(lcdStack)}`)
}
const frontCompression = lcdStack.frontGasketPanelCompressionMm + lcdStack.frontGasketLidCompressionMm
if (lcdStack.frontGasketPanelCompressionMm < 0 || lcdStack.frontGasketLidCompressionMm < 0 ||
    frontCompression < 0.04 || frontCompression > 0.07) {
  throw new Error(`Front LCD gasket does not bridge panel to bezel: ${JSON.stringify(lcdStack)}`)
}

const speakerBounds = nodeBounds("SPK_20MM")
const speakerGasketBounds = nodeBounds("SPK_FOAM_GASKET")
const speakerRetainerBounds = nodeBounds("SPK_RETAINER")
const speakerStack = {
  gasketToRearFloorMm: speakerGasketBounds.min[1] - (-15.8),
  gasketCompressionMm: speakerGasketBounds.max[1] - speakerBounds.min[1],
  retainerPreloadMm: speakerBounds.max[1] - speakerRetainerBounds.min[1],
}
const speakerEnvelope = {
  diameterMm: Math.max(speakerBounds.max[0] - speakerBounds.min[0], speakerBounds.max[2] - speakerBounds.min[2]),
  totalHeightMm: speakerBounds.max[1] - speakerBounds.min[1],
}
if (speakerEnvelope.diameterMm < 19.95 || speakerEnvelope.diameterMm > 20.05 ||
    speakerEnvelope.totalHeightMm < 4.15 || speakerEnvelope.totalHeightMm > 4.25) {
  throw new Error(`CMS-2004-18SP envelope is invalid: ${JSON.stringify(speakerEnvelope)}`)
}
if (Math.abs(speakerStack.gasketToRearFloorMm) > 0.03) {
  throw new Error(`Speaker gasket misses the rear floor by ${speakerStack.gasketToRearFloorMm.toFixed(3)} mm`)
}
if (speakerStack.gasketCompressionMm < 0.08 || speakerStack.gasketCompressionMm > 0.18) {
  throw new Error(`Speaker gasket compression is ${speakerStack.gasketCompressionMm.toFixed(3)} mm`)
}
if (speakerStack.retainerPreloadMm < 0.08 || speakerStack.retainerPreloadMm > 0.18) {
  throw new Error(`Speaker retainer preload is ${speakerStack.retainerPreloadMm.toFixed(3)} mm`)
}

const lidBounds = nodeBounds("GBA_CASE_LID")
const faceButtonTravel = ["BUTTON_CAP_A", "BUTTON_CAP_B", "BUTTON_CAP_X", "BUTTON_CAP_Y",
  "BUTTON_CAP_SELECT", "BUTTON_CAP_START"].map((name) => ({
  name,
  proudOfLidMm: nodeBounds(name).max[1] - lidBounds.max[1],
}))
for (const { name, proudOfLidMm } of faceButtonTravel) {
  if (proudOfLidMm < 3.70 || proudOfLidMm > 3.90) {
    throw new Error(`${name} is ${proudOfLidMm.toFixed(3)} mm proud of the lid`)
  }
}

const faceButtonAlignment = ["A", "B", "X", "Y", "SELECT", "START"].map((label) => {
  const switchNode = nodeByName(`SW_${label}`)
  const switchTranslation = switchNode.translation ?? [0, 0, 0]
  const switchPositions = meshPositions(switchNode).map((position) => position.map(
    (coordinate, axis) => coordinate + switchTranslation[axis]))
  const actuatorTipY = Math.max(...switchPositions.map(([, y]) => y))
  const actuatorTip = switchPositions.filter(([, y]) => y >= actuatorTipY - 0.45)
  const tipMinX = Math.min(...actuatorTip.map(([x]) => x))
  const tipMaxX = Math.max(...actuatorTip.map(([x]) => x))
  const tipMinZ = Math.min(...actuatorTip.map(([, , z]) => z))
  const tipMaxZ = Math.max(...actuatorTip.map(([, , z]) => z))
  const capBounds = nodeBounds(`BUTTON_CAP_${label}`)
  const centerErrorMm = Math.hypot(
    (capBounds.min[0] + capBounds.max[0]) / 2 - (tipMinX + tipMaxX) / 2,
    (capBounds.min[2] + capBounds.max[2]) / 2 - (tipMinZ + tipMaxZ) / 2,
  )
  const restGapMm = 15.85 - actuatorTipY
  const actuatorRadiusMm = Math.max(tipMaxX - tipMinX, tipMaxZ - tipMinZ) / 2
  const socketRadialClearanceMm = 1.78 - actuatorRadiusMm
  if (centerErrorMm > 0.03 || restGapMm < 0.03 || restGapMm > 0.08 || socketRadialClearanceMm < 0.15) {
    throw new Error(`${label} cap/switch stack is invalid: ${JSON.stringify({ centerErrorMm, restGapMm, socketRadialClearanceMm })}`)
  }
  return { label, centerErrorMm, restGapMm, socketRadialClearanceMm }
})

const faceButtonPreload = ["A", "B", "X", "Y", "SELECT", "START"].map((label) => {
  const bounds = nodeBounds(`BUTTON_PRELOAD_${label}`)
  const installedThicknessMm = bounds.max[1] - bounds.min[1]
  const washerToLidMm = 11.6 - bounds.max[1]
  if (installedThicknessMm < 0.095 || installedThicknessMm > 0.105 || Math.abs(washerToLidMm) > 0.03) {
    throw new Error(`${label} anti-rattle washer is not installed against the lid: ` +
      JSON.stringify({ installedThicknessMm, washerToLidMm }))
  }
  return { label, nominalThicknessMm: 0.15, installedThicknessMm, compressionMm: 0.05, washerToLidMm }
})

const dpadPivotBounds = nodeBounds("DPAD_PIVOT_PAD")
const dpadBounds = nodeBounds("DPAD_CAP")
const dpadReturn = {
  pivotPadHeightMm: dpadPivotBounds.max[1] - dpadPivotBounds.min[1],
  capturedOverlapMm: dpadPivotBounds.max[1] - dpadBounds.min[1],
}
if (dpadReturn.pivotPadHeightMm < 0.85 || dpadReturn.capturedOverlapMm < 0.60) {
  throw new Error(`D-pad silicone return pad is not positively captured: ${JSON.stringify(dpadReturn)}`)
}

// The enclosure is only useful if the nominal hardware stack actually closes.
// Measure the exported screw/insert/washer/PCB geometry instead of repeating
// the source dimensions in a second assertion.
const pcbBounds = nodeBounds("Box0")
const closureScrewBounds = nodeBounds("MH_TOP_LEFT_M3X24")
const closureInsertBounds = nodeBounds("MH_TOP_LEFT_M3_INSERT")
const closureWasherBounds = nodeBounds("MH_TOP_LEFT_M3_WASHER")
const baseBounds = nodeBounds("GBA_CASE_BASE")
const closureStack = {
  screwHeadToRearPanelMm: closureScrewBounds.min[1] - (-17.4),
  moldedFootProtrusionMm: -17.4 - baseBounds.min[1],
  screwInsertEngagementMm: Math.min(closureScrewBounds.max[1], closureInsertBounds.max[1]) -
    Math.max(closureScrewBounds.min[1], closureInsertBounds.min[1]),
  washerToPcbTopMm: closureWasherBounds.min[1] - pcbBounds.max[1],
  lidPillarToWasherMm: lidBounds.min[1] - closureWasherBounds.max[1],
}
if (Math.abs(closureStack.screwHeadToRearPanelMm) > 0.03) {
  throw new Error(`Rear screw head is not flush to the main panel: ${closureStack.screwHeadToRearPanelMm.toFixed(3)} mm`)
}
if (closureStack.moldedFootProtrusionMm < 1.55 || closureStack.moldedFootProtrusionMm > 1.65) {
  throw new Error(`Molded feet provide ${closureStack.moldedFootProtrusionMm.toFixed(3)} mm table gap`)
}
if (closureStack.screwInsertEngagementMm < 4.7) {
  throw new Error(`M3 insert engagement is only ${closureStack.screwInsertEngagementMm.toFixed(3)} mm`)
}
if (Math.abs(closureStack.washerToPcbTopMm) > 0.03 || Math.abs(closureStack.lidPillarToWasherMm) > 0.03) {
  throw new Error(`PCB/washer/lid stack has a gap: ${JSON.stringify(closureStack)}`)
}

const batteryCellBounds = [nodeBounds("BAT_AA_1"), nodeBounds("BAT_AA_2")]
const batteryInsulatorBounds = nodeBounds("BATTERY_INSULATOR")
const batteryStack = {
  minimumCellToPcbMm: Math.min(...batteryCellBounds.map((bounds) => pcbBounds.min[1] - bounds.max[1])),
  cellToInsulatorMm: Math.min(...batteryCellBounds.map((bounds) => batteryInsulatorBounds.min[1] - bounds.max[1])),
  insulatorToPcbMm: pcbBounds.min[1] - batteryInsulatorBounds.max[1],
  doorPanelToCellMm: -15.8 - (-16.15),
  doorTongueLateralMm: (31.6 - 30.7) / 2,
  doorTongueEndMm: (53.8 - 51.3) / 2,
}
const batteryDoorLatch = {
  installation: "insert upper tongue, pivot lower edge closed, snap cantilever behind shell lip",
  maximumOpenAngleDeg: 8,
  beamLengthMm: 11.2,
  beamThicknessMm: 0.8,
  requiredDeflectionMm: 0.55,
  estimatedSurfaceStrain: 1.5 * 0.8 * 0.55 / (11.2 ** 2),
  maximumDesignStrain: 0.01,
}
if (batteryDoorLatch.estimatedSurfaceStrain > batteryDoorLatch.maximumDesignStrain) {
  throw new Error(`Battery-door latch strain is excessive: ${JSON.stringify(batteryDoorLatch)}`)
}
if (batteryStack.minimumCellToPcbMm < 0.45) {
  throw new Error(`AA envelope has only ${batteryStack.minimumCellToPcbMm.toFixed(3)} mm to the PCB`)
}
if (batteryStack.cellToInsulatorMm < 0.10 || batteryStack.insulatorToPcbMm < 0.10) {
  throw new Error(`Battery insulation sheet has inadequate air clearance: ${JSON.stringify(batteryStack)}`)
}
if (batteryStack.doorPanelToCellMm < 0.30 || batteryStack.doorTongueLateralMm < 0.40 ||
    batteryStack.doorTongueEndMm < 1.0) {
  throw new Error(`Battery door/cell clearance is inadequate: ${JSON.stringify(batteryStack)}`)
}

const baseNode = nodeByName("GBA_CASE_BASE")
const basePositions = meshPositions(baseNode)
const shoulderAlignment = [["SW_L", "SHOULDER_PADDLE_L"], ["SW_R", "SHOULDER_PADDLE_R"]].map(([switchName, paddleName]) => {
  const switchNode = nodeByName(switchName)
  const switchPositions = meshPositions(switchNode)
  const switchTranslation = switchNode.translation ?? [0, 0, 0]
  const actuatorTipLocalZ = Math.max(...switchPositions.map(([, , z]) => z))
  const actuatorTipZ = actuatorTipLocalZ + switchTranslation[2]
  const actuatorVertices = switchPositions.filter(([, , z]) => z >= actuatorTipLocalZ - 0.5)
  const actuatorMinY = Math.min(...actuatorVertices.map(([, y]) => y)) + (switchNode.translation?.[1] ?? 0)
  const actuatorMaxY = Math.max(...actuatorVertices.map(([, y]) => y)) + (switchNode.translation?.[1] ?? 0)
  const actuatorCenterY = (actuatorMinY + actuatorMaxY) / 2
  const paddlePositions = meshPositions(nodeByName(paddleName))
  const paddleMinY = Math.min(...paddlePositions.map(([, y]) => y))
  const paddleMaxY = Math.max(...paddlePositions.map(([, y]) => y))
  const paddleCenterY = (paddleMinY + paddleMaxY) / 2
  const paddleRearReach = Math.max(...paddlePositions.map(([, , z]) => z))
  // The blind actuator pocket closes at original enclosure Y=46.565 mm.
  // Validate the real switch mesh against that plane; this catches a wrong
  // actuator length without treating the switch stem as paddle material.
  const restGapMm = 46.565 - actuatorTipZ
  const centerError = Math.abs(paddleCenterY - actuatorCenterY)
  if (centerError > 0.03) {
    throw new Error(`${paddleName} is ${centerError.toFixed(3)} mm off the ${switchName} actuator height`)
  }
  if (paddleRearReach < actuatorTipZ + 1.5) {
    throw new Error(`${paddleName} does not protrude far enough beyond ${switchName}`)
  }
  if (restGapMm < 0.22 || restGapMm > 0.28) {
    throw new Error(`${paddleName} rest gap is ${restGapMm.toFixed(3)} mm`)
  }
  return { switchName, paddleName, actuatorCenterY, paddleCenterY, centerError, paddleRearReach, actuatorTipZ, restGapMm }
})

// Alignment alone is insufficient.  Inspect the evaluated mesh to ensure the
// clean exterior key fits its wall guide, the two retaining tabs remain behind
// the wall, and the key can sweep through the complete 0.65 mm take-up/stroke
// distance.  GLB Z is enclosure Y.
const shoulderWall = { innerZ: 43.87, outerZ: 45.47 }
const shoulderAperture = { width: 21.6, minY: 1.78, maxY: 8.18 }
const shoulderVerifiedInwardTravel = 0.65
const shoulderShellClearance = ["SHOULDER_PADDLE_L", "SHOULDER_PADDLE_R"].map((paddleName, index) => {
  const positions = meshPositions(nodeByName(paddleName))
  const paddleMinX = Math.min(...positions.map(([x]) => x))
  const paddleMaxX = Math.max(...positions.map(([x]) => x))
  const paddleCenterX = (paddleMinX + paddleMaxX) / 2
  // The exterior key is the only full-width material outside the wall.  Rails
  // may contribute vertices near its back face but cannot enlarge its section.
  const visibleVertices = positions.filter(([x, y, z]) =>
    z > shoulderWall.outerZ - 0.1 && y >= 1.70 && y <= 8.26)
  if (visibleVertices.length === 0) throw new Error(`Could not isolate ${paddleName} visible body`)
  const visibleInnerZ = Math.min(...visibleVertices.map(([, , z]) => z))
  const visibleMinX = Math.min(...visibleVertices.map(([x]) => x))
  const visibleMaxX = Math.max(...visibleVertices.map(([x]) => x))
  const visibleMinY = Math.min(...visibleVertices.map(([, y]) => y))
  const visibleMaxY = Math.max(...visibleVertices.map(([, y]) => y))

  const retentionVertices = positions.filter(([x, , z]) =>
    z < shoulderWall.innerZ - 0.05 && Math.abs(x - paddleCenterX) > 4.5)
  if (retentionVertices.length === 0) throw new Error(`Could not isolate ${paddleName} retaining tabs`)
  const flangeOuterZ = Math.max(...retentionVertices.map(([, , z]) => z))
  const flangeMinY = Math.min(...retentionVertices.map(([, y]) => y))
  const flangeMaxY = Math.max(...retentionVertices.map(([, y]) => y))

  const exteriorNormalMm = visibleInnerZ - shoulderWall.outerZ
  const usableSwitchStrokeMm = shoulderVerifiedInwardTravel - shoulderAlignment[index].restGapMm
  const interiorNormalMm = shoulderWall.innerZ - flangeOuterZ
  const lateralMm = (shoulderAperture.width - (visibleMaxX - visibleMinX)) / 2
  const verticalMm = Math.min(
    visibleMinY - shoulderAperture.minY,
    shoulderAperture.maxY - visibleMaxY,
  )
  const retentionOverhangMm = Math.min(
    shoulderAperture.minY - flangeMinY,
    flangeMaxY - shoulderAperture.maxY,
  )
  if (exteriorNormalMm < 0.10 || exteriorNormalMm > 0.20) {
    throw new Error(`${paddleName} has an unintended ${exteriorNormalMm.toFixed(3)} mm visible wall gap`)
  }
  if (usableSwitchStrokeMm < 0.40) {
    throw new Error(`${paddleName} leaves only ${usableSwitchStrokeMm.toFixed(3)} mm usable switch stroke`)
  }
  if (interiorNormalMm < 0.28) {
    throw new Error(`${paddleName} retention flange intersects/approaches the inside wall: ${interiorNormalMm.toFixed(3)} mm`)
  }
  if (lateralMm < 0.28 || verticalMm < 0.28) {
    throw new Error(`${paddleName} guide clearance is too small: ${lateralMm.toFixed(3)} x ${verticalMm.toFixed(3)} mm`)
  }
  if (retentionOverhangMm < 0.35) {
    throw new Error(`${paddleName} retaining tabs overhang the guide by only ${retentionOverhangMm.toFixed(3)} mm`)
  }
  return {
    paddleName,
    visibleWallGapMm: exteriorNormalMm,
    interiorTabClearanceMm: interiorNormalMm,
    lateralMm,
    verticalMm,
    retentionOverhangMm,
    sweptInwardTravelMm: shoulderVerifiedInwardTravel,
    usableSwitchStrokeMm,
  }
})

const speakerGrille = {
  holeCount: 19,
  holeDiameterMm: 2,
  openAreaMm2: 19 * Math.PI,
  openAreaPercentOfSpeakerFace: 19,
  minimumWebMm: 1.5,
}
if (speakerGrille.openAreaPercentOfSpeakerFace < 15 || speakerGrille.minimumWebMm < 1.2) {
  throw new Error(`Speaker grille is too restrictive or fragile: ${JSON.stringify(speakerGrille)}`)
}

// The old volume cutout passed the source-level dimension check but stopped
// 1.32 mm before the inward-most vertices at the ends of the real knurled
// wheel.  Validate the exported assembly itself so a curved-wall intersection
// cannot be hidden by nominal cutout dimensions again.
const volumeNode = nodeByName("RV_VOLUME")
const volumePrimitives = gltf.meshes[volumeNode.mesh].primitives.map((primitive) => {
  const positions = translatedPrimitivePositions(volumeNode, primitive)
  return {
    positions,
    minY: Math.min(...positions.map(([, y]) => y)),
    maxY: Math.max(...positions.map(([, y]) => y)),
  }
})
const volumeWheelPrimitive = volumePrimitives.find(({ minY, maxY, positions }) => {
  const xSpan = Math.max(...positions.map(([x]) => x)) - Math.min(...positions.map(([x]) => x))
  const zSpan = Math.max(...positions.map(([, , z]) => z)) - Math.min(...positions.map(([, , z]) => z))
  return minY > 0.78 && minY < 0.81 && maxY > 2.60 && maxY < 2.62 &&
    xSpan > 13.9 && xSpan < 14.1 && zSpan > 13.9 && zSpan < 14.1
})
if (!volumeWheelPrimitive) throw new Error("Could not isolate the real C231765 thumbwheel mesh")

const volumeOpening = {
  center: [71.4618929875, 1.7, 33.9039700662],
  width: 15.2,
  depth: 19.0,
  height: 3.2,
  rotationDeg: 46.7780905471,
}
const volumeAngle = volumeOpening.rotationDeg * Math.PI / 180
const volumeTangent = [-Math.cos(volumeAngle), Math.sin(volumeAngle)]
const volumeOutward = [Math.sin(volumeAngle), Math.cos(volumeAngle)]
const volumeWheelProjected = volumeWheelPrimitive.positions.map(([x, y, z]) => {
  const dx = x - volumeOpening.center[0]
  const dz = z - volumeOpening.center[2]
  return {
    tangent: dx * volumeTangent[0] + dz * volumeTangent[1],
    normal: dx * volumeOutward[0] + dz * volumeOutward[1],
    height: y,
  }
})
const wheelTangentMin = Math.min(...volumeWheelProjected.map(({ tangent }) => tangent))
const wheelTangentMax = Math.max(...volumeWheelProjected.map(({ tangent }) => tangent))
const volumeTangentClearance = Math.min(
  wheelTangentMin + volumeOpening.width / 2,
  volumeOpening.width / 2 - wheelTangentMax,
)
const endVertices = volumeWheelProjected.filter(({ tangent }) =>
  tangent < wheelTangentMin + 0.05 || tangent > wheelTangentMax - 0.05)
const wheelEndMinNormal = Math.min(...endVertices.map(({ normal }) => normal))
const volumeDepthClearance = wheelEndMinNormal + volumeOpening.depth / 2
const wheelMinHeight = Math.min(...volumeWheelProjected.map(({ height }) => height))
const wheelMaxHeight = Math.max(...volumeWheelProjected.map(({ height }) => height))
const volumeVerticalClearance = Math.min(
  wheelMinHeight - (volumeOpening.center[1] - volumeOpening.height / 2),
  volumeOpening.center[1] + volumeOpening.height / 2 - wheelMaxHeight,
)
if (volumeTangentClearance < 0.55) {
  throw new Error(`Volume recess has only ${volumeTangentClearance.toFixed(3)} mm tangent clearance`)
}
if (volumeDepthClearance < 1.45) {
  throw new Error(`Volume recess clips the wheel ends: ${volumeDepthClearance.toFixed(3)} mm depth clearance`)
}
if (volumeVerticalClearance < 0.65) {
  throw new Error(`Volume recess has only ${volumeVerticalClearance.toFixed(3)} mm vertical clearance`)
}
const volumeClearance = {
  tangentMm: volumeTangentClearance,
  wallDepthAtWheelEndsMm: volumeDepthClearance,
  verticalMm: volumeVerticalClearance,
}

mkdirSync(assemblyDirectory, { recursive: true })
copyFileSync(generatedGlbPath, assemblyPath)
writeFileSync(metadataPath, `${JSON.stringify({
  schemaVersion: 1,
  format: "glTF 2.0 binary",
  generator: gltf.asset?.generator,
  sourceCircuitSha256: sha256(readFileSync(circuitPath)),
  assemblySha256: sha256(glb),
  byteLength: glb.byteLength,
  scenes: gltf.scenes?.length ?? 0,
  nodes: gltf.nodes?.length ?? 0,
  meshes: gltf.meshes?.length ?? 0,
  materials: gltf.materials?.length ?? 0,
  textures: gltf.textures?.length ?? 0,
  fittedComponentBodies: expectedNames.length,
  enclosureBodies,
  printableMechanicalBodies: enclosurePartNames,
  printableBodyConnectivity,
  missingFittedComponentBodies: missingNames,
  shoulderAlignment,
  shoulderShellClearance,
  volumeClearance,
  speakerStack,
  speakerEnvelope,
  speakerGrille,
  lcdStack,
  faceButtonTravel,
  faceButtonAlignment,
  faceButtonPreload,
  dpadReturn,
  closureStack,
  batteryStack,
  batteryDoorLatch,
}, null, 2)}\n`)

console.log(`Validated complete 3D assembly: ${assemblyPath}`)
console.log(`${expectedNames.length} fitted component bodies, ${enclosureBodies} intentional printable mechanical bodies, and PCB; ${(glb.byteLength / 1024 / 1024).toFixed(1)} MiB`)