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/render-enclosure-audit-views.mjs

import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { renderGLTFToPNGFromGLB } from "poppygl"

const assemblyPath = fileURLToPath(new URL("../assembly/linux-gameboy-advance-assembly.glb", import.meta.url))
const outputDirectory = fileURLToPath(new URL("../assembly/audit-views/", import.meta.url))
const assemblyGlb = readFileSync(assemblyPath)

const parseGlb = (glb) => {
  if (glb.toString("ascii", 0, 4) !== "glTF") throw new Error("Expected a GLB file")
  const chunks = []
  let offset = 12
  while (offset < glb.length) {
    const byteLength = glb.readUInt32LE(offset)
    const type = glb.readUInt32LE(offset + 4)
    chunks.push({ type, data: glb.subarray(offset + 8, offset + 8 + byteLength) })
    offset += 8 + byteLength
  }
  const jsonChunk = chunks.find(({ type }) => type === 0x4e4f534a)
  if (!jsonChunk) throw new Error("GLB has no JSON chunk")
  return {
    json: JSON.parse(jsonChunk.data.toString("utf8").trim()),
    otherChunks: chunks.filter(({ type }) => type !== 0x4e4f534a),
  }
}

const buildGlb = (json, otherChunks) => {
  const jsonText = JSON.stringify(json)
  const jsonLength = Math.ceil(Buffer.byteLength(jsonText) / 4) * 4
  const jsonData = Buffer.alloc(jsonLength, 0x20)
  jsonData.write(jsonText)
  const chunks = [{ type: 0x4e4f534a, data: jsonData }, ...otherChunks]
  const totalLength = 12 + chunks.reduce((sum, { data }) => sum + 8 + data.length, 0)
  const output = Buffer.alloc(totalLength)
  output.write("glTF", 0, "ascii")
  output.writeUInt32LE(2, 4)
  output.writeUInt32LE(totalLength, 8)
  let offset = 12
  for (const { type, data } of chunks) {
    output.writeUInt32LE(data.length, offset)
    output.writeUInt32LE(type, offset + 4)
    data.copy(output, offset + 8)
    offset += 8 + data.length
  }
  return output
}

const { json: sourceGltf, otherChunks } = parseGlb(assemblyGlb)
const allNames = new Set(sourceGltf.nodes.map(({ name }) => name).filter(Boolean))

const printedParts = new Set([
  "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 controls = new Set([
  "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 closureHardware = new Set([
  "MH_TOP_LEFT_M3X24", "MH_TOP_LEFT_M3_INSERT", "MH_TOP_LEFT_M3_WASHER",
  "MH_TOP_RIGHT_M3X24", "MH_TOP_RIGHT_M3_INSERT", "MH_TOP_RIGHT_M3_WASHER",
  "MH_BOTTOM_LEFT_M3X24", "MH_BOTTOM_LEFT_M3_INSERT", "MH_BOTTOM_LEFT_M3_WASHER",
  "MH_BOTTOM_RIGHT_M3X24", "MH_BOTTOM_RIGHT_M3_INSERT", "MH_BOTTOM_RIGHT_M3_WASHER",
])
const screenParts = new Set([
  "J_LCD", "ER_TFT035IPS_6", "LCD_CARRIER", "LCD_FRONT_GASKET", "LCD_REAR_GASKET",
  "MH_LCD_LT_M2P5X6", "MH_LCD_RT_M2P5X6", "MH_LCD_LB_M2P5X6", "MH_LCD_RB_M2P5X6",
])
const screenStack = new Set(["Box0", ...screenParts])
const statusParts = new Set(["D_STATUS", "STATUS_LIGHT_PIPE", "GBA_CASE_LID", "Box0"])
const batteryStack = new Set([
  "GBA_CASE_BASE", "GBA_BATTERY_DOOR", "BAT_AA_1", "BAT_AA_2",
  "BAT_CONTACT_COMMON_210", "BAT_CONTACT_NEG_209", "BAT_CONTACT_POS_228",
  "BAT_WIRE_NEG", "BAT_WIRE_POS", "J_BAT",
])
const batteryHarness = new Set([
  "Box0", "BAT_CONTACT_NEG_209", "BAT_CONTACT_POS_228",
  "BAT_WIRE_NEG", "BAT_WIRE_POS", "J_BAT",
])
const speakerStack = new Set([
  "GBA_CASE_BASE", "SPK_RETAINER", "SPK_20MM", "SPK_FOAM_GASKET", "SPK_M2X6_L", "SPK_M2X6_R",
  "SPK_WIRE_NEG", "SPK_WIRE_POS", "J_SPK",
])
const speakerHarness = new Set([
  "Box0", "SPK_RETAINER", "SPK_20MM", "SPK_FOAM_GASKET",
  "SPK_WIRE_NEG", "SPK_WIRE_POS", "J_SPK",
])

