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