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

product/geometry.ts

import type { JscadOperation } from "jscad-planner";
import { applyToPoint, compose, scale, translate } from "transformation-matrix";
import {
	dimensions as d,
	mountingHolesMm,
	pcbPointInProduct,
} from "./dimensions";
export type ProductPart = {
	name: string;
	description: string;
	plan: JscadOperation;
	explodeZMm: number;
	referenceOnly?: boolean;
};
export function box({
	size,
	center,
}: {
	size: [number, number, number];
	center: [number, number, number];
}): JscadOperation {
	return { type: "translate", vector: center, shape: { type: "cuboid", size } };
}
function cylinder({
	radiusMm,
	heightMm,
	center,
	segments = 64,
}: {
	radiusMm: number;
	heightMm: number;
	center: [number, number, number];
	segments?: number;
}): JscadOperation {
	const radius = Number(radiusMm.toFixed(6)),
		height = Number(heightMm.toFixed(6));
	return {
		type: "translate",
		vector: center.map((coordinate) => Number(coordinate.toFixed(6))),
		shape: {
			type: "translate",
			vector: [0, 0, -height / 2],
			shape: {
				type: "extrudeLinear",
				options: { height },
				shape: {
					type: "polygon",
					points: Array.from({ length: segments }, (_, index) => [
						radius * Math.cos((index * 2 * Math.PI) / segments),
						radius * Math.sin((index * 2 * Math.PI) / segments),
					]),
				},
			},
		},
	};
}
function subtract(shapes: JscadOperation[]): JscadOperation {
	return { type: "subtract", shapes };
}
function union(shapes: JscadOperation[]): JscadOperation {
	return { type: "union", shapes };
}
function color(
	plan: JscadOperation,
	rgb: [number, number, number],
): JscadOperation {
	return { type: "colorize", color: rgb, shape: plan };
}
function roundBox({
	widthMm,
	lengthMm,
	heightMm,
	center,
	radiusMm = 3,
}: {
	widthMm: number;
	lengthMm: number;
	heightMm: number;
	center: [number, number, number];
	radiusMm?: number;
}): JscadOperation {
	return {
		type: "hull",
		shapes: [-1, 1].flatMap((x) =>
			[-1, 1].map((y) =>
				cylinder({
					radiusMm,
					heightMm,
					center: [
						center[0] + x * (widthMm / 2 - radiusMm),
						center[1] + y * (lengthMm / 2 - radiusMm),
						center[2],
					],
				}),
			),
		),
	};
}
function waistCut({
	zMm,
	heightMm,
	insetMm = 0,
}: {
	zMm: number;
	heightMm: number;
	insetMm?: number;
}): JscadOperation {
	return subtract([
		cylinder({ radiusMm: 25 + insetMm, heightMm, center: [3, -54, zMm] }),
		cylinder({
			radiusMm: 31.8 - insetMm,
			heightMm: heightMm + 2,
			center: [0, -5, zMm],
		}),
	]);
}
function outline({
	zMm,
	heightMm,
	handleWidthMm = 42,
	handleLengthMm = 67,
	circleRadiusMm = 31,
}: {
	zMm: number;
	heightMm: number;
	handleWidthMm?: number;
	handleLengthMm?: number;
	circleRadiusMm?: number;
}): JscadOperation {
	const radiusInsetMm = (42 - handleWidthMm) / 2;
	const lengthInsetMm = (67 - handleLengthMm) / 2;
	return {
		type: "hull",
		shapes: [
			cylinder({ radiusMm: circleRadiusMm, heightMm, center: [0, -5, zMm] }),
			cylinder({
				radiusMm: 14 - radiusInsetMm,
				heightMm,
				center: [-43, 4 - lengthInsetMm, zMm],
			}),
			cylinder({
				radiusMm: 19 - radiusInsetMm,
				heightMm,
				center: [-40, -20, zMm],
			}),
			roundBox({
				widthMm: 38 - 2 * radiusInsetMm,
				lengthMm: 32 - 2 * lengthInsetMm,
				heightMm,
				center: [-38, -36, zMm],
				radiusMm: 11 - radiusInsetMm,
			}),
		],
	};
}