const variant = ({ keep, hide = new Set(), moves = new Map() } = {}) => {
  const gltf = structuredClone(sourceGltf)
  const visible = (name) => (!keep || keep.has(name)) && !hide.has(name)
  for (const scene of gltf.scenes ?? []) {
    scene.nodes = (scene.nodes ?? []).filter((nodeIndex) => visible(gltf.nodes[nodeIndex]?.name))
  }
  for (const [name, delta] of moves) {
    const node = gltf.nodes.find((candidate) => candidate.name === name)
    if (!node) throw new Error(`Cannot move missing GLB node ${name}`)
    node.translation = (node.translation ?? [0, 0, 0]).map((value, axis) => value + delta[axis])
  }
  return buildGlb(gltf, otherChunks)
}

const without = (...sets) => new Set(sets.flatMap((set) => [...set]))
const mechanicalAll = new Set([
  ...printedParts, ...closureHardware, ...screenStack, ...batteryStack, ...speakerStack,
  "DPAD_PIVOT_PAD", "STATUS_LIGHT_PIPE",
])

const common = {
  width: 1400,
  height: 1000,
  supersampling: 2,
  realistic: true,
  lookAt: [0, 0, 0],
  up: "z+",
  backgroundColor: [0.72, 0.75, 0.81],
}

