AnasSarkiz/usb-c-pd-brushed-motor
USB-C PD-powered motor controller with protected input, selectable 5/9/12 V buck supply, MCU qualification, PWM H-bridge drive, overvoltage sensing, regenerative energy-dump resistors, and safety interlocks.
- Version
- 0.0.1-a48-cloud-pipeline9-unrouted-c41d79a-305b1e7a
- License
- unset
- Stars
- 0
scripts/plan-product-placement.ts
import { readFile, writeFile } from "node:fs/promises"
import { applyToPoint, rotateDEG } from "transformation-matrix"
import { z } from "zod"
type Reference = string
interface Placement {
x: number
y: number
ccwRotationDegrees: number
}
interface Bounds {
left: number
right: number
bottom: number
top: number
}
const inventory = z
.array(
z.object({
ref: z.string(),
sheet: z.string(),
width: z.number(),
height: z.number(),
courtyard: z.array(
z.object({
outline: z.array(z.object({ x: z.number(), y: z.number() })),
}),
),
}),
)
.parse(
JSON.parse(await readFile("evidence/placement-inventory-A22.json", "utf8")),
)
const manifest = z
.array(z.object({ ref: z.string(), pins: z.record(z.string(), z.string()) }))
.parse(JSON.parse(await readFile("docs/design-manifest.json", "utf8")))
// Board-level placement only. Official supplier components remain unchanged.
const fixed: Record<Reference, Placement> = {
TP1: { x: -28.1, y: -3, ccwRotationDegrees: 0 },
TP2: { x: -28.1, y: -7, ccwRotationDegrees: 0 },
TP3: { x: -28.1, y: -11, ccwRotationDegrees: 0 },
TP4: { x: -23, y: -12.5, ccwRotationDegrees: 0 },
TP5: { x: -20, y: 18, ccwRotationDegrees: 90 },
TP6: { x: -20, y: 12.5, ccwRotationDegrees: 90 },
TP7: { x: 20, y: 12, ccwRotationDegrees: 90 },
C29: { x: 25, y: 11, ccwRotationDegrees: 0 },
C28: { x: 27.5, y: 8, ccwRotationDegrees: 0 },
C27: { x: 27.5, y: 5.5, ccwRotationDegrees: 0 },
C26: { x: 23.5, y: 5.5, ccwRotationDegrees: 0 },
C22: { x: 2, y: 18, ccwRotationDegrees: 90 },
C23: { x: 3, y: 14.2, ccwRotationDegrees: 0 },
C34: { x: -8.4, y: 2, ccwRotationDegrees: 0 },
R67: { x: -12, y: 2, ccwRotationDegrees: 0 },
R66: { x: -12, y: 0, ccwRotationDegrees: 0 },
C33: { x: -10, y: -7.2, ccwRotationDegrees: 0 },
R61: { x: -10.5, y: -4.8, ccwRotationDegrees: 0 },
R60: { x: -11.5, y: -2, ccwRotationDegrees: 0 },
C32: { x: -14.5, y: -3.8, ccwRotationDegrees: 90 },
C31: { x: -14.2, y: -10.7, ccwRotationDegrees: 90 },
C30: { x: -13.8, y: -7, ccwRotationDegrees: 0 },
C15: { x: -6.7, y: -13.5, ccwRotationDegrees: 0 },
C12: { x: -3.2, y: -16, ccwRotationDegrees: 90 },
J1: { x: -34, y: 4, ccwRotationDegrees: -90 },
J2: { x: 34, y: 8, ccwRotationDegrees: 90 },
RV1: { x: -9, y: 17, ccwRotationDegrees: 0 },
SW1: { x: 10, y: 21, ccwRotationDegrees: 0 },
SW2: { x: -23, y: 24, ccwRotationDegrees: 90 },
U1: { x: -26, y: 4, ccwRotationDegrees: 0 },
U2: { x: -32, y: 15, ccwRotationDegrees: 90 },
U3: { x: -25, y: 15, ccwRotationDegrees: 90 },
U11: { x: -20, y: -6, ccwRotationDegrees: 90 },
U4: { x: -22, y: -19, ccwRotationDegrees: 0 },
U5: { x: -8.7, y: -19, ccwRotationDegrees: 180 },
U6: { x: 3, y: 9, ccwRotationDegrees: 0 },
U8: { x: 13, y: -3, ccwRotationDegrees: 0 },
U9: { x: 13, y: 5, ccwRotationDegrees: 0 },
U10: { x: 25, y: 0, ccwRotationDegrees: 0 },
L1: { x: 5.3, y: -18.5, ccwRotationDegrees: 0 },
C18: { x: 23, y: -14, ccwRotationDegrees: 0 },
D1: { x: -33, y: -7, ccwRotationDegrees: 90 },
D4: { x: -32, y: -17, ccwRotationDegrees: 90 },
D5: { x: -3.7, y: -22.5, ccwRotationDegrees: 90 },
D10: { x: 31, y: 18, ccwRotationDegrees: 90 },
D11: { x: 33, y: -3, ccwRotationDegrees: 90 },
C13: { x: -9.8, y: -10.4, ccwRotationDegrees: 0 },
C14: { x: -13.5, y: -19, ccwRotationDegrees: 90 },
Q3: { x: 33, y: -19, ccwRotationDegrees: 0 },
R45: { x: 17, y: -29, ccwRotationDegrees: 0 },
R46: { x: 26.5, y: -29, ccwRotationDegrees: 0 },
R47: { x: 17, y: -24, ccwRotationDegrees: 0 },
R48: { x: 26.5, y: -24, ccwRotationDegrees: 0 },
R69: { x: -22, y: -29, ccwRotationDegrees: 0 },
R70: { x: -12.5, y: -29, ccwRotationDegrees: 0 },
R71: { x: -2, y: -29, ccwRotationDegrees: 0 },
R72: { x: 7.5, y: -29, ccwRotationDegrees: 0 },
LED1: { x: 25, y: 24, ccwRotationDegrees: 0 },
LED2: { x: 20, y: 24, ccwRotationDegrees: 0 },
LED3: { x: 20, y: 19, ccwRotationDegrees: 0 },
}
const clusterCenters: Record<string, { x: number; y: number }> = {
usb: { x: -27, y: 8 },
pdhost: { x: -19, y: -4 },
input: { x: -24, y: -19 },
buck: { x: -5, y: -18 },
controls: { x: -1, y: 9 },
protection: { x: 12, y: 3 },
motor: { x: 25, y: 2 },
dump: { x: 24, y: -23 },
}
function componentBounds(reference: Reference, placement: Placement): Bounds {
const part = inventory.find((entry) => entry.ref === reference)
if (!part) throw new Error(`Missing inventory: ${reference}`)
const points = part.courtyard.flatMap((court) => court.outline)
points.push(
...[
{ x: -part.width / 2, y: -part.height / 2 },
{ x: part.width / 2, y: part.height / 2 },
],
)
const componentToBoardRotation = rotateDEG(placement.ccwRotationDegrees)
const rotated = points.map((point) =>
applyToPoint(componentToBoardRotation, point),
)
return {
left: Math.min(...rotated.map((point) => point.x)) + placement.x,
right: Math.max(...rotated.map((point) => point.x)) + placement.x,
bottom: Math.min(...rotated.map((point) => point.y)) + placement.y,
top: Math.max(...rotated.map((point) => point.y)) + placement.y,
}
}
function overlaps(first: Bounds, second: Bounds) {
const gapMm = 0.45
return (
first.left < second.right + gapMm &&
first.right + gapMm > second.left &&
first.bottom < second.top + gapMm &&
first.top + gapMm > second.bottom
)
}
const occupied = new Map<Reference, Bounds>()
for (const [reference, placement] of Object.entries(fixed)) {
const bounds = componentBounds(reference, placement)
for (const [otherReference, otherBounds] of occupied) {
if (overlaps(bounds, otherBounds))
throw new Error(
`Fixed overlap: ${reference}/${otherReference} ${JSON.stringify(bounds)} ${JSON.stringify(otherBounds)}`,
)
}
occupied.set(reference, bounds)
}
const mechanicalReservations: Bounds[] = [
// Native front-panel and probe legends, board-space mm (+Y toward top).
// Conservative text envelopes; final generated ink is separately reviewed.
{ left: -39, right: -21.5, bottom: 10.3, top: 12.1 },
{ left: -32, right: -14, bottom: 28.9, top: 30.7 },
{ left: -12, right: -6, bottom: 29.5, top: 30.7 },
{ left: 1, right: 19, bottom: 27.7, top: 29.5 },
{ left: -15, right: 15, bottom: 31, top: 32.2 },
{ left: -32.8, right: -23.4, bottom: -1.85, top: -0.65 },
{ left: -32.8, right: -23.4, bottom: -5.85, top: -4.65 },
{ left: -32.8, right: -23.4, bottom: -9.85, top: -8.65 },
{ left: -26.6, right: -19.4, bottom: -11.35, top: -10.15 },
{ left: -26.5, right: -18.5, bottom: 20.4, top: 21.6 },
{ left: -26.5, right: -18.5, bottom: 9.2, top: 10.4 },
{ left: 16.5, right: 23.5, bottom: 14.4, top: 15.6 },
{ left: 33, right: 39, bottom: 14.2, top: 15.4 },
{ left: -31, right: -28, bottom: 24.2, top: 25.8 },
{ left: -31, right: -28, bottom: 22.2, top: 23.8 },
{ left: 22, right: 28, bottom: 25.8, top: 27 },
{ left: 18, right: 22, bottom: 25.8, top: 27 },
{ left: 18, right: 22, bottom: 16.4, top: 17.6 },
// Shaft centre is offset +3.94 mm from the imported pot placement centre.
{ left: -17, right: -1, bottom: 12.94, top: 28.94 },
{ left: 4, right: 16, bottom: 15, top: 27 },
...[-35, 35].flatMap((x) =>
[-27.5, 27.5].map((y) => ({
left: x - 3.5,
right: x + 3.5,
bottom: y - 3.5,
top: y + 3.5,
})),
),
]
const unplaced = inventory
.filter((part) => !fixed[part.ref])
.sort(
(first, second) =>
second.width * second.height - first.width * first.height,
)
for (const part of unplaced) {
const centre = clusterCenters[part.sheet]
const nets = new Set(
Object.values(
manifest.find((entry) => entry.ref === part.ref)?.pins ?? {},
).filter(
(net) =>
![
"GND",
"VM",
"VCC3V3",
"VDD5",
"VBUS",
"EFUSE_IN",
"VIN_BUCK",
].includes(net),
),
)
const neighbors = manifest.filter(
(entry) =>
fixed[entry.ref] &&
Object.values(entry.pins).some((net) => nets.has(net)),
)
let best: { placement: Placement; score: number; bounds: Bounds } | undefined
for (const ccwRotationDegrees of [0, 90]) {
for (let x = -36; x <= 36; x += 1) {
for (let y = -29; y <= 29; y += 1) {
const placement = { x, y, ccwRotationDegrees }
const bounds = componentBounds(part.ref, placement)
if (
bounds.left < -39 ||
bounds.right > 39 ||
bounds.bottom < -31.5 ||
bounds.top > 31.5 ||
mechanicalReservations.some((reservation) =>
overlaps(bounds, reservation),
) ||
[...occupied.values()].some((otherBounds) =>
overlaps(bounds, otherBounds),
)
)
continue
const score =
0.35 * Math.hypot(x - centre.x, y - centre.y) +
neighbors.reduce(
(sum, entry) =>
sum + Math.hypot(x - fixed[entry.ref].x, y - fixed[entry.ref].y),
0,
) /
Math.max(neighbors.length, 1)
if (!best || score < best.score) best = { placement, score, bounds }
}
}
}
if (!best) throw new Error(`No mechanically clear location for ${part.ref}`)
fixed[part.ref] = best.placement
occupied.set(part.ref, best.bounds)
}
for (const reference of [
"C8",
"C4",
"C5",
"R2",
"R3",
"R66",
"R6",
"R10",
"R16",
"C12",
"C15",
"R24",
"R33",
"R51",
"C28",
"Q5",
"C25",
"R50",
"R5",
"R11",
"R13",
"C11",
"R21",
"D7",
"R39",
"R49",
"R19",
"R23",
"R41",
"R42",
"R43",
"R54",
]) {
fixed[reference].ccwRotationDegrees =
(fixed[reference].ccwRotationDegrees + 180) % 360
}
await writeFile(
"circuit/product-placement.ts",
`// A22: numeric millimetre centres through native manualEdits; official imports untouched.\n` +
`export const productPlacement = ${JSON.stringify(fixed, null, 2)}\n\n` +
`export const pcbPlacements = Object.entries(productPlacement).map(([selector, placement]) => ({\n` +
` selector, center: { x: placement.x, y: placement.y },\n}))\n`,
)
console.log(
`Planned ${occupied.size} components; 80 × 65 mm outline, four mounting keepouts.`,
)