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