ShiboSoftwareDev/linux-gameboy-advance
A multilayer PCB carrier board routing the MCU, USB‑C, battery/AA power selector and TPS63020 regulator, LCD/SD interfaces, UART/I²C/SPI service headers, controls, audio/status LEDs, and backlight with reviewed traces and vias.
- Version
- 1.0.125
- License
- unset
- Stars
- 0
scripts/check-assembly-collisions.mjs
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import modeling from "@jscad/modeling"
const { booleans, geometries, measurements, transforms } = modeling
const { geom3, poly3 } = geometries
const assemblyPath = fileURLToPath(new URL("../assembly/linux-gameboy-advance-assembly.glb", import.meta.url))
const glb = readFileSync(assemblyPath)
if (glb.toString("ascii", 0, 4) !== "glTF") throw new Error("Expected a GLB assembly")
const jsonChunkLength = glb.readUInt32LE(12)
const gltf = JSON.parse(glb.subarray(20, 20 + jsonChunkLength).toString("utf8").trim())
const binaryChunkOffset = 20 + jsonChunkLength + 8
const readVec3Accessor = (accessorIndex) => {
const accessor = gltf.accessors[accessorIndex]
const view = gltf.bufferViews[accessor.bufferView]
if (accessor.componentType !== 5126 || accessor.type !== "VEC3") {
throw new Error(`Expected FLOAT VEC3 accessor, got ${accessor.componentType}/${accessor.type}`)
}
const offset = binaryChunkOffset + (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0)
const stride = view.byteStride ?? 12
return Array.from({ length: accessor.count }, (_, index) => [
glb.readFloatLE(offset + index * stride),
glb.readFloatLE(offset + index * stride + 4),
glb.readFloatLE(offset + index * stride + 8),
])
}
const readIndexAccessor = (accessorIndex) => {
const accessor = gltf.accessors[accessorIndex]
const view = gltf.bufferViews[accessor.bufferView]
const bytesPerIndex = accessor.componentType === 5121 ? 1 : accessor.componentType === 5123 ? 2 : 4
const offset = binaryChunkOffset + (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0)
const stride = view.byteStride ?? bytesPerIndex
const read = accessor.componentType === 5121
? (at) => glb.readUInt8(at)
: accessor.componentType === 5123
? (at) => glb.readUInt16LE(at)
: (at) => glb.readUInt32LE(at)
return Array.from({ length: accessor.count }, (_, index) => read(offset + index * stride))
}
const nodeByName = (name) => {
const node = gltf.nodes.find((candidate) => candidate.name === name)
if (!node) throw new Error(`Assembly is missing ${name}`)
if (node.rotation || node.scale || node.matrix) {
throw new Error(`${name} uses an unsupported node transform; bake it into the mesh before collision checking`)
}
return node
}
const geometryCache = new Map()
const geometryForNode = (name) => {
if (geometryCache.has(name)) return geometryCache.get(name)
const node = nodeByName(name)
const translation = node.translation ?? [0, 0, 0]
const polygons = []
for (const primitive of gltf.meshes[node.mesh].primitives) {
if ((primitive.mode ?? 4) !== 4) throw new Error(`${name} contains a non-triangle primitive`)
const positions = readVec3Accessor(primitive.attributes.POSITION).map((point) =>
point.map((coordinate, axis) => coordinate + translation[axis]))
const indices = primitive.indices == null
? positions.map((_, index) => index)
: readIndexAccessor(primitive.indices)
let signedVolume = 0
for (let index = 0; index < indices.length; index += 3) {
const a = positions[indices[index]]
const b = positions[indices[index + 1]]
const c = positions[indices[index + 2]]
signedVolume += (
a[0] * (b[1] * c[2] - b[2] * c[1]) -
a[1] * (b[0] * c[2] - b[2] * c[0]) +
a[2] * (b[0] * c[1] - b[1] * c[0])
) / 6
}
const reverse = signedVolume < 0
for (let index = 0; index < indices.length; index += 3) {
const points = reverse
? [positions[indices[index + 2]], positions[indices[index + 1]], positions[indices[index]]]
: [positions[indices[index]], positions[indices[index + 1]], positions[indices[index + 2]]]
polygons.push(poly3.fromPoints(points))
}
}
const geometry = geom3.create(polygons)
geometryCache.set(name, geometry)
return geometry
}
const moved = (geometry, delta) => transforms.translate(delta, geometry)
const rotatedAroundX = (geometry, degrees, pivot) => transforms.translate(
pivot,
transforms.rotateX(degrees * Math.PI / 180,
transforms.translate(pivot.map((value) => -value), geometry)),
)
const overlapDetails = (leftName, rightName, leftDelta = [0, 0, 0], rightDelta = [0, 0, 0], leftRotation) => {
let left = leftDelta.some(Boolean) ? moved(geometryForNode(leftName), leftDelta) : geometryForNode(leftName)
if (leftRotation) left = rotatedAroundX(left, leftRotation.degrees, leftRotation.pivot)
const right = rightDelta.some(Boolean) ? moved(geometryForNode(rightName), rightDelta) : geometryForNode(rightName)
const intersection = booleans.intersect(left, right)
const signedVolume = measurements.measureVolume(intersection)
return {
// Imported GLB triangles are reoriented per primitive above, but JSCAD's
// intersection can still return a globally inverted solid when one input
// contains nested subtractive shells. Collision volume is scalar, so a
// signed result must never be allowed to pass the release gate.
volume: Math.abs(signedVolume),
signedVolume,
bounds: measurements.measureBoundingBox(intersection),
}
}
const probes = [
{ label: "left shoulder at rest / rear shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_BASE" },
{ label: "right shoulder at rest / rear shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_BASE" },
{ label: "left shoulder at full stroke / rear shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_BASE", leftDelta: [0, 0, -0.65] },
{ label: "right shoulder at full stroke / rear shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_BASE", leftDelta: [0, 0, -0.65] },
{ label: "left shoulder at rest / front shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_LID" },
{ label: "right shoulder at rest / front shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_LID" },
{ label: "left shoulder at full stroke / front shell", left: "SHOULDER_PADDLE_L", right: "GBA_CASE_LID", leftDelta: [0, 0, -0.65] },
{ label: "right shoulder at full stroke / front shell", left: "SHOULDER_PADDLE_R", right: "GBA_CASE_LID", leftDelta: [0, 0, -0.65] },
{ label: "PCB / rear shell", left: "Box0", right: "GBA_CASE_BASE" },
{ label: "PCB / front shell", left: "Box0", right: "GBA_CASE_LID" },
{ label: "rear shell / front shell", left: "GBA_CASE_BASE", right: "GBA_CASE_LID" },
{ label: "LCD carrier / rear shell", left: "LCD_CARRIER", right: "GBA_CASE_BASE" },
{ label: "LCD carrier / front shell", left: "LCD_CARRIER", right: "GBA_CASE_LID" },
{ label: "left AA envelope / battery door", left: "BAT_AA_1", right: "GBA_BATTERY_DOOR" },
{ label: "right AA envelope / battery door", left: "BAT_AA_2", right: "GBA_BATTERY_DOOR" },
{ label: "battery door / rear shell", left: "GBA_BATTERY_DOOR", right: "GBA_CASE_BASE" },
// The 0-4 degree transition is where the compliant latch deflects; its
// surface strain is checked analytically in export-3d-assembly.mjs. These
// rigid-body probes validate the tongue-guided remainder of the motion.
...[4, 6, 8].map((degrees) => ({
label: `battery door ${degrees} degree installation sweep / rear shell`,
left: "GBA_BATTERY_DOOR",
right: "GBA_CASE_BASE",
leftRotation: { degrees: -degrees, pivot: [0, -16.02, 34.15] },
})),
{ label: "speaker retainer / rear shell", left: "SPK_RETAINER", right: "GBA_CASE_BASE" },
...["BAT_WIRE_NEG", "BAT_WIRE_POS"].flatMap((wire) => [
{ label: `${wire} / rear shell`, left: wire, right: "GBA_CASE_BASE" },
{ label: `${wire} / front shell`, left: wire, right: "GBA_CASE_LID" },
{ label: `${wire} / PCB substrate`, left: wire, right: "Box0" },
{ label: `${wire} / left AA envelope`, left: wire, right: "BAT_AA_1" },
{ label: `${wire} / right AA envelope`, left: wire, right: "BAT_AA_2" },
{ label: `${wire} / battery door`, left: wire, right: "GBA_BATTERY_DOOR" },
{ label: `${wire} / LCD carrier`, left: wire, right: "LCD_CARRIER" },
...["MH_TOP_LEFT", "MH_TOP_RIGHT", "MH_BOTTOM_LEFT", "MH_BOTTOM_RIGHT"].map((mount) => ({
label: `${wire} / ${mount} service screw`, left: wire, right: `${mount}_M3X24`,
})),
]),
...["SPK_WIRE_NEG", "SPK_WIRE_POS"].flatMap((wire) => [
{ label: `${wire} / rear shell`, left: wire, right: "GBA_CASE_BASE" },
{ label: `${wire} / front shell`, left: wire, right: "GBA_CASE_LID" },
{ label: `${wire} / PCB substrate`, left: wire, right: "Box0" },
{ label: `${wire} / speaker retainer`, left: wire, right: "SPK_RETAINER" },
{ label: `${wire} / left AA envelope`, left: wire, right: "BAT_AA_1" },
{ label: `${wire} / right AA envelope`, left: wire, right: "BAT_AA_2" },
{ label: `${wire} / battery door`, left: wire, right: "GBA_BATTERY_DOOR" },
{ label: `${wire} / LCD carrier`, left: wire, right: "LCD_CARRIER" },
...["MH_TOP_LEFT", "MH_TOP_RIGHT", "MH_BOTTOM_LEFT", "MH_BOTTOM_RIGHT"].map((mount) => ({
label: `${wire} / ${mount} service screw`, left: wire, right: `${mount}_M3X24`,
})),
]),
{ label: "battery harness conductor separation", left: "BAT_WIRE_NEG", right: "BAT_WIRE_POS" },
{ label: "speaker harness conductor separation", left: "SPK_WIRE_NEG", right: "SPK_WIRE_POS" },
...["A", "B", "X", "Y", "SELECT", "START"].flatMap((name) => [
{ label: `${name} cap at rest / front shell`, left: `BUTTON_CAP_${name}`, right: "GBA_CASE_LID" },
{ label: `${name} cap at full stroke / front shell`, left: `BUTTON_CAP_${name}`, right: "GBA_CASE_LID", leftDelta: [0, -0.65, 0] },
]),
{ label: "D-pad at rest / front shell", left: "DPAD_CAP", right: "GBA_CASE_LID" },
{ label: "D-pad at full nominal stroke / front shell", left: "DPAD_CAP", right: "GBA_CASE_LID", leftDelta: [0, -0.65, 0] },
]
const toleranceMm3 = 0.001
const failures = []
for (const { label, left, right, leftDelta, rightDelta, leftRotation } of probes) {
const { volume, signedVolume, bounds } = overlapDetails(left, right, leftDelta, rightDelta, leftRotation)
const status = volume <= toleranceMm3 ? "PASS" : "FAIL"
const signNote = signedVolume < -toleranceMm3 ? ` (signed ${signedVolume.toFixed(6)})` : ""
console.log(`[collision] ${status} ${label}: ${volume.toFixed(6)} mm^3${signNote}${volume > toleranceMm3 ? ` ${JSON.stringify(bounds)}` : ""}`)
if (volume > toleranceMm3) failures.push(`${label}: ${volume.toFixed(6)} mm^3`)
}
if (failures.length > 0) {
throw new Error(`Assembly interference detected:\n- ${failures.join("\n- ")}`)
}
console.log(`[collision] ${probes.length} swept-volume checks passed (tolerance ${toleranceMm3} mm^3)`)