import type { CustomizerModel, CustomizerParameter, ParameterValues } from "./catalog"; import { LIMITS } from "@/config/business"; const number = (key: string, label: string, value: number, min: number, max: number, step = .1): CustomizerParameter => ({ key, label, default: value, min, max, step, kind: "number", unit: "mm" }); const list = (key: string, label: string, value: number[], min: number, max: number, minItems = 1): CustomizerParameter => ({ key, label, default: value, min, max, step: .1, kind: "list", unit: "mm", minItems, maxItems: 6 }); const parameters: CustomizerParameter[] = [ number("width", "Finished panel width", 125, 40, 260), number("height", "Finished panel height", 72, 30, 260), list("opening_widths", "Opening widths in order", [42, 8], 3, 200), list("opening_heights", "Opening heights in order", [18, 8], 3, 200), list("opening_x", "Opening center X positions", [43, 91], 0, 260), list("opening_y", "Opening center Y positions", [36, 36], 0, 260), list("opening_radii", "Opening corner radii in order", [1, 4], 0, 100), list("screw_x", "Screw center X positions", [8, 117, 8, 117], 0, 260, 2), list("screw_y", "Screw center Y positions", [8, 8, 64, 64], 0, 260, 2), number("thickness", "Panel thickness", 3, 2.4, 6), number("corner_radius", "Panel corner radius", 3, 0, 40), number("opening_allowance", "Total opening fit allowance", .3, 0, 1.2, .05), number("body_overhang", "Largest connector overhang per side", 3, 2, 15), number("screw_diameter", "Finished screw bore diameter", 3.4, 2.5, 6.5), number("head_diameter", "Largest screw head or washer diameter", 6.5, 5, 14), ]; export const PROJECT_PANEL: CustomizerModel = { id: "project_panel", slug: "custom-project-box-panel", name: "Custom project-box panel", description: "Replace a flat screw-mounted hobby enclosure panel with your own measured display, socket and control openings. Match the outside outline, one to six rounded-rectangle or round openings, and two to six existing screw centers. One PETG panel per quantity; enclosure, electronics and hardware are separate. Physical print and fit tests are pending; presets are measurement examples, without device-fit claims.", sourcePath: "src/lib/customizer/cad/project-panel.ts", sourceUrl: "https://3dprinterverse.com/designs/project-panel", sourceKind: "store-original", geometryMode: "browser-csg", family: "measured-electronics", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-project-box-panel.stl", imagePath: "/images/galaxy/custom-project-box-panel.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["PETG"], infillPct: 100, priceCentsByMaterial: { PETG: 1600 }, presets: [ { id: "display-control", label: "Display & round control · 125 × 72", parameters: {}, description: "A measured display opening and one round control. Check the entire module, retaining nut and thread length; this is not a named-device fit." }, { id: "round-strip", label: "Three round ports · 96 × 42", parameters: { width: 96, height: 42, opening_widths: [8, 12, 8], opening_heights: [8, 12, 8], opening_radii: [4, 6, 4], opening_x: [25, 48, 71], opening_y: [21, 21, 21], screw_x: [7, 89], screw_y: [21, 21], head_diameter: 6, body_overhang: 2.5 } }, { id: "wide-display", label: "Wide display & two ports · 180 × 82", parameters: { width: 180, height: 82, opening_widths: [64, 10, 16], opening_heights: [26, 10, 16], opening_radii: [1.5, 5, 8], opening_x: [58, 116, 151], opening_y: [41, 41, 41], screw_x: [9, 171, 9, 171], screw_y: [9, 9, 73, 73], thickness: 3.6, body_overhang: 4 } }, { id: "staggered-repair", label: "Staggered replacement · 112 × 64", parameters: { width: 112, height: 64, opening_widths: [28, 13], opening_heights: [15, 9], opening_radii: [1, 2], opening_x: [36, 78], opening_y: [32, 34], screw_x: [9, 103, 94], screw_y: [10, 14, 55], corner_radius: 5 }, description: "Three independently measured existing screws. View from outside and keep the same bottom-left origin for every opening and screw." }, ], }; export type PanelPoint = [number, number]; type PanelScalars = Record<"width" | "height" | "thickness" | "corner_radius" | "opening_allowance" | "body_overhang" | "screw_diameter" | "head_diameter", number>; type PanelLists = Record<"opening_widths" | "opening_heights" | "opening_x" | "opening_y" | "opening_radii" | "screw_x" | "screw_y", number[]>; export function panelValues(input: ParameterValues) { const values: ParameterValues = {}; for (const p of parameters) { const value = input[p.key] ?? p.default; if (p.kind === "list" && (!Array.isArray(value) || value.length < (p.minItems ?? 1) || value.length > 6) || p.kind === "number" && Array.isArray(value) || (Array.isArray(value) ? value : [value]).some(v => typeof v !== "number" || !Number.isFinite(v) || v < p.min || v > p.max)) { throw new Error(`Enter supported ${p.label.toLowerCase()}.`); } values[p.key] = Array.isArray(value) ? [...value] : value; } return values as PanelScalars & PanelLists; } /** Exterior arcs stay inside the measured outline. Opening tangent facets * enclose the requested curve, including the stated normal fit allowance. */ export function panelOutline(width: number, height: number, radius: number, tangent = false): PanelPoint[] { if (!radius) return [[-width / 2, -height / 2], [width / 2, -height / 2], [width / 2, height / 2], [-width / 2, height / 2]]; if (tangent) { const support = (a: number) => (width / 2 - radius) * Math.abs(Math.cos(a)) + (height / 2 - radius) * Math.abs(Math.sin(a)) + radius; return Array.from({ length: 96 }, (_, i) => { const a = i * Math.PI / 48, b = (i + 1) * Math.PI / 48, u = support(a), v = support(b), det = Math.sin(b - a); return [(u * Math.sin(b) - v * Math.sin(a)) / det, (v * Math.cos(a) - u * Math.cos(b)) / det]; }); } const points: PanelPoint[] = []; const corners = [[width / 2 - radius, height / 2 - radius], [-width / 2 + radius, height / 2 - radius], [-width / 2 + radius, -height / 2 + radius], [width / 2 - radius, -height / 2 + radius]]; corners.forEach(([x, y], corner) => { for (let i = 0; i <= 24; i++) { const a = (corner + i / 24) * Math.PI / 2; const point: PanelPoint = [x + radius * Math.cos(a), y + radius * Math.sin(a)]; if (!points.length || Math.hypot(point[0] - points.at(-1)![0], point[1] - points.at(-1)![1]) > 1e-8) points.push(point); } }); if (Math.hypot(points[0][0] - points.at(-1)![0], points[0][1] - points.at(-1)![1]) < 1e-8) points.pop(); return points; } const translate = (points: PanelPoint[], x: number, y: number): PanelPoint[] => points.map(p => [p[0] + x, p[1] + y]); export function panelDimensions(input: ParameterValues) { const p = panelValues(input), allowance = p.opening_allowance; const outline = translate(panelOutline(p.width, p.height, p.corner_radius), p.width / 2, p.height / 2); const openings = p.opening_widths.map((w, i) => { const width = w + allowance, height = p.opening_heights[i] + allowance, radius = p.opening_radii[i] + allowance / 2; const x = p.opening_x[i], y = p.opening_y[i]; return { x, y, width, height, radius, outline: translate(panelOutline(width, height, radius, true), x, y), keepout: translate(panelOutline(width + 2 * p.body_overhang, height + 2 * p.body_overhang, radius + p.body_overhang, true), x, y) }; }); const screws = p.screw_x.map((x, i) => ({ x, y: p.screw_y[i] })); return { outline, openings, screws, bbox: { x: p.width, y: p.height, z: p.thickness } }; } // All outlines are convex and counterclockwise. Positive = inside the edge. function inwardDistance(point: PanelPoint, outline: PanelPoint[]) { return Math.min(...outline.map((a, i) => { const b = outline[(i + 1) % outline.length], dx = b[0] - a[0], dy = b[1] - a[1]; return (dx * (point[1] - a[1]) - dy * (point[0] - a[0])) / Math.hypot(dx, dy); })); } function pointDistance(point: PanelPoint, outline: PanelPoint[]) { if (inwardDistance(point, outline) >= -1e-8) return 0; return Math.min(...outline.map((a, i) => { const b = outline[(i + 1) % outline.length], dx = b[0] - a[0], dy = b[1] - a[1]; const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / (dx * dx + dy * dy))); return Math.hypot(point[0] - a[0] - t * dx, point[1] - a[1] - t * dy); })); } function polygonDistance(a: PanelPoint[], b: PanelPoint[]) { // Separating axes detect crossing edges even when neither contains a vertex. const separated = [a, b].some(outline => outline.some((v, i) => { const next = outline[(i + 1) % outline.length], nx = next[1] - v[1], ny = v[0] - next[0]; const pa = a.map(p => p[0] * nx + p[1] * ny), pb = b.map(p => p[0] * nx + p[1] * ny); return Math.max(...pa) < Math.min(...pb) - 1e-8 || Math.max(...pb) < Math.min(...pa) - 1e-8; })); return separated ? Math.min(...a.map(p => pointDistance(p, b)), ...b.map(p => pointDistance(p, a))) : 0; } export function validatePanel(input: ParameterValues) { const p = panelValues(input), count = p.opening_widths.length; if ([p.width, p.height, p.thickness].some(v => v > LIMITS.maxDimensionMm + 1e-8)) throw new Error(`The complete panel exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Reduce its width or height.`); if (p.corner_radius > Math.min(p.width, p.height) / 2) throw new Error("The panel corner radius cannot exceed half its shorter side."); if ([p.opening_heights, p.opening_x, p.opening_y, p.opening_radii].some(list => list.length !== count)) throw new Error("Use the same number of widths, heights, radii, X and Y positions for the openings, in matching order."); if (p.screw_x.length !== p.screw_y.length) throw new Error("Use matching X and Y lists for the two to six screw centers."); if (p.head_diameter < p.screw_diameter + 2) throw new Error("The screw head or washer must be at least 2 mm wider than its bore."); if (p.opening_radii.some((r, i) => r > Math.min(p.opening_widths[i], p.opening_heights[i]) / 2 + 1e-9)) throw new Error("Each opening corner radius must be at most half its smaller width or height. For a circle, use equal spans and half that diameter as the radius."); const d = panelDimensions(p); for (const [i, opening] of d.openings.entries()) { if (opening.keepout.some(v => inwardDistance(v, d.outline) < 2 - 1e-7)) throw new Error(`Opening ${i + 1} and its connector overhang need 2 mm of material to every panel edge, including rounded corners.`); for (const other of d.openings.slice(0, i)) if (polygonDistance(opening.keepout, other.keepout) < 1 - 1e-7) throw new Error("Separate the opening centers so connector body/retainer envelopes have at least 1 mm between them."); } for (const [i, screw] of d.screws.entries()) { const point: PanelPoint = [screw.x, screw.y]; if (inwardDistance(point, d.outline) < Math.max(p.screw_diameter / 2 + 3, p.head_diameter / 2 + 2) - 1e-7) throw new Error(`Screw ${i + 1} is too close to the panel edge or a rounded corner. Leave 3 mm around the bore and 2 mm around the head/washer.`); if (d.openings.some(o => pointDistance(point, o.keepout) < p.head_diameter / 2 + 1 - 1e-7)) throw new Error(`Screw ${i + 1} head/washer overlaps a connector envelope. Move it or reduce the measured opening.`); for (const other of d.screws.slice(0, i)) if (Math.hypot(screw.x - other.x, screw.y - other.y) < p.head_diameter + 2 - 1e-7) throw new Error("Separate the screw centers to leave 2 mm between heads/washers; duplicate screws are not supported."); } } const area = (outline: PanelPoint[]) => Math.abs(outline.reduce((sum, a, i) => { const b = outline[(i + 1) % outline.length]; return sum + a[0] * b[1] - b[0] * a[1]; }, 0)) / 2; const perimeter = (outline: PanelPoint[]) => outline.reduce((sum, a, i) => { const b = outline[(i + 1) % outline.length]; return sum + Math.hypot(a[0] - b[0], a[1] - b[1]); }, 0); export function panelMetrics(input: ParameterValues) { const p = panelValues(input), d = panelDimensions(p); const hole = panelOutline(p.screw_diameter, p.screw_diameter, p.screw_diameter / 2, true); const face = area(d.outline) - d.openings.reduce((sum, o) => sum + area(o.outline), 0) - d.screws.length * area(hole); return { volumeMm3: face * p.thickness, surfaceAreaMm2: 2 * face + p.thickness * (perimeter(d.outline) + d.openings.reduce((sum, o) => sum + perimeter(o.outline), 0) + d.screws.length * perimeter(hole)), bbox: d.bbox }; } export function panelPrintability(input: ParameterValues) { const p = panelValues(input); return { fits: Object.values(panelDimensions(p).bbox).every(v => v <= LIMITS.maxDimensionMm), instruction: "Print one panel flat on a broad face, in PETG at 100% infill with a 0.4 mm nozzle; all openings pass vertically through the plate. No supports.", notes: "Low-voltage indoor hobby enclosures only. Check connector bodies, retaining nuts, thread reach and enclosure clearance on both sides. Flat pan-head mounting; no countersinks, clips, sealing or electrical/fire rating. Physical print and fit tests are pending." }; }