ShiboSoftwareDev/linux-gameboy-advance
A multilayer PCB carrier/router with USB-C and battery power selection, buck regulation, microSD, LCD/backlight, UART/SPI/I²C service headers, controls, volume input, and RGB status LEDs, using vias and reviewed copper routing.
- Version
- 1.0.123
- 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}`)
}