ShiboSoftwareDev/linux-gameboy-advance
A multilayer PCB carrier board routing an SoC, USB-C, LCD/SD, UART/SPI/I²C headers, audio controls, LEDs, and either AA or rechargeable-battery power systems with chargers, regulators, switches, capacitors, and vias.
- Version
- 1.0.135
- License
- unset
- Stars
- 0
scripts/convert-oem-screen-model.mjs
import { readFile, writeFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import {
BufferGeometry,
Color,
Float32BufferAttribute,
Mesh,
MeshStandardMaterial,
Scene,
Uint32BufferAttribute,
} from "three"
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js"
const require = createRequire(import.meta.url)
const occtImport = require("occt-import-js")
// Three's exporter uses the browser FileReader API even when run in Node.
class NodeFileReader {
result = null
onloadend = null
onerror = null
async readAsArrayBuffer(blob) {
try {
this.result = await blob.arrayBuffer()
this.onloadend?.()
} catch (error) {
this.onerror?.(error)
}
}
async readAsDataURL(blob) {
try {
const bytes = Buffer.from(await blob.arrayBuffer())
this.result = `data:${blob.type};base64,${bytes.toString("base64")}`
this.onloadend?.()
} catch (error) {
this.onerror?.(error)
}
}
}
globalThis.FileReader ??= NodeFileReader
const scriptDir = dirname(fileURLToPath(import.meta.url))
const sourcePath = resolve(scriptDir, "../assets/.source/ER-TFT035-6.step")
// Version the installed asset name whenever its alignment changes so the
// hosted 3D viewer cannot reuse a cached GLB from an earlier release.
const outputPath = resolve(scriptDir, "../assets/er-tft035ips-6-oem-installed-v5.glb")
// The OEM STEP is supplied with the 30 mm FPC flat. In the handheld, that
// flexible section folds back between J_LCD and the rigid display. The old
// six-segment polyline produced three visibly hard kinks. Build a smooth
// cubic fold instead, then solve its control-point excursion so its sampled
// centreline remains exactly the manufacturer's 30 mm flexible length. The
// endpoints and tangents stay aligned with the connector and panel tails; the
// rigid OEM glass/backlight geometry is still translated only, never scaled.
const bezierPoint = (controlY, t) => {
const oneMinus = 1 - t
const blend0 = oneMinus ** 3
const blend1 = 3 * oneMinus ** 2 * t
const blend2 = 3 * oneMinus * t ** 2
const blend3 = t ** 3
return [
0,
blend1 * controlY + blend2 * controlY + blend3,
blend0 * 0.32 + blend1 * 0.32 + blend2 * 10.2 + blend3 * 10.2,
]
}
const sampledBezier = (controlY, segments = 96) => Array.from(
{ length: segments + 1 },
(_, index) => bezierPoint(controlY, index / segments),
)
const polylineLength = (points) => points.slice(1).reduce((total, point, index) =>
total + Math.hypot(...point.map((value, axis) => value - points[index][axis])), 0)
let lowerControlY = 10
let upperControlY = 24
for (let iteration = 0; iteration < 64; iteration += 1) {
const midpoint = (lowerControlY + upperControlY) / 2
if (polylineLength(sampledBezier(midpoint, 96)) < 30) lowerControlY = midpoint
else upperControlY = midpoint
}
const flexControlY = (lowerControlY + upperControlY) / 2
const flexPath = sampledBezier(flexControlY, 96)
const installedPanelLateralX = flexPath.at(-1)[0]
// A smooth-looking spline is not enough: the installed FPC also needs a
// controlled bend radius. Estimate the local osculating radius from every
// three adjacent samples and reject a model that would crease the 0.20 mm
// flexible stack. The 2.0 mm / 10x-thickness floor is conservative for this
// static, one-time assembly fold.
const circumcircleRadius = (a, b, c) => {
const ab = Math.hypot(...b.map((value, axis) => value - a[axis]))
const bc = Math.hypot(...c.map((value, axis) => value - b[axis]))
const ca = Math.hypot(...a.map((value, axis) => value - c[axis]))
const cross = [
(b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]),
(b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]),
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]),
]
const doubledArea = Math.hypot(...cross)
return doubledArea < 1e-9 ? Infinity : ab * bc * ca / (2 * doubledArea)
}
const flexibleStackThicknessMm = 0.20
const minimumInstalledBendRadiusMm = Math.min(...flexPath.slice(1, -1).map(
(point, index) => circumcircleRadius(flexPath[index], point, flexPath[index + 2]),
))
const bendRadiusToThicknessRatio = minimumInstalledBendRadiusMm / flexibleStackThicknessMm
if (minimumInstalledBendRadiusMm < 2.0 || bendRadiusToThicknessRatio < 10) {
throw new Error(`Unsafe installed FPC bend: ${minimumInstalledBendRadiusMm.toFixed(3)} mm radius, ` +
`${bendRadiusToThicknessRatio.toFixed(2)}x thickness`)
}
const stepFlexTipY = -72.48
const stepPanelNearEdgeY = -42.48
const stepFlexPlaneZ = 2.4
const installedPanelNearEdgeY = 1.0
const installedPanelBackZ = 7.0
const stepPanelBackZ = -0.8
const panelYOffset = installedPanelNearEdgeY - stepPanelNearEdgeY
const panelZOffset = installedPanelBackZ - stepPanelBackZ
const pathSegments = []
let pathLength = 0
for (let index = 0; index < flexPath.length - 1; index += 1) {
const [x0, y0, z0] = flexPath[index]
const [x1, y1, z1] = flexPath[index + 1]
const dx = x1 - x0
const dy = y1 - y0
const dz = z1 - z0
const length = Math.hypot(dx, dy, dz)
pathSegments.push({ x0, y0, z0, dx, dy, dz, length, start: pathLength })
pathLength += length
}
const pointOnFlexPath = (distance) => {
const clamped = Math.max(0, Math.min(pathLength, distance))
const segment = pathSegments.find(({ start, length }) => clamped <= start + length)
?? pathSegments.at(-1)
const ratio = segment.length === 0 ? 0 : (clamped - segment.start) / segment.length
return {
x: segment.x0 + segment.dx * ratio,
y: segment.y0 + segment.dy * ratio,
z: segment.z0 + segment.dz * ratio,
tangentX: segment.dx / segment.length,
tangentY: segment.dy / segment.length,
tangentZ: segment.dz / segment.length,
}
}
const flexFrame = (path) => {
const tangent = [path.tangentX, path.tangentY, path.tangentZ]
const widthRaw = [1 - tangent[0] ** 2, -tangent[0] * tangent[1], -tangent[0] * tangent[2]]
const widthLength = Math.hypot(...widthRaw)
const width = widthRaw.map((value) => value / widthLength)
const normal = [
width[1] * tangent[2] - width[2] * tangent[1],
width[2] * tangent[0] - width[0] * tangent[2],
width[0] * tangent[1] - width[1] * tangent[0],
]
return { tangent, width, normal }
}
const transformPosition = (x, y, z) => {
if (y >= stepPanelNearEdgeY - 1e-6) {
return [x + installedPanelLateralX, y + panelYOffset, z + panelZOffset]
}
const flatDistance = y - stepFlexTipY
const pathDistance = flatDistance * pathLength / 30
const path = pointOnFlexPath(pathDistance)
const frame = flexFrame(path)
const crossSectionOffset = z - stepFlexPlaneZ
return [
path.x + frame.width[0] * x + frame.normal[0] * crossSectionOffset,
path.y + frame.width[1] * x + frame.normal[1] * crossSectionOffset,
path.z + frame.width[2] * x + frame.normal[2] * crossSectionOffset,
]
}
const transformNormal = (nx, ny, nz, originalY) => {
if (originalY >= stepPanelNearEdgeY - 1e-6) return [nx, ny, nz]
const flatDistance = originalY - stepFlexTipY
const path = pointOnFlexPath(flatDistance * pathLength / 30)
const frame = flexFrame(path)
const transformed = [
nx * frame.width[0] + ny * frame.tangent[0] + nz * frame.normal[0],
nx * frame.width[1] + ny * frame.tangent[1] + nz * frame.normal[1],
nx * frame.width[2] + ny * frame.tangent[2] + nz * frame.normal[2],
]
const length = Math.hypot(...transformed)
return transformed.map((value) => value / length)
}
const bounds = (positions) => {
const result = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]
for (let index = 0; index < positions.length; index += 3) {
for (let axis = 0; axis < 3; axis += 1) {
result[axis] = Math.min(result[axis], positions[index + axis])
result[axis + 3] = Math.max(result[axis + 3], positions[index + axis])
}
}
return result
}
const stepBytes = await readFile(sourcePath)
const occt = await occtImport()
const imported = occt.ReadStepFile(stepBytes, {
linearUnit: "millimeter",
linearDeflectionType: "absolute_value",
linearDeflection: 0.03,
angularDeflection: 0.2,
})
if (!imported.success || imported.meshes.length !== 1) {
throw new Error(`Unexpected OEM STEP import result: success=${imported.success}, meshes=${imported.meshes.length}`)
}
const sourceMesh = imported.meshes[0]
const sourcePositions = sourceMesh.attributes.position.array
const sourceNormals = sourceMesh.attributes.normal?.array
const installedPositions = new Float32Array(sourcePositions.length)
const installedNormals = sourceNormals ? new Float32Array(sourceNormals.length) : null
for (let index = 0; index < sourcePositions.length; index += 3) {
const position = transformPosition(
sourcePositions[index],
sourcePositions[index + 1],
sourcePositions[index + 2],
)
installedPositions.set(position, index)
if (installedNormals) {
installedNormals.set(transformNormal(
sourceNormals[index],
sourceNormals[index + 1],
sourceNormals[index + 2],
sourcePositions[index + 1],
), index)
}
}
const geometry = new BufferGeometry()
geometry.setAttribute("position", new Float32BufferAttribute(installedPositions, 3))
if (installedNormals) geometry.setAttribute("normal", new Float32BufferAttribute(installedNormals, 3))
geometry.setIndex(new Uint32BufferAttribute(new Uint32Array(sourceMesh.index.array), 1))
const materialByColor = new Map()
const materials = []
const materialIndexFor = (faceColor) => {
const color = faceColor ?? sourceMesh.color ?? [0.72, 0.74, 0.76]
const key = color.map((value) => value.toFixed(6)).join(",")
if (materialByColor.has(key)) return materialByColor.get(key)
const luminance = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722
const material = new MeshStandardMaterial({
// Use a real Three.js Color. Passing a plain {r, g, b} object leaves the
// material at its white default and GLTFExporter omits baseColorFactor.
color: new Color().setRGB(color[0], color[1], color[2]),
roughness: luminance > 0.7 ? 0.36 : 0.5,
metalness: luminance > 0.45 && Math.abs(color[0] - color[1]) < 0.08 ? 0.32 : 0.02,
})
material.name = `OEM-${key}`
const index = materials.length
materials.push(material)
materialByColor.set(key, index)
return index
}
geometry.clearGroups()
for (const face of sourceMesh.brep_faces) {
geometry.addGroup(
face.first * 3,
(face.last - face.first + 1) * 3,
materialIndexFor(face.color),
)
}
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
const mesh = new Mesh(geometry, materials)
mesh.name = "ER-TFT035-6-OEM-installed"
mesh.userData = {
manufacturer: "EastRising / BuyDisplay",
partNumber: "ER-TFT035IPS-6 with capacitive touch",
source: "ER-TFT035-6.step",
rigidGeometry: "OEM STEP, unscaled",
flexibleGeometry: "OEM mesh deformed along a 30 mm installed fold path",
rigidPanelWidthMm: 84.96,
rigidPanelHeightMm: 56.54,
flexPathLengthMm: pathLength,
flexibleStackThicknessMm,
minimumInstalledBendRadiusMm,
bendRadiusToThicknessRatio,
bendRequirement: "static fold >=2.0 mm radius and >=10x flex-stack thickness",
installedPanelLateralOffsetMm: installedPanelLateralX,
}
const scene = new Scene()
scene.name = "ER-TFT035IPS-6 OEM installed assembly"
scene.add(mesh)
const exporter = new GLTFExporter()
const glb = await exporter.parseAsync(scene, {
binary: true,
onlyVisible: true,
truncateDrawRange: true,
})
await writeFile(outputPath, new Uint8Array(glb))
const sourceBounds = bounds(sourcePositions)
const installedBounds = bounds(installedPositions)
console.log(JSON.stringify({
sourcePath,
outputPath,
byteLength: glb.byteLength,
vertices: installedPositions.length / 3,
triangles: sourceMesh.index.array.length / 3,
materials: materials.length,
flexPathLength: pathLength,
minimumInstalledBendRadiusMm,
bendRadiusToThicknessRatio,
sourceBounds,
installedBounds,
rigidPanelSize: [56.54, 84.96, sourceBounds[5] - sourceBounds[2]],
}, null, 2))