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("hole_width", "Opening inside width", 85.5, 20, 300), number("hole_length", "Opening inside length", 43.5, 20, 300), number("corner_radius", "Opening corner radius", 4, 0, 150), number("insertion_depth", "Depth below desktop", 18, 4, 60), number("flange_width", "Overlap beyond opening", 5, 3, 15), number("clearance", "Total width & length clearance", .6, .4, 2, .05), number("wall", "Sleeve wall thickness", 2.4, 2, 5), number("flange_thickness", "Flange thickness", 2.4, 1.6, 5), ]; export const GROMMET_SEAM_MM = .4; export const GROMMET_PRINT_GAP_MM = 4; export const SPLIT_DESK_GROMMET: CustomizerModel = { id: "split_desk_grommet", slug: "custom-split-desk-grommet", name: "Custom split desk grommet", description: "Line an unusual desk cable opening without recutting the furniture. Match its width, length, corner radius and available depth. Two removable halves fit around connected cables. One matched pair per quantity, in PETG; open center, no lid or latch. Physical print and fit testing are pending.", sourcePath: "src/lib/customizer/cad/split-desk-grommet.ts", sourceUrl: "https://3dprinterverse.com/designs/split-desk-grommet", geometryMode: "browser-csg", sourceKind: "store-original", family: "measured-desk-repairs", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-split-desk-grommet.stl", imagePath: "/images/galaxy/custom-split-desk-grommet.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["PETG"], infillPct: 100, priceCentsByMaterial: { PETG: 1400 }, presets: [ { id: "legacy-slot", label: "Small rectangle · 85.5 × 43.5 mm", parameters: {}, description: "Example measured opening, 4 mm corner radius. Measure your own desk; no furniture-model fit is assumed." }, { id: "long-slot", label: "Long rectangle · 154 × 52 mm", parameters: { hole_width: 154, hole_length: 52, corner_radius: 8, insertion_depth: 25 } }, { id: "oval", label: "Obround · 95 × 45 mm", parameters: { hole_width: 95, hole_length: 45, corner_radius: 22.5, insertion_depth: 20 }, description: "Straight sides with semicircular ends. This does not fit an ellipse." }, { id: "round", label: "Round · 63.5 mm diameter", parameters: { hole_width: 63.5, hole_length: 63.5, corner_radius: 31.75, insertion_depth: 16 }, description: "For a round opening, set both dimensions to the measured diameter and radius to half that diameter." }, ], }; export function grommetValues(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) throw new Error(`Choose supported ${p.label.toLowerCase()}.`); values[p.key] = value; } return values as Record<"hole_width" | "hole_length" | "corner_radius" | "insertion_depth" | "flange_width" | "clearance" | "wall" | "flange_thickness", number>; } export function grommetDimensions(input: ParameterValues) { const p = grommetValues(input); const sleeve = { x: p.hole_width - p.clearance, y: p.hole_length - p.clearance, radius: Math.max(0, p.corner_radius - p.clearance / 2) }; const opening = { x: sleeve.x - 2 * p.wall, y: sleeve.y - 2 * p.wall, radius: Math.max(0, sleeve.radius - p.wall) }; const flange = { x: p.hole_width + 2 * p.flange_width, y: p.hole_length + 2 * p.flange_width, radius: p.corner_radius + p.flange_width }; const installed = { x: flange.x, y: flange.y, z: p.insertion_depth + p.flange_thickness }; // Both halves are supplied flat, with 4 mm between them, in the same STL. const half = grommetHalf(grommetOutline(flange.x, flange.y, flange.radius)); const bbox = { ...installed, x: Math.max(...half.map(point => point[0])) - Math.min(...half.map(point => point[0])), y: installed.y + GROMMET_PRINT_GAP_MM - GROMMET_SEAM_MM }; return { sleeve, opening, flange, installed, bbox }; } export function validateGrommet(input: ParameterValues) { const p = grommetValues(input), d = grommetDimensions(input); if (p.corner_radius > Math.min(p.hole_width, p.hole_length) / 2 + 1e-8) throw new Error("Corner radius cannot exceed half the smaller opening dimension. An obround has semicircular ends; an ellipse needs a different outline."); if (Math.min(d.opening.x, d.opening.y) < 10 - 1e-8) throw new Error("Leave at least 10 mm of clear cable opening. Reduce sleeve wall thickness or increase the measured opening."); if (Object.values(d.bbox).some(value => value > LIMITS.maxDimensionMm + 1e-6)) throw new Error(`The two-half print layout exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Reduce the measured opening or flange overlap.`); } type Point = [number, number]; /** Inscribed facets stay within the measured hole. The inward clearance exceeds * the maximum 32-segment-per-quadrant chord error over the supported radii. * The same polygons drive the solid, displayed sizes and exact pricing metrics. */ export function grommetOutline(x: number, y: number, radius: number): Point[] { if (radius < 1e-8) return [[x / 2, y / 2], [-x / 2, y / 2], [-x / 2, -y / 2], [x / 2, -y / 2]]; const centers: Point[] = [[x / 2 - radius, y / 2 - radius], [-x / 2 + radius, y / 2 - radius], [-x / 2 + radius, -y / 2 + radius], [x / 2 - radius, -y / 2 + radius]]; const points: Point[] = []; for (let corner = 0; corner < 4; corner++) for (let i = 0; i <= 32; i++) { const angle = (corner + i / 32) * Math.PI / 2; const point: Point = [centers[corner][0] + radius * Math.cos(angle), centers[corner][1] + radius * Math.sin(angle)]; 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; } /** Convex outline clipped at the positive half of the assembly seam. */ export function grommetHalf(points: Point[]): Point[] { const cut = GROMMET_SEAM_MM / 2, result: Point[] = []; for (let i = 0; i < points.length; i++) { const a = points[i], b = points[(i + 1) % points.length], insideA = a[1] >= cut, insideB = b[1] >= cut; if (insideA) result.push(a); if (insideA !== insideB) result.push([a[0] + (b[0] - a[0]) * (cut - a[1]) / (b[1] - a[1]), cut]); } return result; } function polygonMetrics(points: Point[]) { let twiceArea = 0, perimeter = 0, seamLength = 0; for (let i = 0; i < points.length; i++) { const a = points[i], b = points[(i + 1) % points.length], length = Math.hypot(b[0] - a[0], b[1] - a[1]); twiceArea += a[0] * b[1] - b[0] * a[1]; perimeter += length; if (Math.abs(a[1] - GROMMET_SEAM_MM / 2) < 1e-8 && Math.abs(b[1] - GROMMET_SEAM_MM / 2) < 1e-8) seamLength += length; } return { area: Math.abs(twiceArea) / 2, perimeter, seamLength }; } export function grommetProfiles(input: ParameterValues) { const d = grommetDimensions(input); const half = (outline: { x: number; y: number; radius: number }) => grommetHalf(grommetOutline(outline.x, outline.y, outline.radius)); return { flange: half(d.flange), sleeve: half(d.sleeve), opening: half(d.opening) }; } /** Layered ring extrusions; the sleeve/flange contact surfaces are internal. */ export function grommetMetrics(input: ParameterValues) { const p = grommetValues(input), profiles = grommetProfiles(input); const outer = polygonMetrics(profiles.flange), sleeve = polygonMetrics(profiles.sleeve), inner = polygonMetrics(profiles.opening); const flangeArea = outer.area - inner.area, sleeveArea = sleeve.area - inner.area; const flangePerimeter = outer.perimeter + inner.perimeter - 2 * inner.seamLength; const sleevePerimeter = sleeve.perimeter + inner.perimeter - 2 * inner.seamLength; return { volumeMm3: 2 * (flangeArea * p.flange_thickness + sleeveArea * p.insertion_depth), surfaceAreaMm2: 2 * (2 * flangeArea + flangePerimeter * p.flange_thickness + sleevePerimeter * p.insertion_depth), bbox: grommetDimensions(input).bbox }; } export function grommetPrintability(input: ParameterValues) { return { fits: Object.values(grommetDimensions(input).bbox).every(value => value <= LIMITS.maxDimensionMm + 1e-6), instruction: "Print both halves with their broad flange faces on the bed and sleeve walls upward. PETG, 100% infill; no supports. The preview and STL show the print layout with a 4 mm gap.", notes: "One pair per quantity. Place the halves around the cables, then turn flange-up and lower into the desk opening. Leave the 0.4 mm assembly seam; no latch or lid. Physical print and fit testing are pending." }; }