AnasSarkiz/magnetic-shutter-remote--01a0f81f
A compact USB‑C rechargeable battery device with BQ25185 charging, 3.3‑V regulation and protection, E73 Bluetooth control, temperature monitoring, status/charge LEDs, shutter/pair/power switches, and a mechanically enclosed Li‑Po battery with docking/antenna provisions.
- Version
- 0.1.9
- License
- unset
- Stars
- 0
enclosure-design/parts.ts
import {
bar,
box,
colored,
cylinder,
hull,
move,
ring,
roundedBox,
subtract,
union,
type MechanicalPlan,
type Point3,
type Rgb,
} from "./geometry";
import { dimensions as d } from "./dimensions";
export type PartRole = "printed" | "optic" | "external_envelope" | "reference";
export interface MechanicalPart {
name: string;
plan: MechanicalPlan;
role: PartRole;
material: string;
explodeZ: number;
translucent?: boolean;
}
const graphite: Rgb = [0.075, 0.09, 0.105];
const face: Rgb = [0.14, 0.16, 0.18];
const coral: Rgb = [0.76, 0.17, 0.16];
const clearWhite: Rgb = [0.91, 0.95, 0.98];
const steel: Rgb = [0.56, 0.61, 0.65];
function shellProfile(options: { z: number; height: number }): MechanicalPlan {
return roundedBox({
width: d.body.width,
length: d.body.length,
radius: d.body.cornerRadius,
...options,
});
}
function magnetPocket(options: {
x: number;
y: number;
z: number;
height?: number;
}): MechanicalPlan {
return cylinder({ ...options, radius: 3.325, height: options.height ?? 3.5 });
}
export function createBase(): MechanicalPlan {
const outer = shellProfile({
z: d.body.bottomZ,
height: d.body.seamZ - d.body.bottomZ,
});
const cavity = roundedBox({
width: d.body.insideWidth,
length: d.body.insideLength,
radius: 4.6,
z: -4.1,
height: 17,
});
const posts = [-13, 13].map((x) =>
cylinder({ x, y: 24, radius: 2.85, z: -4.1, height: 3.6 }),
);
const magnetBosses = [-13, 13].map((x) =>
cylinder({ x, y: 16, radius: 4.5, z: -4.1, height: 3.5 }),
);
const screwBores = [-13, 13].map((x) =>
cylinder({ x, y: 24, radius: 1.6, z: -4.25, height: 3.76 }),
);
const ledgeSupports = [-17.6, 17.6].map((x) =>
box([1.2, 5, 3.6], [x, -8, -2.3]),
);
const cradleShelves = [
[-20.5, -4.8],
[20.5, -6.9],
[-20.5, 21],
[20.5, 21],
].map(([x, y]) => box([4.5, 2.3, 0.8], [x, y, 2.0]));
const usbTunnel = box(
[d.usb.openingWidth, 12, d.usb.openingHeight],
[0, 31, d.usb.openingCentreZ],
);
const usbShoulder = roundedBox({
width: d.usb.shoulderReliefWidth,
length: 8,
radius: 1,
y: 32.2,
z: -3.4,
height: 10.8,
});
const sliderOpening = box(
[8, d.power.slotLength, d.power.slotHeight],
[21, d.power.y, 1.5],
);
const pockets = [-13, 13].map((x) => magnetPocket({ x, y: 16, z: -4.1 }));
const grooves = [-16, -13, -10, -7, -4, -1, 2].map((y) =>
box([0.55, 0.65, 9], [-22.9, y, 5.0]),
);
return colored(
graphite,
subtract(
union(
subtract(outer, cavity),
...posts,
...magnetBosses,
...ledgeSupports,
...cradleShelves,
),
...screwBores,
...pockets,
usbTunnel,
usbShoulder,
sliderOpening,
...grooves,
),
);
}
export function createLid(): MechanicalPlan {
const roof = shellProfile({ z: d.body.seamZ, height: d.body.lid });
const lip = subtract(
roundedBox({
width: 42.6,
length: 60.6,
radius: 4.3,
z: 10.1,
height: 1.6,
}),
roundedBox({
width: 40.8,
length: 58.8,
radius: 3.4,
z: 10.0,
height: 1.9,
}),
);
const columns = [-13, 13].map((x) =>
cylinder({ x, y: 24, radius: 2.3, z: 0.6, height: 11.1 }),
);
const guides = [d.shutter, d.pair].flatMap((p) => {
const radius = p === d.shutter ? 7.2 : 4.8;
return [
ring({
x: p.x,
y: p.y,
z: 8.5,
outerRadius: radius,
innerRadius: radius - 0.5,
height: 3.2,
}),
ring({
x: p.x,
y: p.y,
z: 8.5,
outerRadius: 2.4,
innerRadius: 1.45,
height: 0.8,
}),
box([radius - 2.2, 0.8, 0.8], [p.x + (radius + 2.2) / 2, p.y, 8.9]),
box([radius - 2.2, 0.8, 0.8], [p.x - (radius + 2.2) / 2, p.y, 8.9]),
];
});
const opticShroud = hull(
cylinder({ x: 10, y: 0, z: 8.9, radius: 1.9, height: 3 }),
cylinder({ x: -8.1, y: -5.4, z: 10.3, radius: 1.9, height: 2.1 }),
);
const shroudChannel = hull(
cylinder({ x: 10, y: 0, z: 9.2, radius: 1.5, height: 1.7 }),
cylinder({ x: -8.1, y: -5.4, z: 10.5, radius: 1.5, height: 1.7 }),
);
const ringHole = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 11.25,
radius: 6.15,
height: 3,
});
const flangeSeat = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 11.25,
radius: 6.7,
height: 0.75,
});
const pairHole = cylinder({
x: d.pair.x,
y: d.pair.y,
z: 10.5,
radius: 3.1,
height: 4,
});
const chargeHole = cylinder({
x: d.chargeLed.x,
y: d.chargeLed.y,
z: 10.4,
radius: 1.4,
height: 4,
});
const chargeSeat = cylinder({
x: d.chargeLed.x,
y: d.chargeLed.y,
z: 11.3,
radius: 1.9,
height: 0.7,
});
const collectorHole = cylinder({
x: 10,
y: 0,
z: 8.2,
radius: 1.55,
height: 3.5,
});
const screwHoles = [-13, 13].flatMap((x) => [
cylinder({ x, y: 24, z: 0.5, radius: 1.2, height: 13 }),
cylinder({ x, y: 24, z: 11.0, radius: 2.2, height: 3 }),
]);
const usbRelief = box([16.5, 8, 10], [0, 32.2, 7.0]);
const flangeClearances = [
cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 10.4,
radius: 5.4,
height: 0.9,
}),
cylinder({ x: d.pair.x, y: d.pair.y, z: 10.7, radius: 4.3, height: 0.8 }),
];
return colored(
face,
subtract(
union(roof, lip, ...columns, ...guides, opticShroud),
ringHole,
flangeSeat,
pairHole,
chargeHole,
chargeSeat,
collectorHole,
shroudChannel,
...screwHoles,
...flangeClearances,
usbRelief,
),
);
}
export function createStatusOptic(): MechanicalPlan {
const visibleRing = ring({
x: d.shutter.x,
y: d.shutter.y,
z: 11.9,
outerRadius: 5.9,
innerRadius: 5.15,
height: 1.4,
});
const retainingFlange = ring({
x: d.shutter.x,
y: d.shutter.y,
z: 11.4,
outerRadius: 6.5,
innerRadius: 5.1,
height: 0.6,
});
const collector = cylinder({
x: 10,
y: 0,
z: d.statusLed.collectorBottomZ,
radius: 1.25,
height: 8.4,
});
const feed = bar({
start: [10, 0, 9.4],
end: [-8.1, -5.4, 10.7],
radius: 1.2,
height: 1.2,
});
return colored(
clearWhite,
subtract(
union(visibleRing, retainingFlange, collector, feed),
cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 8,
radius: 5.1,
height: 8,
}),
cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 8,
radius: 5.4,
height: 3.3,
}),
),
);
}
export function createChargeOptic(): MechanicalPlan {
return colored(
clearWhite,
union(
cylinder({
x: 8,
y: 26,
z: d.chargeLed.collectorBottomZ,
radius: 1.1,
height: 11.4,
}),
cylinder({ x: 8, y: 26, z: 11.4, radius: 1.6, height: 0.6 }),
),
);
}
function cameraEngraving(): MechanicalPlan {
const body = ring({
x: d.shutter.x,
y: d.shutter.y,
z: 13.95,
outerRadius: 1.25,
innerRadius: 0.8,
height: 0.4,
});
const frame = subtract(
roundedBox({
width: 4.4,
length: 2.8,
radius: 0.4,
x: d.shutter.x,
y: d.shutter.y,
z: 13.95,
height: 0.4,
}),
roundedBox({
width: 3.6,
length: 2,
radius: 0.3,
x: d.shutter.x,
y: d.shutter.y,
z: 13.9,
height: 0.6,
}),
);
return union(
body,
frame,
box([1.4, 0.4, 0.4], [d.shutter.x - 0.8, d.shutter.y + 1.6, 14.15]),
);
}
export function createShutterPlunger(): MechanicalPlan {
const shaft = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: d.shutter.stemTipZ,
radius: 1.2,
height: 10.0,
});
const flange = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 10.7,
radius: 5.15,
height: 0.5,
});
const cap = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 12.4,
radius: 4.45,
height: 1.8,
});
return colored(coral, subtract(union(shaft, flange, cap), cameraEngraving()));
}
export function createShutterSkin(): MechanicalPlan {
const cap = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 13.65,
radius: 4.8,
height: 0.95,
});
const cavity = cylinder({
x: d.shutter.x,
y: d.shutter.y,
z: 13.5,
radius: 4.55,
height: 0.75,
});
return colored(
coral,
subtract(cap, cavity, move([0, 0, 0.45], cameraEngraving())),
);
}
export function createPairPlunger(): MechanicalPlan {
const shaft = cylinder({
x: d.pair.x,
y: d.pair.y,
z: d.pair.stemTipZ,
radius: 1.1,
height: 10.0,
});
const flange = cylinder({
x: d.pair.x,
y: d.pair.y,
z: 11.0,
radius: 4,
height: 0.4,
});
const cap = cylinder({
x: d.pair.x,
y: d.pair.y,
z: 12.4,
radius: 2.8,
height: 1.3,
});
const symbolPoints: Point3[] = [
[13.1, -5.3, 13.45],
[14.5, -3.2, 13.45],
[13.1, -2.6, 13.45],
[13.1, -5.3, 13.45],
[14.5, -4.7, 13.45],
[12.4, -3.0, 13.45],
];
const engraving = symbolPoints
.slice(1)
.map((end, i) =>
bar({ start: symbolPoints[i], end, radius: 0.17, height: 0.4 }),
);
return colored(graphite, subtract(union(shaft, flange, cap), ...engraving));
}
export function createSlider(): MechanicalPlan {
// Fork retains the 1.3 mm switch actuator with 0.2 mm radial-side allowances.
const fork = box([2.0, 2.3, 1.8], [18.55, 5, 1.5]);
const shaft = box([6.4, 2.3, 1.8], [21, 5, 1.5]);
const cap = roundedBox({
width: 2.4,
length: 4.8,
radius: 0.7,
x: 24.0,
y: 5,
z: 0.45,
height: 2.1,
});
const pocket = box([2.2, 1.7, 2.2], [17.7, 5, 1.4]);
const gripCuts = [-1.25, 0, 1.25].map((y) =>
box([0.45, 0.45, 2.4], [25.1, 5 + y, 1.5]),
);
// Supplier model shows one stable end position at Y=5.85, not neutral Y=5.
return colored(
coral,
move([0, 0.85, 0], subtract(union(fork, shaft, cap), pocket, ...gripCuts)),
);
}
export function createBatteryCarrier(): MechanicalPlan {
const floor = roundedBox({
width: 18.5,
length: 28.8,
radius: 1.4,
y: 7.2,
z: 2.4,
height: 0.4,
});
const tabs = [-8.6, 8.6].flatMap((x) =>
[-0.5, 16.1].map((y) => box([0.6, 3, 4.6], [x, y, 5.1])),
);
const reliefs = [
box([2.4, 2.4, 1], [0, 6, 2.6]),
box([5, 3, 1], [5, 17.5, 2.6]),
box([2, 7, 1], [9.9, 15.7, 2.6]),
cylinder({ x: 10, y: 0, z: 2, radius: 1.6, height: 6 }),
];
const beams = [
[-14.55, -4.8],
[14.55, -6.9],
[-14.55, 21],
[14.55, 21],
].map(([x, y]) => box([11.6, 1, 0.8], [x, y, 2.8]));
return colored(
[0.32, 0.36, 0.4],
subtract(
union(subtract(floor, ...reliefs), ...tabs, ...beams),
cylinder({ x: 10, y: 0, z: 2, radius: 1.6, height: 6 }),
),
);
}
export function createGrip(): MechanicalPlan {
const body = roundedBox({
width: d.dock.width,
length: d.dock.length,
radius: d.dock.cornerRadius,
y: d.dock.centreY,
z: d.dock.bottomZ,
height: d.dock.surfaceZ - d.dock.bottomZ,
});
const handWell = roundedBox({
width: 41,
length: 44,
radius: 12,
y: 67,
z: -11.0,
height: 2.6,
});
const rails = [-24.3, 24.3].map((x) =>
roundedBox({
width: 1.8,
length: 39,
radius: 0.8,
x,
y: 11.5,
z: -5.8,
height: 2.7,
}),
);
const stop = roundedBox({
width: 46.8,
length: 1.8,
radius: 0.8,
y: 33.3,
z: -5.8,
height: 1.8,
});
const dockPockets = [-13, 13].map((x) =>
magnetPocket({ x, y: 16, z: -9.65, height: 3.86 }),
);
const phonePockets = [-14, 14].flatMap((x) =>
[28, 56].map((y) => magnetPocket({ x, y, z: -12.05, height: 3.86 })),
);
const fingerGrooves = [55, 59, 63, 67, 71, 75, 79].map((y) =>
box([46, 0.9, 0.65], [0, y, -11.85]),
);
return colored(
graphite,
subtract(
union(body, ...rails, stop),
handWell,
...dockPockets,
...phonePockets,
...fingerGrooves,
),
);
}
export function harnessPoints(offset: number): Point3[] {
const points: Point3[] = [
[-13.5, 4.5, 4.3],
[-20, 4.5, 4.3],
[-20, 17.5, 4.3],
[-20, 17.5, 7.8],
[-19, 17.5, 7.8],
[-19, 1, 7.8],
[-15.8, 1, 7.8],
[-15.8, 16.25, 7.8],
[-12.6, 16.25, 7.8],
[-12.6, 1, 7.8],
[-10.5, 1, 7.8],
[-10.5, -1, 5.5],
[-10.5, -6.7, 5.5],
[0, -6.7, 5.5],
[0, -6.5, 5.0],
];
return points.map(([x, y, z], i) => [
x + offset,
y + (i >= 12 ? 0 : offset),
z + offset,
]);
}
function harnessEnvelope(offset: number): MechanicalPlan {
const points = harnessPoints(offset);
return union(
...points
.slice(1)
.map((end, i) =>
hull(
move(points[i], { type: "sphere", radius: 0.6 }),
move(end, { type: "sphere", radius: 0.6 }),
),
),
);
}
export function createParts(): MechanicalPart[] {
const printed = (
name: string,
plan: MechanicalPlan,
explodeZ: number,
): MechanicalPart => ({
name,
plan,
role: "printed",
material: "Unfilled PETG or PA12; dimensions require prototype fit",
explodeZ,
});
const parts: MechanicalPart[] = [
printed("RemoteBase", createBase(), -14),
printed("RemoteLid", createLid(), 33),
{
name: "StatusRingLightGuide",
plan: createStatusOptic(),
role: "optic",
material: "Optical PMMA / clear resin; polish feed, diffuse ring",
explodeZ: 23,
translucent: true,
},
{
name: "ChargeWindowLightGuide",
plan: createChargeOptic(),
role: "optic",
material: "Optical PMMA / clear resin",
explodeZ: 23,
translucent: true,
},
printed("ShutterPlunger", createShutterPlunger(), 40),
printed("PairPlunger", createPairPlunger(), 40),
{
name: "ShutterSoftSkin",
plan: createShutterSkin(),
role: "printed",
material: "Unfilled TPU; press fit subject to prototype test",
explodeZ: 45,
},
printed("PowerSlider", createSlider(), 14),
printed("BatteryCarrier", createBatteryCarrier(), 9),
{
name: "BatteryDTP401525MaximumEnvelope",
plan: colored(
[0.73, 0.76, 0.79],
roundedBox({
width: 15.5,
length: 27,
radius: 0.7,
y: 7.8,
z: 2.8,
height: 4.2,
}),
),
role: "external_envelope",
material:
"DATA POWER DTP401525(PHR): documented maximum envelope, not a supplier CAD model",
explodeZ: 15,
},
printed("MagneticGripDock", createGrip(), -25),
{
name: "BatteryThermalPadCompressedEnvelope",
plan: colored([0.57, 0.46, 0.67], box([2, 2, 1.7], [0, 6, 1.95])),
role: "external_envelope",
material:
"BERGQUIST GP1500-0.080-02-0404; 2x2 cut piece, nominal compressed envelope",
explodeZ: 6,
},
{
name: "BatteryHarnessPositiveReservation",
plan: colored(coral, harnessEnvelope(0.65)),
role: "reference",
material:
"1.2 mm insulated-wire reservation; ~100 mm route, actual bends/slack untested",
explodeZ: 16,
},
{
name: "BatteryHarnessNegativeReservation",
plan: colored([0.1, 0.1, 0.1], harnessEnvelope(-0.65)),
role: "reference",
material:
"1.2 mm insulated-wire reservation; ~100 mm route, actual bends/slack untested",
explodeZ: 16,
},
{
name: "PhoneReceiverPlateEnvelope",
plan: colored(
steel,
roundedBox({
width: 45,
length: 45,
radius: 3,
y: 42,
z: -12.55,
height: 0.5,
}),
),
role: "external_envelope",
material:
"Original 45x45x0.5 steel receiver plate, externally installed; no MagSafe claim",
explodeZ: -31,
},
];
for (const x of [-13, 13]) {
const suffix = x < 0 ? "Left" : "Right";
parts.push({
name: `LidScrewM2x14${suffix}`,
plan: colored(
steel,
union(
cylinder({ x, y: 24, z: -3, radius: 1, height: 14 }),
cylinder({ x, y: 24, z: 11, radius: 2, height: 1.6 }),
),
),
role: "external_envelope",
material:
"M2x14 low-profile screw nominal envelope; exact hardware vendor not locked",
explodeZ: 50,
});
parts.push({
name: `M2InsertEnvelope${suffix}`,
plan: colored(
[0.76, 0.57, 0.22],
ring({
x,
y: 24,
z: -3.7,
outerRadius: 1.6,
innerRadius: 1.01,
height: 3,
}),
),
role: "external_envelope",
material:
"M2 insert target envelope OD3.2x3; supplier-specific pilot/thermal insertion to qualify",
explodeZ: -8,
});
}
for (const x of [-13, 13]) {
parts.push({
name: `RemoteMagnet${x < 0 ? "Left" : "Right"}`,
plan: colored(
steel,
cylinder({ x, y: 16, z: -4.0, radius: 3.175, height: 3.175 }),
),
role: "external_envelope",
material: "K&J D42 dimensional envelope; externally installed",
explodeZ: -8,
});
parts.push({
name: `DockMagnet${x < 0 ? "Left" : "Right"}`,
plan: colored(
steel,
cylinder({ x, y: 16, z: -9.6, radius: 3.175, height: 3.175 }),
),
role: "external_envelope",
material: "K&J D42 dimensional envelope; externally installed",
explodeZ: -25,
});
parts.push(
printed(
`RemoteMagnetInsulatingCap${x < 0 ? "Left" : "Right"}`,
colored(
face,
cylinder({ x, y: 16, z: -0.75, radius: 3.3, height: 0.25 }),
),
-8,
),
);
parts.push(
printed(
`DockMagnetInsulatingCap${x < 0 ? "Left" : "Right"}`,
colored(
face,
cylinder({ x, y: 16, z: -6.3, radius: 3.3, height: 0.5 }),
),
-25,
),
);
}
for (const x of [-14, 14])
for (const y of [28, 56]) {
parts.push({
name: `PhoneMagnet${x < 0 ? "Left" : "Right"}${y}`,
plan: colored(
steel,
cylinder({ x, y, z: -11.6, radius: 3.175, height: 3.175 }),
),
role: "external_envelope",
material:
"K&J D42 dimensional envelope; original magnet design, not MagSafe certification",
explodeZ: -25,
});
parts.push(
printed(
`PhoneMagnetInsulatingCap${x < 0 ? "Left" : "Right"}${y}`,
colored(
face,
cylinder({ x, y, z: -12.0, radius: 3.3, height: 0.35 }),
),
-25,
),
);
}
return parts;
}
export function cutawayPart(plan: MechanicalPlan): MechanicalPlan {
return subtract(plan, box([45, 90, 35], [22.5, 0, 10]));
}