import { LIMITS } from "@/config/business"; import type { CustomizerModel, CustomizerParameter, ParameterValues } from "./catalog"; const number = (key: string, label: string, value: number, min: number, max: number, step = .1): CustomizerParameter => ({ key, label, default: value, min, max, step, unit: "mm", kind: "number" }); const parameters: CustomizerParameter[] = [ number("plate_width", "Flange width", 110, 50, 300), number("plate_length", "Flange length", 90, 50, 300), number("hole_pitch_x", "Horizontal screw center spacing", 86, 15, 270), number("hole_pitch_y", "Vertical screw center spacing", 66, 15, 270), number("hose_diameter", "Measured hose cuff diameter", 50, 20, 180), { key: "fit", label: "Hose connection", default: 0, min: 0, max: 1, step: 1, integer: true, unit: "", kind: "number", choices: [{ value: 0, label: "Socket over cuff · measure its outside" }, { value: 1, label: "Spigot inside cuff · measure its inside" }] }, number("clearance", "Total diameter clearance", .4, .25, 1.5, .05), number("engagement", "Straight hose engagement", 32, 15, 80), number("offset_x", "Port center offset right", 0, -70, 70), number("offset_y", "Port center offset up", 0, -70, 70), number("screw_diameter", "Screw through-hole diameter", 5, 3, 8), number("head_diameter", "Screw head or washer diameter", 10, 6, 20), number("wall", "Hose wall thickness", 3, 2.4, 5), number("plate_thickness", "Flange thickness", 4, 3, 8), number("corner_radius", "Flange corner radius", 6, 0, 30), ]; export const BOLT_ON_DUST_PORT: CustomizerModel = { id: "bolt_on_dust_port", slug: "custom-bolt-on-dust-port", name: "Custom bolt-on dust port", description: "Replace an odd-size screw-mounted dust port or fit a workshop dust box. Match a rectangular four-hole pattern, flat flange and measured hose cuff; offset the port center when space is tight. One PETG port per quantity. Hardware and gasket are separate. Physical print, fit and airflow testing are pending.", sourcePath: "src/lib/customizer/cad/bolt-on-dust-port.ts", sourceUrl: "https://3dprinterverse.com/designs/bolt-on-dust-port", geometryMode: "browser-csg", sourceKind: "store-original", family: "measured-dust-connections", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-bolt-on-dust-port.stl", imagePath: "/images/galaxy/custom-bolt-on-dust-port.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["PETG"], infillPct: 100, priceCentsByMaterial: { PETG: 1800 }, presets: [ { id: "medium-socket", label: "110 × 90 flange · 50 mm cuff", parameters: {}, description: "Measured example with an 86 × 66 mm four-hole pattern. Presets do not promise tool or brand compatibility." }, { id: "compact", label: "84 × 74 flange · 35 mm cuff", parameters: { plate_width: 84, plate_length: 74, hole_pitch_x: 64, hole_pitch_y: 54, hose_diameter: 35, screw_diameter: 4.5, head_diameter: 9, wall: 2.4, engagement: 25, corner_radius: 4 } }, { id: "offset", label: "Offset port · 63 mm cuff", parameters: { plate_width: 148, plate_length: 108, hole_pitch_x: 124, hole_pitch_y: 84, hose_diameter: 63, offset_x: 16, engagement: 35 } }, { id: "large-spigot", label: "145 × 145 flange · 100 mm inside cuff", parameters: { plate_width: 145, plate_length: 145, hole_pitch_x: 120, hole_pitch_y: 120, hose_diameter: 100, fit: 1, engagement: 40, screw_diameter: 6, head_diameter: 12 } }, ], }; export function dustPortValues(input: ParameterValues) { const values: Record = {}; for (const p of parameters) { const value = input[p.key] ?? p.default; if (typeof value !== "number" || !Number.isFinite(value) || value < p.min || value > p.max || (p.integer && !Number.isInteger(value))) throw new Error(`Choose supported ${p.label.toLowerCase()}.`); values[p.key] = value; } return values as Record<"plate_width" | "plate_length" | "hole_pitch_x" | "hole_pitch_y" | "hose_diameter" | "fit" | "clearance" | "engagement" | "offset_x" | "offset_y" | "screw_diameter" | "head_diameter" | "wall" | "plate_thickness" | "corner_radius", number>; } export type DustPortPoint = [number, number]; export const DUST_PORT_CIRCLE_SEGMENTS = 192; export function dustPortCircle(radius: number): DustPortPoint[] { return Array.from({ length: DUST_PORT_CIRCLE_SEGMENTS }, (_, i) => [radius * Math.cos(2 * Math.PI * i / DUST_PORT_CIRCLE_SEGMENTS), radius * Math.sin(2 * Math.PI * i / DUST_PORT_CIRCLE_SEGMENTS)]); } export function dustPortOutline(input: ParameterValues): DustPortPoint[] { const p = dustPortValues(input), x = p.plate_width / 2, y = p.plate_length / 2, r = p.corner_radius; if (!r) return [[x, y], [-x, y], [-x, -y], [x, -y]]; const centers = [[x - r, y - r], [-x + r, y - r], [-x + r, -y + r], [x - r, -y + r]]; return centers.flatMap(([cx, cy], corner) => Array.from({ length: 17 }, (_, i): DustPortPoint => { const angle = (corner + i / 16) * Math.PI / 2; return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)]; })).filter((point, i, all) => Math.hypot(point[0] - all[(i + all.length - 1) % all.length][0], point[1] - all[(i + all.length - 1) % all.length][1]) > 1e-8); } export function dustPortDimensions(input: ParameterValues) { const p = dustPortValues(input); const inner = p.fit === 0 ? p.hose_diameter + p.clearance : p.hose_diameter - p.clearance - 2 * p.wall; const outer = inner + 2 * p.wall; const holes: DustPortPoint[] = [-1, 1].flatMap(sx => [-1, 1].map(sy => [sx * p.hole_pitch_x / 2, sy * p.hole_pitch_y / 2] as DustPortPoint)); return { inner, outer, minimumBore: inner * Math.cos(Math.PI / DUST_PORT_CIRCLE_SEGMENTS), holes, bbox: { x: p.plate_width, y: p.plate_length, z: p.plate_thickness + p.engagement } }; } /** Distance to every edge of the actual convex faceted flange, positive inside. */ function edgeClearance(point: DustPortPoint, outline: DustPortPoint[]) { 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); })); } export function validateDustPort(input: ParameterValues) { const p = dustPortValues(input), d = dustPortDimensions(input); if (Object.values(d.bbox).some(value => value > LIMITS.maxDimensionMm + 1e-6)) throw new Error(`This port exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Reduce the flange or engagement.`); if (p.corner_radius > Math.min(p.plate_width, p.plate_length) / 2) throw new Error("Corner radius cannot exceed half the smaller flange dimension."); if (d.minimumBore < 15) throw new Error("Keep at least 15 mm of clear bore. Reduce wall thickness or increase cuff diameter."); if (p.head_diameter < p.screw_diameter + 2) throw new Error("Screw head or washer diameter must exceed the through-hole by at least 2 mm."); if (Math.min(p.hole_pitch_x, p.hole_pitch_y) < p.head_diameter + 3) throw new Error("Leave at least 3 mm between neighboring screw heads or washers."); const outline = dustPortOutline(input); if (edgeClearance([p.offset_x, p.offset_y], outline) < d.outer / 2 + 3 - 1e-7) throw new Error("Keep the hose wall at least 3 mm inside every flange edge. Reduce the port size or offset, or enlarge the flange."); for (const hole of d.holes) { if (edgeClearance(hole, outline) < Math.max(p.screw_diameter / 2 + 3, p.head_diameter / 2 + 1.5) - 1e-7) throw new Error("Move screw centers inward or enlarge the flange: keep 3 mm around each through-hole and 1.5 mm beyond its head or washer."); if (Math.hypot(hole[0] - p.offset_x, hole[1] - p.offset_y) < d.outer / 2 + p.head_diameter / 2 + 1.5 - 1e-7) throw new Error("Screw heads need 1.5 mm of clearance from the hose wall for straight tool access. Increase screw spacing, reduce the port or adjust its offset."); } } function metrics(points: DustPortPoint[]) { let twiceArea = 0, perimeter = 0; for (let i = 0; i < points.length; i++) { const a = points[i], b = points[(i + 1) % points.length]; twiceArea += a[0] * b[1] - b[0] * a[1]; perimeter += Math.hypot(b[0] - a[0], b[1] - a[1]); } return { area: Math.abs(twiceArea) / 2, perimeter }; } /** Exact polygon extrusion metrics; tube/flange contact faces are internal. */ export function dustPortMetrics(input: ParameterValues) { const p = dustPortValues(input), d = dustPortDimensions(input); const plate = metrics(dustPortOutline(input)), inner = metrics(dustPortCircle(d.inner / 2)); const outer = metrics(dustPortCircle(d.outer / 2)), screw = metrics(dustPortCircle(p.screw_diameter / 2)); const plateArea = plate.area - inner.area - 4 * screw.area; return { volumeMm3: plateArea * p.plate_thickness + (outer.area - inner.area) * p.engagement, surfaceAreaMm2: 2 * plateArea + (plate.perimeter + inner.perimeter + 4 * screw.perimeter) * p.plate_thickness + (outer.perimeter + inner.perimeter) * p.engagement, bbox: d.bbox }; } export function dustPortPrintability(input: ParameterValues) { return { fits: Object.values(dustPortDimensions(input).bbox).every(v => v <= LIMITS.maxDimensionMm + 1e-6), instruction: "Print with the flat flange on the bed and hose connection upward. PETG, 100% infill; no supports.", notes: "One port per quantity. Four through-holes; hardware and gasket are separate. Independently support the hose. Physical print, fit and airflow testing are pending." }; }