mohan-bee/tscircuit-robot-vacuum

A tscircuit package created by mohan-bee.

Version
1.0.0
License
unset
Stars
0

scripts/export-printed-parts.ts

/**
 * Export the native tscircuit printed-part meshes to binary STL.
 * Geometry is authored in assembly.printedpart/JSCAD and tessellated by
 * circuit-json-to-gltf's native loader. This is only a file-format adapter.
 * No alternate CAD system, replacement geometry, or bounding boxes are used.
 */
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import { resolve, join } from "node:path"
import { loadCadComponentMesh } from "circuit-json-to-gltf"

type RecordData = { type: string; [key: string]: any }
type V3 = [number, number, number]
type Mesh = { positions: number[]; indices: number[] }

const inputPath = resolve(process.argv[2] ?? "dist/circuit.json")
const outDir = resolve(process.argv[3] ?? "dist/printed-parts")
const records: RecordData[] = JSON.parse(readFileSync(inputPath, "utf8"))
if (!Array.isArray(records)) throw new Error("Expected a Circuit JSON array")
mkdirSync(outDir, { recursive: true })
const printed = new Map(
  records.filter((r) => r.type === "source_component" && r.ftype === "printedpart")
    .map((r) => [r.source_component_id, r]),
)
const candidates = records.filter(
  (r) => r.type === "cad_component" && printed.has(r.source_component_id),
)
if (!candidates.length) throw new Error("No native printed parts found")
const usedNames = new Set<string>()
const parts: Record<string, unknown>[] = []

function vertex(mesh: Mesh, i: number): V3 {
  return [mesh.positions[3 * i]!, mesh.positions[3 * i + 1]!, mesh.positions[3 * i + 2]!]
}
function subtract(a: V3, b: V3): V3 { return a.map((v, i) => v - b[i]!) as V3 }
function cross(a: V3, b: V3): V3 {
  return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
function dot(a: V3, b: V3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }

for (const cad of candidates) {
  const source = printed.get(cad.source_component_id)!
  const name = String(source.name || cad.cad_component_id).replace(/[^a-zA-Z0-9_-]/g, "-")
  if (usedNames.has(name)) throw new Error(`Duplicate printable filename: ${name}`)
  usedNames.add(name)
  const mesh = await loadCadComponentMesh(cad as any)
  if (!mesh.indices.length || mesh.indices.length % 3 || mesh.positions.length % 3)
    throw new Error(`${name}: invalid triangle mesh`)
  if (mesh.positions.some((n) => !Number.isFinite(n))) throw new Error(`${name}: nonfinite mesh`)
  if (mesh.indices.some((i) => !Number.isInteger(i) || i < 0 || i * 3 >= mesh.positions.length))
    throw new Error(`${name}: invalid vertex index`)

  const min: V3 = [Infinity, Infinity, Infinity], max: V3 = [-Infinity, -Infinity, -Infinity]
  for (let i = 0; i < mesh.positions.length; i += 3) {
    for (let a = 0; a < 3; a++) {
      min[a] = Math.min(min[a]!, mesh.positions[i + a]!)
      max[a] = Math.max(max[a]!, mesh.positions[i + a]!)
    }
  }
  // Preserve assembled orientation. Center XY and put the lowest point at Z=0.
  // This is not an automatic choice of optimal print orientation.
  const offset: V3 = [-(min[0] + max[0]) / 2, -(min[1] + max[1]) / 2, -min[2]]
  const count = mesh.indices.length / 3
  const stl = Buffer.alloc(84 + count * 50)
  stl.write(`tscircuit ${name}; mm; orientation preserved`, 0, 80, "ascii")
  stl.writeUInt32LE(count, 80)
  let volume = 0, surfaceArea = 0, degenerate = 0
  const edges = new Map<string, { count: number; direction: number }>()
  // Coincident triangle vertices can have distinct indices. Weld only the
  // diagnostic edge keys at 1e-7 mm; the exported positions stay unchanged.
  const vertexKeys = Array.from({ length: mesh.positions.length / 3 }, (_, i) =>
    vertex(mesh, i).map((n) => Math.round(n * 1e7)).join(","),
  )
  for (let t = 0; t < count; t++) {
    const ids = mesh.indices.slice(t * 3, t * 3 + 3)
    const points = ids.map((i) => vertex(mesh, i)) as [V3, V3, V3]
    const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0]))
    const length = Math.hypot(...normal)
    if (length <= 1e-12) degenerate++
    surfaceArea += length / 2
    volume += dot(points[0], cross(points[1], points[2])) / 6
    const start = 84 + t * 50
    for (let a = 0; a < 3; a++) stl.writeFloatLE(length > 0 ? normal[a]! / length : 0, start + a * 4)
    points.forEach((p, v) => p.forEach((n, a) => stl.writeFloatLE(n + offset[a]!, start + 12 + v * 12 + a * 4)))
    for (let e = 0; e < 3; e++) {
      const a = vertexKeys[ids[e]!]!, b = vertexKeys[ids[(e + 1) % 3]!]!
      const key = a < b ? `${a}|${b}` : `${b}|${a}`
      const direction = a < b ? 1 : -1
      const old = edges.get(key) ?? { count: 0, direction: 0 }
      old.count++; old.direction += direction; edges.set(key, old)
    }
  }
  const boundaryEdges = [...edges.values()].filter((e) => e.count === 1).length
  const nonManifoldEdges = [...edges.values()].filter((e) => e.count > 2).length
  const inconsistentEdges = [...edges.values()].filter((e) => e.count === 2 && e.direction !== 0).length
  const file = `${name}.stl`
  writeFileSync(join(outDir, file), stl)
  parts.push({
    name: source.name, file, source_component_id: source.source_component_id,
    cad_component_id: cad.cad_component_id, triangleCount: count,
    units: "mm", worldBounds: { min, max },
    sizeMm: max.map((n, a) => n - min[a]!),
    stlTranslationFromWorldMm: offset,
    orientation: "assembled orientation retained; XY centered and lowest Z=0",
    signedVolumeMm3: volume, surfaceAreaMm2: surfaceArea,
    meshChecks: { boundaryEdges, nonManifoldEdges, inconsistentEdges, degenerateTriangles: degenerate },
    fabricationStatus: "engineering prototype; mesh diagnostics do not establish printability or fit",
  })
  console.log(`${name}: ${count} triangles, boundary=${boundaryEdges}, nonmanifold=${nonManifoldEdges}`)
}
const manifest = {
  source: inputPath, geometryPipeline: "tscircuit assembly.printedpart -> Circuit JSON -> circuit-json-to-gltf loadCadComponentMesh -> binary STL",
  date: new Date().toISOString(), units: "mm", parts,
  limitations: [
    "The CLI has no native STL export selector; this script only serializes native meshes.",
    "STL has no intrinsic units; import using millimetres.",
    "No automatic support, slicing, material shrinkage, tolerance, strength, or fit qualification is performed.",
    "A zero boundary-edge count is a topology diagnostic, not a self-intersection or manufacturability guarantee.",
    "Files retain assembly orientation; choose suitable print orientations and validate in a slicer before printing.",
  ],
}
writeFileSync(join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n")
console.log(`Exported ${parts.length} native printed parts to ${outDir}`)