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`)