function gripInsertFootprint({
	heightMm,
	marginMm = 0,
}: {
	heightMm: number;
	marginMm?: number;
}): JscadOperation {
	const contours: [number, number][][] = [
		[
			[-54, 7],
			[-48, 8],
			[-30, 5],
			[-25, -1],
		],
		[
			[-25, -1],
			[-23, -9],
			[-25, -21],
			[-23, -31],
		],
		[
			[-23, -31],
			[-22, -40],
			[-24, -47],
			[-32, -48.5],
		],
		[
			[-32, -48.5],
			[-40, -50],
			[-50, -47],
			[-54, -43],
		],
		[
			[-54, -43],
			[-56, -30],
			[-55, -12],
			[-54, 7],
		],
	];
	const insertToPocketTransform = compose(
		translate(-38, -20),
		scale((36 + 2 * marginMm) / 36, (56 + 2 * marginMm) / 56),
		translate(38, 20),
	);
	return {
		type: "translate",
		vector: [0, 0, 26 - heightMm / 2],
		shape: {
			type: "extrudeLinear",
			options: { height: heightMm },
			shape: {
				type: "polygon",
				points: contours
					.flatMap((points, index) =>
						bezierRoof({ points, steps: 16 }).slice(index ? 1 : 0),
					)
					.slice(0, -1)
					.reverse()
					.map((point) => applyToPoint(insertToPocketTransform, point)),
			},
		},
	};
}
// Product X/Z crown sampled from tangent-continuous cubic curves, in micrometres.
function bezierCoordinate({
	points,
	t,
	axis,
}: {
	points: [number, number][];
	t: number;
	axis: number;
}): number {
	const u = 1 - t;
	return Number(
		(
			u * u * u * points[0][axis] +
			3 * u * u * t * points[1][axis] +
			3 * u * t * t * points[2][axis] +
			t * t * t * points[3][axis]
		).toFixed(6),
	);
}
function bezierRoof({
	points,
	steps,
}: {
	points: [number, number][];
	steps: number;
}): [number, number][] {
	return Array.from({ length: steps + 1 }, (_, index) => [
		bezierCoordinate({ points, t: index / steps, axis: 0 }),
		bezierCoordinate({ points, t: index / steps, axis: 1 }),
	]);
}
const crownRoofMm: [number, number][] = [
	...bezierRoof({
		points: [
			[-65, 18],
			[-58, 34],
			[-47, 34],
			[-38, 34],
		],
		steps: 12,
	}),
	...bezierRoof({
		points: [
			[-38, 34],
			[-28, 34],
			[-23, 33],
			[-19, 22],
		],
		steps: 12,
	}).slice(1),
	...bezierRoof({
		points: [
			[-19, 22],
			[-17.2, 17.05],
			[-16, 17],
			[-14, 17],
		],
		steps: 6,
	}).slice(1),
	[35, 17],
];
const crownProfileMm: [number, number][] = [[-65, 6], ...crownRoofMm, [35, 6]];
function crownMask({
	zOffsetMm = 0,
}: {
	zOffsetMm?: number;
} = {}): JscadOperation {
	return {
		type: "translate",
		vector: [0, 40, zOffsetMm],
		shape: {
			type: "rotate",
			angles: [Math.PI / 2, 0, 0],
			shape: {
				type: "extrudeLinear",
				options: { height: 110 },
				shape: { type: "polygon", points: [...crownProfileMm].reverse() },
			},
		},
	};
}
function intersect(shapes: JscadOperation[]): JscadOperation {
	return { type: "intersect", shapes };
}
export function createRemoteOuterPlan({
	wallInsetMm = 0,
}: {
	wallInsetMm?: number;
} = {}): JscadOperation {
	return union([
		cylinder({
			radiusMm: 29.4 - wallInsetMm,
			heightMm: 1.2,
			center: [0, -5, 5.4],
		}),
		intersect([
			subtract([
				{
					type: "hull",
					shapes: [
						outline({
							zMm: 6.5,
							heightMm: 1,
							handleWidthMm: 40 - 2 * wallInsetMm,
							handleLengthMm: 65 - 2 * wallInsetMm,
							circleRadiusMm: 31 - wallInsetMm,
						}),
						outline({
							zMm: 18,
							heightMm: 1,
							handleWidthMm: 42 - 2 * wallInsetMm,
							handleLengthMm: 67 - 2 * wallInsetMm,
							circleRadiusMm: 31 - wallInsetMm,
						}),
						outline({
							zMm: 29.5,
							heightMm: 1,
							handleWidthMm: 40 - 2 * wallInsetMm,
							handleLengthMm: 65 - 2 * wallInsetMm,
							circleRadiusMm: 31 - wallInsetMm,
						}),
						outline({
							zMm: 34,
							heightMm: 0.2,
							handleWidthMm: 37 - 2 * wallInsetMm,
							handleLengthMm: 62 - 2 * wallInsetMm,
							circleRadiusMm: 29.2 - wallInsetMm,
						}),
					],
				},
				waistCut({ zMm: 20, heightMm: 40, insetMm: wallInsetMm }),
			]),
			crownMask({ zOffsetMm: -wallInsetMm }),
		]),
	]);
}
export function createProductParts(): ProductPart[] {
	const mountPoints = mountingHolesMm.map(([x, y]) =>
		pcbPointInProduct({ x, y }),
	);
	// The face pocket must retain a vertical sealing land where the thin disk
	// meets the taller shoulder. Previously both cutters removed this wall.
	const shoulderCollar = intersect([
		cylinder({ radiusMm: 31, heightMm: 18, center: [0, -5, 25.8] }),
		box({ size: [17, 29.1, 18], center: [-23.5, 15.45, 25.8] }),
	]);
	// Keep the upper junction on the lid, with a planar 0.05mm Y clearance.
	const lidShoulderRegion = box({
		size: [100, 41.1, 18],
		center: [0, 21.45, 25.6],
	});
	const collarBaseClearance = box({
		size: [100, 41.2, 18],
		center: [0, 21.45, 25.6],
	});

	const cavity: JscadOperation = intersect([
		createRemoteOuterPlan({ wallInsetMm: 1.6 }),
		subtract([
			{
				type: "hull",
				shapes: [
					cylinder({ radiusMm: 29.4, heightMm: 23, center: [0, -5, 19.5] }),
					cylinder({ radiusMm: 10.4, heightMm: 23, center: [-43, 5, 19.5] }),
					roundBox({
						widthMm: 38.8,
						lengthMm: 63.8,
						heightMm: 23,
						center: [-38, -18.5, 19.5],
						radiusMm: 15.4,
					}),
				],
			},
			waistCut({ zMm: 19.5, heightMm: 25, insetMm: 1.6 }),
		]),
		crownMask({ zOffsetMm: -2 }),
	]);
	const lowerSeats = mountPoints.map(({ x, y }) =>
		box({ size: [4.2, 4.2, 1.2], center: [x, y, 8.4] }),
	);
	const baseShafts = mountPoints.map(({ x, y }) =>
		cylinder({ radiusMm: 1.1, heightMm: 6, center: [x, y, 8] }),
	);
	const batterySeats = [-1, 1].map((side) =>
		box({
			size: [3.6, 43, 0.8],
			center: [d.battery.centerXMm + side * 17.85, d.battery.centerYMm, 14.4],
		}),
	);
	const tip = pcbPointInProduct({
		x: d.shutter.actuatorXMm,
		y: d.shutter.centerYMm,
	});
	const contactY = tip.y + d.shutter.freeGapMm;
	const barY = 15,
		capX = -45.5,
		riserX = -43,
		capY = 15.8,
		capZ = 30.7;
	const plunger = union([
		box({
			size: [3, barY - contactY + 0.8, 1.8],
			center: [tip.x, (contactY + barY + 0.8) / 2, d.shutter.centerZMm],
		}),
		box({
			size: [Math.abs(riserX - tip.x) + 1.5, 1.8, 1.8],
			center: [(riserX + tip.x) / 2, barY, d.shutter.centerZMm],
		}),
		box({
			size: [3, 1.8, capZ - d.shutter.centerZMm + 1.8],
			center: [riserX, barY, (capZ + d.shutter.centerZMm) / 2],
		}),
		box({
			size: [Math.abs(capX - riserX) + 3, 1.8, 1.8],
			center: [(capX + riserX) / 2, barY - 0.05, capZ],
		}),
		box({
			size: [3, Math.abs(capY - barY) + 0.8, 1.8],
			center: [capX, (barY + capY) / 2, capZ],
		}),
		{
			type: "translate",
			vector: [capX, capY, capZ],
			shape: {
				type: "rotate",
				angles: [(2 * Math.PI) / 3, 0, 0],
				shape: {
					type: "hull",
					shapes: [
						roundBox({
							widthMm: 11.8,
							lengthMm: 5.5,
							heightMm: 0.2,
							center: [0, 0, 0.7],
							radiusMm: 2.5,
						}),
						roundBox({
							widthMm: 11.6,
							lengthMm: 5.3,
							heightMm: 0.3,
							center: [0, 0, 0],
							radiusMm: 2.4,
						}),
						roundBox({
							widthMm: 11,
							lengthMm: 4.7,
							heightMm: 0.2,
							center: [0, 0, -0.7],
							radiusMm: 2.1,
						}),
					],
				},
			},
		},
	]);
	const apertures = [
		box({ size: [3.5, 4.3, 2.3], center: [capX, 16.25, capZ] }),
		{
			type: "translate",
			vector: [capX, capY, capZ],
			shape: {
				type: "rotate",
				angles: [(2 * Math.PI) / 3, 0, 0],
				shape: roundBox({
					widthMm: 12.5,
					lengthMm: 6.2,
					heightMm: 8,
					center: [0, 0, 0],
					radiusMm: 2.8,
				}),
			},
		} satisfies JscadOperation,
		{
			type: "translate",
			vector: [-52, -9, 11.48],
			shape: {
				type: "rotate",
				angles: [Math.PI / 2, 0, Math.PI / 2],
				shape: roundBox({
					widthMm: 10.2,
					lengthMm: 5.2,
					heightMm: 22,
					center: [0, 0, 0],
					radiusMm: 2.4,
				}),
			},
		} satisfies JscadOperation,
		box({ size: [3.5, 2.4, 25.2], center: [riserX, barY - 0.05, 20.6] }),
		box({
			size: [Math.abs(capX - riserX) + 3.5, 2.4, 2.3],
			center: [(capX + riserX) / 2, barY - 0.05, capZ],
		}),
		box({ size: [8, 2.3, 2.3], center: [tip.x, 14.8, d.shutter.centerZMm] }),
		box({
			size: [Math.abs(riserX - tip.x) + 2, 2.4, 2.3],
			center: [(riserX + tip.x) / 2, barY - 0.05, d.shutter.centerZMm],
		}),
	];
	const dockPoints = [
		[-20, -5],
		[20, -5],
		[15, -22],
		[0, 15],
	];
	const dockingSlots = dockPoints.flatMap(([x, y]) => [
		cylinder({ radiusMm: 2.9, heightMm: 5.2, center: [x + 4, y, 7.2] }),
		{
			type: "hull",
			shapes: [0, 4].map((dx) =>
				cylinder({ radiusMm: 2.3, heightMm: 5.2, center: [x + dx, y, 7.2] }),
			),
		} satisfies JscadOperation,
		box({ size: [10, 6, 1.4], center: [x + 2, y, 7.7] }),
	]);
	const upperPillars = mountPoints.flatMap(({ x, y }) =>
		x < -30 && y < 0
			? [
					box({ size: [4.2, 4.2, 3.8], center: [x, y, 11.9] }),
					box({ size: [18.1, 4.2, 3.8], center: [-48.25, y, 11.9] }),
					box({ size: [2.8, 4.2, 21.5], center: [-56.3, y, 20.75] }),
				]
			: [box({ size: [4.2, 4.2, 21.5], center: [x, y, 20.75] })],
	);
	const lidPilots = mountPoints.map(({ x, y }) =>
		cylinder({
			radiusMm: 0.85,
			heightMm: x < -30 && y < 0 ? 3.8 : 21.2,
			center: [x, y, x < -30 && y < 0 ? 11.8 : 20.5],
		}),
	);
	const padFootprint = intersect([
		gripInsertFootprint({ heightMm: 20 }),
		subtract([
			outline({
				zMm: 20,
				heightMm: 40,
				handleWidthMm: 34,
				handleLengthMm: 60,
				circleRadiusMm: 29,
			}),
			waistCut({ zMm: 20, heightMm: 40, insetMm: 2 }),
		]),
	]);
	const padSkin = intersect([
		padFootprint,
		subtract([crownMask(), crownMask({ zOffsetMm: -1.4 })]),
	]);
	const pad = intersect([padSkin, createRemoteOuterPlan()]);
	const padPocket = intersect([
		gripInsertFootprint({ heightMm: 22, marginMm: 0.3 }),
		subtract([
			outline({
				zMm: 20,
				heightMm: 40,
				handleWidthMm: 34.6,
				handleLengthMm: 60.6,
				circleRadiusMm: 29.3,
			}),
			waistCut({ zMm: 20, heightMm: 40, insetMm: 1.7 }),
		]),
		subtract([crownMask({ zOffsetMm: 1 }), crownMask({ zOffsetMm: -1.5 })]),
	]);
	const gripFaceKeepout = roundBox({
		widthMm: 40.6,
		lengthMm: 65.6,
		heightMm: 40,
		center: [-38, -18.5, 25],
		radiusMm: 16.3,
	});
	const rearCover = subtract([
		cylinder({ radiusMm: 29.2, heightMm: 0.6, center: [0, -5, 16.75] }),
		box({
			size: [
				d.battery.widthMm + 0.6,
				d.battery.lengthMm + 0.6,
				d.battery.heightMm + 0.6,
			],
			center: [
				d.battery.centerXMm,
				d.battery.centerYMm,
				d.battery.bottomZMm + d.battery.heightMm / 2,
			],
		}),
		shoulderCollar,
	]);
	const rearPocket = union([
		subtract([
			cylinder({ radiusMm: 29.4, heightMm: 16, center: [0, -5, 24.3] }),
			gripFaceKeepout,
		]),
		cylinder({ radiusMm: 29.4, heightMm: 1.2, center: [0, -5, 16.9] }),
	]);
	// Union the internal cutters before retaining the land. Independent cuts
	// leave duplicate boundary fragments on their shared 29.4mm-radius face.
	const interiorCut = subtract([union([cavity, rearPocket]), shoulderCollar]);
	const protectedRearPocket = subtract([rearPocket, shoulderCollar]);
	const jointMask = crownMask({ zOffsetMm: -3.5 });
	const shell = subtract([
		createRemoteOuterPlan(),
		interiorCut,
		...apertures,
		...dockingSlots,
		padPocket,
	]);
	const base = subtract([
		union([
			intersect([shell, jointMask]),
			...lowerSeats,
			intersect([union(batterySeats), createRemoteOuterPlan()]),
		]),
		collarBaseClearance,
		intersect([
			box({ size: [3.2, 4.6, 25], center: [-56.3, -28, 20] }),
			createRemoteOuterPlan({ wallInsetMm: 0.8 }),
		]),
		...baseShafts,
	]);
	const lid = subtract([
		union([
			subtract([shell, subtract([jointMask, lidShoulderRegion])]),
			intersect([
				union(upperPillars),
				createRemoteOuterPlan({ wallInsetMm: 1 }),
			]),
		]),
		...lidPilots,
		padPocket,
		protectedRearPocket,
		...apertures,
	]);
	const tray = subtract([
		box({
			size: [33, 44, 0.6],
			center: [d.battery.centerXMm, d.battery.centerYMm, 15.1],
		}),
		box({
			size: [29, 40, 2],
			center: [d.battery.centerXMm, d.battery.centerYMm, 15.1],
		}),
	]);
	const cell = box({
		size: [32, 43, 8.5],
		center: [
			d.battery.centerXMm,
			d.battery.centerYMm,
			d.battery.bottomZMm + 4.25,
		],
	});
	const dock = union([
		cylinder({ radiusMm: 29.5, heightMm: 2.6, center: [0, -5, 3.5] }),
		subtract([
			cylinder({ radiusMm: 29.5, heightMm: 1.8, center: [0, -5, 1.5] }),
			cylinder({ radiusMm: 27.3, heightMm: 2.2, center: [0, -5, 1.5] }),
		]),
		cylinder({ radiusMm: 22.75, heightMm: 1.8, center: [0, -5, 1.5] }),
		...dockPoints.flatMap(([x, y]) => [
			cylinder({ radiusMm: 2, heightMm: 2.5, center: [x, y, 6.05] }),
			cylinder({ radiusMm: 2.6, heightMm: 1, center: [x, y, 7.8] }),
		]),
	]);
	const ring = subtract([
		cylinder({ radiusMm: 27.05, heightMm: 1.1, center: [0, -5, 1.15] }),
		cylinder({ radiusMm: 23, heightMm: 2, center: [0, -5, 1.15] }),
	]);
	return [
		{
			name: "RemoteBase",
			description:
				"90×78 reference body; PCB spans shoulder/circular back; original four mounting points",
			plan: color(base, [0.06, 0.065, 0.07]),
			explodeZMm: 4,
		},
		{
			name: "BatterySupport",
			description:
				"Protected battery support in the lower palm; harness/thermal qualification pending",
			plan: color(tray, [0.1, 0.11, 0.12]),
			explodeZMm: 34,
		},
		{
			name: "ASR00012_MaximumEnvelope",
			description: "Actual retained protected pack maximum32×43×8.5mm",
			plan: color(cell, [0.7, 0.72, 0.75]),
			explodeZMm: 44,
		},
		{
			name: "SideShutterPlunger",
			description:
				"Rigid L-slider from shoulder cap to actual horizontal PCB side switch; stroke/return qualification pending",
			plan: color(plunger, [0.48, 0.29, 0.14]),
			explodeZMm: 4,
		},
		{
			name: "RemoteLid",
			description:
				"Sculpted palm lid with blind underside fasteners; native camera-grip reference silhouette",
			plan: color(lid, [0.065, 0.07, 0.075]),
			explodeZMm: 74,
		},
		{
			name: "FingerGripInsert",
			description:
				"Curved palm insert; fine leather-like surface finish and bonding qualification pending",
			plan: color(pad, [0.025, 0.028, 0.03]),
			explodeZMm: 82,
		},
		{
			name: "MagSafeRearCover",
			description:
				"Circular rear face integrated in the shoulder; cover bonding pending",
			plan: color(rearCover, [0.025, 0.028, 0.03]),
			explodeZMm: 45,
		},
		{
			name: "MagSafeGrip",
			description:
				"Separate phone-facing dock with four printed slide keys; retention/removal not physically qualified",
			plan: color(dock, [0.06, 0.065, 0.07]),
			explodeZMm: 0,
		},
		{
			name: "MagSafeFaceCover",
			description:
				"Phone-facing cover over reference annulus; exact array/DCshield/bonding pending",
			plan: color(
				cylinder({ radiusMm: 29.5, heightMm: 0.6, center: [0, -5, 0.3] }),
				[0.055, 0.06, 0.065],
			),
			explodeZMm: -10,
		},
		{
			name: "MagSafeArrayReference",
			description:
				"AppleR31 reference46/54.1×1.1 only; not a selected array or RF pass",
			plan: color(ring, [0.8, 0.55, 0.15]),
			explodeZMm: -6,
			referenceOnly: true,
		},
	];
}
export function partInProduct(part: ProductPart): JscadOperation {
	return part.plan;
}