const views = [
  { name: "closed-top", glb: assemblyGlb, camPos: [0, 225, 0], up: "z+" },
  { name: "closed-bottom", glb: assemblyGlb, camPos: [0, -225, 0], up: "z-" },
  { name: "closed-usb-edge", glb: assemblyGlb, camPos: [0, 8, -220], up: "y+" },
  { name: "closed-shoulder-edge", glb: assemblyGlb, camPos: [0, 8, 220], up: "y+" },
  { name: "closed-volume-side", glb: assemblyGlb, camPos: [220, 8, 105], lookAt: [55, 2, 26], up: "y+" },
  { name: "closed-sd-side", glb: assemblyGlb, camPos: [-220, 8, 40], lookAt: [-58, 2, 20], up: "y+" },
  { name: "power-switch-close", glb: assemblyGlb, camPos: [145, 20, -45], lookAt: [70, 3, -24], up: "y+" },
  { name: "volume-wheel-close", glb: assemblyGlb, camPos: [135, 24, 92], lookAt: [70, 2, 34], up: "y+" },
  { name: "status-light-close", glb: variant({ keep: statusParts }), camPos: [55, 52, -80], lookAt: [6.5, 6, -18.5], up: "y+" },

  { name: "base-interior-empty", glb: variant({ keep: new Set(["GBA_CASE_BASE"]) }), camPos: [0, 190, 0], up: "z+" },
  { name: "base-interior-loaded", glb: variant({ hide: without(controls, new Set(["GBA_CASE_LID"])) }), camPos: [0, 195, 0], up: "z+" },
  { name: "base-rear-open-battery", glb: variant({ keep: batteryStack, hide: new Set(["GBA_BATTERY_DOOR"]) }), camPos: [0, -190, 0], lookAt: [20, -6, 5], up: "z-" },
  { name: "battery-door-isolated", glb: variant({ keep: new Set(["GBA_BATTERY_DOOR"]) }), camPos: [72, -82, 66], lookAt: [24, -16, 5], up: "y-" },
  { name: "battery-door-underside", glb: variant({ keep: new Set(["GBA_BATTERY_DOOR"]) }), camPos: [72, 82, 66], lookAt: [24, -16, 5], up: "y+" },
  { name: "battery-harness-route", glb: variant({ keep: batteryHarness }), camPos: [86, 42, -92], lookAt: [25, -3, -32], up: "y+" },
  { name: "battery-harness-free", glb: variant({ keep: new Set([...batteryHarness].filter((name) => name !== "Box0")) }), camPos: [78, 58, -86], lookAt: [25, -5, -32], up: "y+" },
  { name: "speaker-cradle-empty", glb: variant({ keep: new Set(["GBA_CASE_BASE"]) }), camPos: [-50, 72, -24], lookAt: [-50, -10, -24], up: "z+" },
  { name: "speaker-stack", glb: variant({ keep: speakerStack, hide: new Set(["GBA_CASE_BASE"]) }), camPos: [-88, -78, -70], lookAt: [-50, -12, -24], up: "y-" },
  { name: "speaker-harness-route", glb: variant({ keep: speakerHarness }), camPos: [-92, 38, -96], lookAt: [-49, -3, -34], up: "y+" },
  { name: "speaker-harness-free", glb: variant({ keep: new Set([...speakerHarness].filter((name) => name !== "Box0")) }), camPos: [-84, 48, -78], lookAt: [-49, -5, -32], up: "y+" },

  { name: "lid-underside-loaded", glb: variant({ keep: new Set([...controls, ...closureHardware, ...screenParts, "GBA_CASE_LID", "DPAD_PIVOT_PAD", "STATUS_LIGHT_PIPE"]) }), camPos: [0, -190, 0], up: "z-" },
  { name: "lid-underside-empty", glb: variant({ keep: new Set(["GBA_CASE_LID"]) }), camPos: [0, -190, 0], up: "z-" },
  { name: "lid-registration-close", glb: variant({ keep: new Set(["GBA_CASE_LID", "GBA_CASE_BASE"]) }), camPos: [-150, 65, -110], lookAt: [-65, 10, -28], up: "y+" },
  { name: "screen-stack-top", glb: variant({ keep: screenParts }), camPos: [0, 150, 0], lookAt: [0, 8, 14], up: "z+" },
  { name: "screen-stack-underside", glb: variant({ keep: screenParts }), camPos: [0, -150, 0], lookAt: [0, 7, 14], up: "z-" },
  { name: "screen-stack-side", glb: variant({ keep: screenParts }), camPos: [145, 60, 105], lookAt: [0, 8, 14], up: "y+" },
  { name: "screen-carrier-switch-clearance", glb: variant({ keep: new Set(["LCD_CARRIER", "SW_RIGHT", "SW_L"]) }), camPos: [92, -112, -125], lookAt: [-40, 7, 24], up: "y-" },
  { name: "controls-under-lid", glb: variant({ keep: new Set([...controls, "GBA_CASE_LID", "DPAD_PIVOT_PAD"]) }), camPos: [0, -165, 0], up: "z-" },
  { name: "shoulder-left-close", glb: variant({ keep: new Set(["GBA_CASE_BASE", "GBA_CASE_LID", "SHOULDER_PADDLE_L", "SW_L"]) }), camPos: [105, 45, 145], lookAt: [45, 5, 44], up: "y+" },
  { name: "shoulder-right-close", glb: variant({ keep: new Set(["GBA_CASE_BASE", "GBA_CASE_LID", "SHOULDER_PADDLE_R", "SW_R"]) }), camPos: [-105, 45, 145], lookAt: [-45, 5, 44], up: "y+" },
  { name: "shoulder-left-mechanism", glb: variant({ keep: new Set(["SHOULDER_PADDLE_L", "SW_L"]) }), camPos: [82, 27, 91], lookAt: [45, 4.98, 44.5], up: "y+" },
  { name: "shoulder-right-mechanism", glb: variant({ keep: new Set(["SHOULDER_PADDLE_R", "SW_R"]) }), camPos: [-82, 27, 91], lookAt: [-45, 4.98, 44.5], up: "y+" },
  { name: "shoulder-left-pocket", glb: variant({ keep: new Set(["SHOULDER_PADDLE_L", "SW_L"]) }), camPos: [82, 27, 2], lookAt: [45, 4.98, 45.5], up: "y+" },
  { name: "shoulder-right-pocket", glb: variant({ keep: new Set(["SHOULDER_PADDLE_R", "SW_R"]) }), camPos: [-82, 27, 2], lookAt: [-45, 4.98, 45.5], up: "y+" },
  { name: "shoulder-left-interior-rest", glb: variant({ keep: new Set(["GBA_CASE_BASE", "SHOULDER_PADDLE_L", "SW_L"]) }), camPos: [78, 23, 1], lookAt: [45, 4.98, 43.8], up: "y+" },
  { name: "shoulder-right-interior-rest", glb: variant({ keep: new Set(["GBA_CASE_BASE", "SHOULDER_PADDLE_R", "SW_R"]) }), camPos: [-78, 23, 1], lookAt: [-45, 4.98, 43.8], up: "y+" },
  { name: "shoulder-left-interior-pressed", glb: variant({
    keep: new Set(["GBA_CASE_BASE", "SHOULDER_PADDLE_L", "SW_L"]),
    moves: new Map([["SHOULDER_PADDLE_L", [0, 0, -0.65]]]),
  }), camPos: [78, 23, 1], lookAt: [45, 4.98, 43.8], up: "y+" },
  { name: "shoulder-right-interior-pressed", glb: variant({
    keep: new Set(["GBA_CASE_BASE", "SHOULDER_PADDLE_R", "SW_R"]),
    moves: new Map([["SHOULDER_PADDLE_R", [0, 0, -0.65]]]),
  }), camPos: [-78, 23, 1], lookAt: [-45, 4.98, 43.8], up: "y+" },
  { name: "shoulder-edge-pressed", glb: variant({
    keep: new Set(["GBA_CASE_BASE", "GBA_CASE_LID", "SHOULDER_PADDLE_L", "SHOULDER_PADDLE_R", "SW_L", "SW_R"]),
    moves: new Map([
      ["SHOULDER_PADDLE_L", [0, 0, -0.65]],
      ["SHOULDER_PADDLE_R", [0, 0, -0.65]],
    ]),
  }), camPos: [0, 8, 220], up: "y+" },
  { name: "fastener-stack", glb: variant({ keep: new Set([...closureHardware, "GBA_CASE_BASE", "GBA_CASE_LID", "Box0"]) }), camPos: [175, 90, 165], up: "y+" },
  { name: "fastener-corner-close", glb: variant({ keep: new Set([...closureHardware, "GBA_CASE_BASE", "GBA_CASE_LID", "Box0"]) }), camPos: [135, 75, -90], lookAt: [60, 0, -34], up: "y+" },

  { name: "mechanical-exploded", glb: variant({ keep: mechanicalAll, moves: new Map([
    ["GBA_CASE_LID", [0, 36, 0]],
    ["DPAD_CAP", [0, 48, 0]],
    ["BUTTON_CAP_A", [0, 48, 0]], ["BUTTON_CAP_B", [0, 48, 0]],
    ["BUTTON_CAP_X", [0, 48, 0]], ["BUTTON_CAP_Y", [0, 48, 0]],
    ["BUTTON_CAP_SELECT", [0, 48, 0]], ["BUTTON_CAP_START", [0, 48, 0]],
    ["LCD_CARRIER", [0, 25, 0]], ["LCD_FRONT_GASKET", [0, 29, 0]],
    ["LCD_REAR_GASKET", [0, 18, 0]], ["ER_TFT035IPS_6", [0, 22, 0]],
    ["GBA_CASE_BASE", [0, -24, 0]], ["GBA_BATTERY_DOOR", [0, -38, 0]],
  ]) }), camPos: [185, 130, 180], lookAt: [0, 4, 0], up: "y+" },
]

for (const requiredName of mechanicalAll) {
  if (!allNames.has(requiredName)) throw new Error(`Audit view references missing node ${requiredName}`)
}

mkdirSync(outputDirectory, { recursive: true })
const selectedNames = new Set(process.argv.slice(2))
const selectedViews = selectedNames.size > 0
  ? views.filter(({ name }) => selectedNames.has(name))
  : views

for (const { name, glb, ...camera } of selectedViews) {
  const png = await renderGLTFToPNGFromGLB(glb, { ...common, ...camera })
  const outputPath = fileURLToPath(new URL(`${name}.png`, new URL("../assembly/audit-views/", import.meta.url)))
  writeFileSync(outputPath, png)
  console.log(`[enclosure-audit] rendered ${outputPath}`)
}