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;