krishnax12/watchy-eink-smartwatch
Unfinished prototype (not production-ready): 4 native DRC errors, 2 display-flex intersections, and missing antenna radiator copper remain
- Version
- 1.2.7
- License
- MIT
- Stars
- 1
scripts/verify-cad-intersections.mjs
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { instantiateManifold } from "@tscircuit/manifold-2d";
import { convertCircuitJsonTo3D } from "circuit-json-to-gltf";
// Same Y, X, Z order and signs as circuit-json-to-gltf transformMesh.
function place(point, box) {
const r = box.rotation ?? { x: 0, y: 0, z: 0 };
const x = point.x * Math.cos(r.y) - point.z * Math.sin(r.y);
const z = point.x * Math.sin(r.y) + point.z * Math.cos(r.y);
const y = point.y * Math.cos(r.x) + z * Math.sin(r.x);
return [
x * Math.cos(r.z) + y * Math.sin(r.z) + box.center.x,
-point.y * Math.sin(r.x) + z * Math.cos(r.x) + box.center.z,
-x * Math.sin(r.z) + y * Math.cos(r.z) + box.center.y,
];
}
const kernel = await instantiateManifold();
const cube = kernel.Manifold.cube([2, 2, 2]);
const shifted = cube.translate([1, 1, 1]);
const overlap = cube.intersect(shifted);
assert.ok(Math.abs(overlap.volume() - 1) < 1e-6, "Boolean volume self-test");
cube.delete();
shifted.delete();
overlap.delete();
const circuit = JSON.parse(
await fs.readFile(
process.env.WATCHY_CIRCUIT_PATH ?? "dist/index/circuit.json",
"utf8",
),
);
const includeGeneratedModels =
process.env.WATCHY_INCLUDE_GENERATED_MODELS === "1";
const assetDirectory = path.resolve(process.env.WATCHY_ASSET_DIRECTORY ?? ".");
// The viewer falls back to generated boxes after a failed STEP load. That must
// not make this mechanical audit pass against geometry other than the model.
for (const component of circuit.filter(
(element) => element.type === "cad_component" && element.model_step_url,
)) {
const modelUrl = new URL(
component.model_step_url,
`file://${assetDirectory}/`,
);
assert.equal(
modelUrl.protocol,
"file:",
"Bundle remote CAD assets before auditing",
);
await fs.access(fileURLToPath(modelUrl));
}
const scene = await convertCircuitJsonTo3D(
includeGeneratedModels
? circuit
: circuit.filter((e) => e.type !== "cad_component" || e.model_step_url),
{
projectBaseUrl: `file://${assetDirectory}/`,
renderBoardTextures: false,
},
);
const loadedModelUrls = new Set(
scene.boxes
.filter((box) => box.mesh && box.meshUrl)
.map((box) => new URL(box.meshUrl, `file://${assetDirectory}/`).href),
);
for (const component of circuit.filter(
(element) => element.type === "cad_component" && element.model_step_url,
)) {
const modelUrl = new URL(
component.model_step_url,
`file://${assetDirectory}/`,
);
assert.ok(
loadedModelUrls.has(modelUrl.href),
`STEP not loaded: ${modelUrl.href}`,
);
}
const records = [];
const solids = [];
for (const box of scene.boxes.filter((box) => box.mesh)) {
const vertices = box.mesh.triangles.flatMap((triangle) =>
triangle.vertices.flatMap((point) => place(point, box)),
);
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
vertices.forEach((value, i) => {
min[i % 3] = Math.min(min[i % 3], value);
max[i % 3] = Math.max(max[i % 3], value);
});
const record = {
id: records.length,
name: box.label ?? "PCB",
file: box.meshUrl,
min,
max,
triangles: vertices.length / 9,
};
// Signed volume chooses consistent global winding after coordinate reflections.
let signedVolume = 0;
for (let i = 0; i < vertices.length; i += 9) {
const [ax, ay, az, bx, by, bz, cx, cy, cz] = vertices.slice(i, i + 9);
signedVolume +=
(ax * (by * cz - bz * cy) +
ay * (bz * cx - bx * cz) +
az * (bx * cy - by * cx)) /
6;
}
const indices = Uint32Array.from(
{ length: vertices.length / 3 },
(_, i) => i,
);
if (signedVolume < 0)
for (let i = 0; i < indices.length; i += 3)
[indices[i + 1], indices[i + 2]] = [indices[i + 2], indices[i + 1]];
let solid = null;
try {
const mesh = new kernel.Mesh({
numProp: 3,
vertProperties: Float32Array.from(vertices),
triVerts: indices,
tolerance: 1e-5,
});
mesh.merge();
solid = new kernel.Manifold(mesh);
record.status = String(solid.status());
if (record.status !== "NoError") throw new Error(record.status);
record.volumeMm3 = solid.volume();
} catch (error) {
solid?.delete();
solid = null;
record.status = `Unresolved: ${error.message}`;
}
records.push(record);
solids.push(solid);
console.log(
record.name,
record.file ?? "board",
record.status,
record.volumeMm3,
);
}
const pairs = [];
for (let i = 0; i < records.length; i++)
for (let j = i + 1; j < records.length; j++) {
const a = records[i];
const b = records[j];
const gaps = [0, 1, 2].map(
(axis) =>
Math.min(a.max[axis], b.max[axis]) - Math.max(a.min[axis], b.min[axis]),
);
if (!gaps.every((gap) => gap > 0.00001)) continue;
const pair = { a: i, b: j, names: [a.name, b.name] };
if (!solids[i] || !solids[j])
pair.status = "unresolved: invalid input mesh";
else {
try {
const intersection = solids[i].intersect(solids[j]);
if (String(intersection.status()) !== "NoError")
throw new Error(String(intersection.status()));
pair.volumeMm3 = intersection.volume();
pair.status =
pair.volumeMm3 > 0.001
? "positive-volume intersection"
: "no significant volume intersection";
intersection.delete();
} catch (error) {
pair.status = `unresolved: ${error.message}`;
}
}
pairs.push(pair);
console.log(pair);
}
const report = {
circuitPath: process.env.WATCHY_CIRCUIT_PATH ?? "dist/index/circuit.json",
assetDirectory,
scope: `Boolean intersections of tessellated component models and PCB mesh using installed circuit-json-to-gltf placement. Not exact B-rep certification; ${includeGeneratedModels ? "includes native generated package models, not manufacturer-certified envelopes" : "excludes generated passive models"}. Threshold 0.001 mm^3, vertex merge tolerance 0.00001 mm. Invalid inputs remain unresolved.`,
missingMeshes: scene.boxes.filter((box) => !box.mesh).map((box) => box.label),
records,
pairs,
};
await fs.writeFile(
includeGeneratedModels
? "reports/cad-all-model-intersections.json"
: "reports/cad-solid-intersections.json",
JSON.stringify(report, null, 2),
);
for (const solid of solids) solid?.delete();
if (
report.missingMeshes.length > 0 ||
records.some((record) => record.status !== "NoError") ||
pairs.some((pair) => pair.status !== "no significant volume intersection")
)
process.exitCode = 1;