AnasSarkiz/magnetic-shutter-remote-r8
A USB‑C rechargeable battery-powered ESP32‑C3 wireless controller with Li‑ion charging, protected 3.3‑V buck‑boost regulation, thermal/safety monitoring, status LEDs, buttons, programming UART, crystal clock, and 2.4‑GHz antenna matching network.
- Version
- 0.4.0-prototype
- License
- unset
- Stars
- 0
scripts/check-product-geometry.ts
import { executeJscadOperations } from "jscad-planner";
import type { JscadOperation } from "jscad-planner";
import type Geom3 from "@jscad/modeling/src/geometries/geom3/type";
import modeling from "@jscad/modeling";
import {
createProductParts,
partInProduct,
createRemoteOuterPlan,
} from "../product/geometry";
import { z } from "zod";
import {
dimensions as d,
mountingHolesMm,
pcbPointInProduct,
} from "../product/dimensions";
import { any_circuit_element } from "circuit-json";
// Native plan loader evaluates the identical plans used by the assembly.
function solidFromPlan(plan: JscadOperation): Geom3 {
const geometry = executeJscadOperations(modeling, plan);
if (!modeling.geometries.geom3.isA(geometry))
throw new Error("Product plan did not evaluate to a3D solid");
return geometry;
}
const parts = createProductParts();
const shapes = parts.map((part) => {
const started = performance.now();
const solid = solidFromPlan(partInProduct(part));
console.error(
`Evaluated ${part.name}: ${((performance.now() - started) / 1000).toFixed(2)}s`,
);
return solid;
});
const rows = parts.map((part, index) => ({
name: part.name,
localBoundsMm: modeling.measurements.measureBoundingBox(shapes[index]),
boundsMm: modeling.measurements.measureBoundingBox(shapes[index]),
volumeMm3: modeling.measurements.measureVolume(shapes[index]),
referenceOnly: part.referenceOnly ?? false,
}));
function disjointBounds(a: number[][], b: number[][]): boolean {
return a[0].some(
(minimum, axis) => minimum >= b[1][axis] || a[1][axis] <= b[0][axis],
);
}
const clashes = [];
for (let a = 0; a < parts.length; a++)
for (let b = a + 1; b < parts.length; b++) {
if (disjointBounds(rows[a].boundsMm, rows[b].boundsMm)) continue;
const volumeMm3 = modeling.measurements.measureVolume(
modeling.booleans.intersect(shapes[a], shapes[b]),
);
if (volumeMm3 > 0.001)
clashes.push({ a: parts[a].name, b: parts[b].name, volumeMm3 });
}
const meshReview = z
.object({
sourceCircuitSha256: z.string(),
meshes: z.array(
z.object({
name: z.string(),
boundsMm: z.tuple([
z.tuple([z.number(), z.number(), z.number()]),
z.tuple([z.number(), z.number(), z.number()]),
]),
}),
),
})
.parse(await Bun.file("product/pcb-model-review.json").json());
const currentCircuitSha256 = new Bun.CryptoHasher("sha256")
.update(await Bun.file("dist/index/circuit.json").arrayBuffer())
.digest("hex");
if (meshReview.sourceCircuitSha256 !== currentCircuitSha256)
throw new Error("Enclosure review requires models from the current circuit");
const electronicEnvelopeClashes = [];
for (const component of meshReview.meshes) {
const [min, max] = component.boundsMm;
const envelope = modeling.transforms.translate(
[d.remote.centerXMm, d.remote.centerYMm, d.pcb.centerZMm],
modeling.transforms.rotateZ(
(d.remote.ccwRotationDegrees * Math.PI) / 180,
modeling.primitives.cuboid({
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
center: [
(min[0] + max[0]) / 2,
(min[1] + max[1]) / 2,
(min[2] + max[2]) / 2,
],
}),
),
);
for (let index = 0; index < parts.length; index++) {
if (
parts[index].referenceOnly ||
disjointBounds(
modeling.measurements.measureBoundingBox(envelope),
rows[index].boundsMm,
)
)
continue;
const volumeMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.intersect(envelope, shapes[index]),
),
);
if (volumeMm3 > 0.001)
electronicEnvelopeClashes.push({
component: component.name,
part: parts[index].name,
volumeMm3,
});
}
}
const phoneClearanceViolations = [];
for (let index = 0; index < parts.length; index++) {
for (const polygon of modeling.geometries.geom3.toPolygons(shapes[index]))
for (const vertex of polygon.vertices) {
if (
Math.hypot(
vertex[0] - d.magsafe.centerXMm,
vertex[1] - d.magsafe.centerYMm,
) > 30.00001 &&
vertex[2] < 5.99999
)
phoneClearanceViolations.push(parts[index].name);
}
}
if (electronicEnvelopeClashes.length || phoneClearanceViolations.length)
throw new Error(
JSON.stringify({ electronicEnvelopeClashes, phoneClearanceViolations }),
);
const source = any_circuit_element
.array()
.parse(await Bun.file("dist/index/circuit.json").json());
const board = source.find((element) => element.type === "pcb_board");
if (
board?.type !== "pcb_board" ||
board.width !== d.pcb.widthMm ||
board.height !== d.pcb.lengthMm ||
board.thickness !== d.pcb.thicknessMm
)
throw new Error("Enclosure PCB envelope differs from actual board");
const outerShell = solidFromPlan(
partInProduct({
name: "RemoteOuterEnvelope",
description: "Actual case boundary before hollowing",
plan: createRemoteOuterPlan(),
explodeZMm: 0,
}),
);
const gripInsertIndex = parts.findIndex(
(part) => part.name === "FingerGripInsert",
);
const gripInsertOutsideVolumeMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.subtract(shapes[gripInsertIndex], outerShell),
),
);
if (gripInsertOutsideVolumeMm3 > 0.001)
throw new Error(
`Grip insert overhangs the tapered case: ${gripInsertOutsideVolumeMm3}mm3`,
);
console.error(`Grip insert outside case: ${gripInsertOutsideVolumeMm3}mm3`);
const boardCenter = pcbPointInProduct({ x: 0, y: 0 });
const boardEnvelope = modeling.transforms.translate(
[boardCenter.x, boardCenter.y, d.pcb.centerZMm],
modeling.transforms.rotateZ(
(d.remote.ccwRotationDegrees * Math.PI) / 180,
modeling.primitives.cuboid({
size: [board.width, board.height, board.thickness],
}),
),
);
const batteryIndex = parts.findIndex(
(part) => part.name === "ASR00012_MaximumEnvelope",
);
const containmentReview = {
boardOutsideVolumeMm3: modeling.measurements.measureVolume(
modeling.booleans.subtract(boardEnvelope, outerShell),
),
batteryOutsideVolumeMm3: modeling.measurements.measureVolume(
modeling.booleans.subtract(shapes[batteryIndex], outerShell),
),
};
if (
Math.abs(containmentReview.boardOutsideVolumeMm3) > 0.001 ||
Math.abs(containmentReview.batteryOutsideVolumeMm3) > 0.001
)
throw new Error(
`Board or battery escapes case: ${JSON.stringify(containmentReview)}`,
);
const actualMounts = source.filter(
(element) => element.type === "pcb_hole" && !element.pcb_component_id,
);
if (actualMounts.length !== mountingHolesMm.length)
throw new Error("PCB mount count changed");
const mountingReview = mountingHolesMm.map(([x, y]) => {
const hole = actualMounts.find(
(element) =>
element.type === "pcb_hole" &&
element.x === x &&
element.y === y &&
element.hole_shape === "circle" &&
element.hole_diameter === 2.2,
);
if (!hole) throw new Error("Enclosure mount does not match actual PCB hole");
for (const [name, zMm] of [
["RemoteBase", d.pcb.centerZMm - 1],
["RemoteLid", d.pcb.centerZMm + 1],
] as const) {
const index = parts.findIndex((part) => part.name === name);
const mountCenter = pcbPointInProduct({ x: x + 1.5, y });
const witness = modeling.primitives.cuboid({
size: [0.2, 0.2, 0.2],
center: [mountCenter.x, mountCenter.y, zMm],
});
const volumeMm3 = modeling.measurements.measureVolume(
modeling.booleans.intersect(witness, shapes[index]),
);
if (Math.abs(volumeMm3 - 0.008) > 0.00001)
throw new Error(`Missing physical mount support: ${name} ${x},${y}`);
}
return {
pcbXmm: x,
pcbYmm: y,
holeDiameterMm: 2.2,
lowerSeatAndUpperPillarVerified: true,
};
});
const plungerBounds = rows.find(
(row) => row.name === "SideShutterPlunger",
)?.boundsMm;
if (!plungerBounds) throw new Error("Missing side actuator");
const actualSwitch = meshReview.meshes.find((mesh) => mesh.name === "SW2");
if (!actualSwitch) throw new Error("Missing actual switch model");
const actualTip = pcbPointInProduct({
x: actualSwitch.boundsMm[1][0],
y: d.shutter.centerYMm,
});
const expectedShutterCenter = pcbPointInProduct({
x: d.shutter.actuatorXMm,
y: d.shutter.centerYMm,
});
const shutterFreeGapMm = plungerBounds[0][1] - actualTip.y;
const plungerIndex = parts.findIndex(
(part) => part.name === "SideShutterPlunger",
);
const contactSlice = modeling.booleans.intersect(
shapes[plungerIndex],
modeling.primitives.cuboid({
size: [10, 0.1, 5],
center: [actualTip.x, plungerBounds[0][1] + 0.1, d.shutter.centerZMm],
}),
);
const contactBounds = modeling.measurements.measureBoundingBox(contactSlice);
const shutterCenterXMm = (contactBounds[0][0] + contactBounds[1][0]) / 2;
const shutterCenterZMm = (contactBounds[0][2] + contactBounds[1][2]) / 2;
if (
Math.abs(shutterFreeGapMm - d.shutter.freeGapMm) > 0.0001 ||
Math.abs(shutterCenterXMm - expectedShutterCenter.x) > 0.0001 ||
Math.abs(shutterCenterZMm - d.shutter.centerZMm) > 0.0001 ||
Math.abs(contactBounds[1][0] - contactBounds[0][0] - 3) > 0.0001 ||
Math.abs(contactBounds[1][2] - contactBounds[0][2] - 1.8) > 0.0001
)
throw new Error("Shutter linkage lost actual side-switch contact alignment");
const shutterAlignment = {
axis: "horizontal -Y",
freeGapMm: shutterFreeGapMm,
centerXMm: shutterCenterXMm,
centerYMm: actualTip.y,
centerZMm: shutterCenterZMm,
contactBoundsMm: contactBounds,
boardRelativeActuatorZMm: d.shutter.centerZMm - d.pcb.centerZMm,
physicalStrokeAndOvertravelQualified: false,
};
// The manufacturer gives 0.15±0.05mm contact travel. Add the checked 0.15mm
// free gap: the lower bar's channel wall is the nominal 0.35mm travel stop.
const shutterTravelReview = [];
for (const travelMm of [0, 0.15, 0.25, 0.35]) {
const movingPlunger = modeling.transforms.translate(
[0, -travelMm, 0],
shapes[plungerIndex],
);
const movingBounds = modeling.measurements.measureBoundingBox(movingPlunger);
for (let index = 0; index < parts.length; index++) {
if (
index === plungerIndex ||
parts[index].referenceOnly ||
disjointBounds(movingBounds, rows[index].boundsMm)
)
continue;
const overlapMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.intersect(movingPlunger, shapes[index]),
),
);
if (overlapMm3 > 0.001)
throw new Error(
`Shutter travel hits ${parts[index].name} at ${travelMm}mm: ${overlapMm3}`,
);
}
for (const component of meshReview.meshes) {
const [min, max] = component.boundsMm;
const envelope = modeling.transforms.translate(
[d.remote.centerXMm, d.remote.centerYMm, d.pcb.centerZMm],
modeling.transforms.rotateZ(
(d.remote.ccwRotationDegrees * Math.PI) / 180,
modeling.primitives.cuboid({
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
center: [
(min[0] + max[0]) / 2,
(min[1] + max[1]) / 2,
(min[2] + max[2]) / 2,
],
}),
),
);
if (
disjointBounds(
movingBounds,
modeling.measurements.measureBoundingBox(envelope),
)
)
continue;
if (component.name === "SW2") {
const allowedContact = modeling.primitives.cuboid({
size: [3, 0.2, 1.8],
center: [actualTip.x, actualTip.y - 0.1, d.shutter.centerZMm],
});
const outsideContactMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.subtract(
modeling.booleans.intersect(movingPlunger, envelope),
allowedContact,
),
),
);
if (outsideContactMm3 > 0.001)
throw new Error(
`Shutter travel reaches beyond the SW2 actuator's 0.20mm travel region: ${outsideContactMm3}`,
);
continue;
}
const overlapMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.intersect(movingPlunger, envelope),
),
);
if (overlapMm3 > 0.001)
throw new Error(
`Shutter travel hits ${component.name} at ${travelMm}mm: ${overlapMm3}`,
);
}
shutterTravelReview.push({
travelMm,
caseAndOther43ComponentEnvelopesClear: true,
intendedSw2ActuatorContactOnly: true,
});
}
const shoulderWallWitness = modeling.primitives.cuboid({
size: [0.2, 0.2, 0.2],
center: [-49.4, 14.8, 11],
});
const baseIndex = parts.findIndex((part) => part.name === "RemoteBase");
const shoulderWallVolumeMm3 = modeling.measurements.measureVolume(
modeling.booleans.intersect(shapes[baseIndex], shoulderWallWitness),
);
if (Math.abs(shoulderWallVolumeMm3 - 0.008) > 0.00001)
throw new Error("Shutter linkage clearance opens the exterior shoulder wall");
// These points reproduced the visible opening above the circular face in the
// previous assembly. Check native material, not only containment or mesh bounds.
const closedCase = modeling.booleans.union(
...shapes.filter((_, index) =>
[
"RemoteBase",
"RemoteLid",
"MagSafeRearCover",
"FingerGripInsert",
].includes(parts[index].name),
),
);
const protectedSideSkin = modeling.booleans.intersect(
modeling.booleans.subtract(
outerShell,
solidFromPlan(createRemoteOuterPlan({ wallInsetMm: 0.6 })),
),
modeling.primitives.cuboid({ size: [6, 4.6, 6], center: [-57, -28, 12] }),
);
const missingSideSkinMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.subtract(protectedSideSkin, closedCase),
),
);
if (missingSideSkinMm3 > 0.00001)
throw new Error(
`Wall-post clearance opens the outer grip: ${missingSideSkinMm3}mm3`,
);
console.error(`Wall-post exterior skin missing: ${missingSideSkinMm3}mm3`);
const shoulderClosureReview = [];
for (const radiusMm of [26, 27.6, 29.3]) {
for (const angleDegrees of [125, 130, 135, 140, 145, 150, 155, 160, 165]) {
const angleRadians = (angleDegrees * Math.PI) / 180;
for (const zMm of [17.4, 18, 19, 20, 22, 24, 26]) {
const centerMm: [number, number, number] = [
radiusMm * Math.cos(angleRadians),
-5 + radiusMm * Math.sin(angleRadians),
zMm,
];
const witness = modeling.primitives.cuboid({
size: [0.1, 0.1, 0.1],
center: centerMm,
});
const expectedMaterial = modeling.booleans.intersect(witness, outerShell);
const expectedVolumeMm3 = Math.abs(
modeling.measurements.measureVolume(expectedMaterial),
);
if (expectedVolumeMm3 < 1e-8) continue;
const missingVolumeMm3 = Math.abs(
modeling.measurements.measureVolume(
modeling.booleans.subtract(expectedMaterial, closedCase),
),
);
shoulderClosureReview.push({
centerMm,
expectedVolumeMm3,
missingVolumeMm3,
});
if (missingVolumeMm3 > 1e-8)
throw new Error(
`Circular-face pocket opens the shoulder wall: ${JSON.stringify(shoulderClosureReview.at(-1))}`,
);
}
}
}
const productBounds = modeling.measurements.measureBoundingBox(
modeling.booleans.union(...shapes),
);
const targetSize = [d.product.widthMm, d.product.heightMm, d.product.depthMm];
if (
productBounds[1].some(
(coordinate, index) =>
Math.abs(coordinate - productBounds[0][index] - targetSize[index]) >
0.00001,
)
)
throw new Error(
`Product differs from 90×78×34 reference: ${JSON.stringify(productBounds)}`,
);
await Bun.write(
"product/print-plans.json",
JSON.stringify(
parts
.filter(
(part) =>
!part.referenceOnly && part.name !== "ASR00012_MaximumEnvelope",
)
.map((part) => ({ name: part.name, plan: part.plan })),
),
);
const report = {
parts: rows,
electronicEnvelopeClashes,
phoneClearanceViolations,
fittedComponentEnvelopesChecked: meshReview.meshes.filter(
(mesh) => mesh.name !== "Box0",
).length,
boardEnvelopeChecked: true,
containmentReview,
mountingReview,
shutterAlignment,
shutterTravelReview,
shoulderWallVolumeMm3,
shoulderClosureReview,
clashes,
phoneDatumZMm: 0,
remoteBottomZMm: rows[baseIndex].boundsMm[0][2],
designHandleBottomZMm: d.remote.bottomZMm,
fullProductBoundingBoxMm: productBounds,
};
await Bun.write(
"product/geometry-review.json",
JSON.stringify(report, null, 2),
);
console.log(JSON.stringify(report, null, 2));
if (clashes.length) throw new Error("Product geometry has solid intersections");