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("box_width", "Box recess inside width", 154, 40, 350), number("box_length", "Box recess inside length", 150, 50, 350), number("box_depth", "Depth below closed lid", 24, 12, 120), { key: "sleeve_widths", label: "Sleeve widths, left to right", default: [46, 46, 46], min: 30, max: 100, step: .1, unit: "mm", kind: "list", minItems: 1, maxItems: 5 }, number("sleeve_length", "Longest sleeve length", 70, 45, 180), { ...number("rows", "Rows of decks", 2, 1, 2, 1), unit: "", integer: true }, number("box_clearance", "Total width & length clearance", .8, .4, 3, .05), number("lid_clearance", "Space below lid", 2, 1, 8), number("sleeve_clearance", "Minimum sleeve clearance", 1, .6, 3, .05), number("wall", "Wall thickness", 2, 2, 4), number("floor", "Floor thickness", 1.6, 1.2, 3.2), number("notch_width", "Thumb cutout width", 22, 10, 44), number("notch_lip", "Floor lip at thumb openings", 0, 0, 12), ]; export const CARD_BOX_INSERT: CustomizerModel = { id: "card_box_insert", slug: "custom-card-box-insert", name: "Custom card-box insert", description: "Fit sleeved decks to a measured rectangular box recess. Enter each column’s sleeve width, the longest sleeve length and the depth below the closed lid. Unequal bays share any extra width evenly. One open insert per quantity; cards, sleeves, box and lid are separate. Physical print and fit testing are pending.", sourcePath: "src/lib/customizer/cad/card-box-insert.ts", sourceUrl: "https://3dprinterverse.com/designs/card-box-insert", geometryMode: "browser-csg", sourceKind: "store-original", family: "measured-card-storage", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-card-box-insert.stl", imagePath: "/images/galaxy/custom-card-box-insert.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["PLA", "PETG"], infillPct: 100, priceCentsByMaterial: { PLA: 1800, PETG: 2070 }, presets: [ { id: "six-mini", label: "Six mini decks · 154 × 150 mm recess", parameters: {}, description: "Example: three 46 mm sleeve columns, two rows of 70 mm sleeves. Measure your box and sleeves." }, { id: "four-long", label: "Four long decks · 124 × 176 mm recess", parameters: { box_width: 124, box_length: 176, box_depth: 30, sleeve_widths: [56, 56], sleeve_length: 82 }, description: "Example dimensions only; no game or edition fit is assumed." }, { id: "mixed", label: "Mixed widths · 44 / 58 / 67 mm sleeves", parameters: { box_width: 184, box_length: 98, box_depth: 30, sleeve_widths: [44, 58, 67], sleeve_length: 92, rows: 1 }, description: "Three unequal wells, all long enough for the longest sleeve. Left-to-right order is retained." }, { id: "single-mini", label: "One mini deck · 60 × 78 mm recess", parameters: { box_width: 60, box_length: 78, box_depth: 26, sleeve_widths: [46], sleeve_length: 70, rows: 1 } }, ], }; /** Both the server and worker reject malformed numbers before doing arithmetic. */ export function cardInsertValues(input: ParameterValues) { const p: Record = {}; for (const definition of parameters) { const raw = input[definition.key] ?? definition.default; const values = definition.kind === "list" ? raw : [raw]; if (!Array.isArray(values) || values.length < (definition.minItems ?? 1) || values.length > (definition.maxItems ?? 1) || values.some(v => typeof v !== "number" || !Number.isFinite(v) || v < definition.min || v > definition.max || (definition.integer && !Number.isInteger(v)))) { throw new Error(`Choose supported ${definition.label.toLowerCase()}.`); } if (definition.kind !== "list") p[definition.key] = raw as number; } return { ...p, sleeve_widths: [...(input.sleeve_widths ?? CARD_BOX_INSERT.defaults.sleeve_widths) as number[]] } as { sleeve_widths: number[] } & Record<"box_width" | "box_length" | "box_depth" | "rows" | "box_clearance" | "lid_clearance" | "sleeve_clearance" | "sleeve_length" | "wall" | "floor" | "notch_width" | "notch_lip", number>; } export function cardInsertDimensions(input: ParameterValues) { const p = cardInsertValues(input), count = p.sleeve_widths.length; const bbox = { x: p.box_width - p.box_clearance, y: p.box_length - p.box_clearance, z: p.box_depth - p.lid_clearance }; const spareWidth = bbox.x - (count + 1) * p.wall - p.sleeve_widths.reduce((a, b) => a + b + p.sleeve_clearance, 0); const widths = p.sleeve_widths.map(width => width + p.sleeve_clearance + spareWidth / count); let cursor = p.wall; const bays = widths.map(width => { const bay = { x: cursor, width }; cursor += width + p.wall; return bay; }); const bayLength = (bbox.y - (p.rows + 1) * p.wall) / p.rows; return { bbox, bays, bayLength, spareWidth, stackDepth: bbox.z - p.floor, notchBottom: p.floor + p.notch_lip, notchDepth: bbox.z - p.floor - p.notch_lip }; } export function validateCardInsert(input: ParameterValues) { const p = cardInsertValues(input), d = cardInsertDimensions(input); if (Object.values(d.bbox).some(value => value > LIMITS.maxDimensionMm + 1e-6)) { throw new Error(`This insert exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Use a smaller recess or a separate insert for part of the box.`); } if (d.spareWidth < -1e-8) throw new Error("The sleeve columns and walls do not fit this box width. Use fewer columns, narrower sleeves or thinner walls."); if (d.bayLength < p.sleeve_length + p.sleeve_clearance - 1e-8) throw new Error("The sleeves do not fit this box length. Use one row, shorter sleeves or thinner walls."); if (d.stackDepth < 8 - 1e-8 || d.notchDepth < 4 - 1e-8) throw new Error("Reduce the floor lip or lid clearance, or increase box depth, to leave 8 mm of stack depth and 4 mm of thumb access."); if (d.bays.some(bay => bay.width < p.notch_width + 8 - 1e-8)) throw new Error("Reduce thumb cutout width to leave at least 4 mm of wall on each side in every bay."); } /** A rounded U open at its top. The same faceted outline drives CSG and price; * there is no unsupported roof or bridge. Coordinates are local X/Z. */ export function cardInsertNotch(input: ParameterValues): [number, number][] { const p = cardInsertValues(input), width = p.notch_width, depth = cardInsertDimensions(input).notchDepth, r = Math.min(width / 2, depth); const points: [number, number][] = [[0, depth], [0, r]]; for (let i = 1; i <= 24; i++) { const a = Math.PI + i * Math.PI / 48; points.push([r + r * Math.cos(a), r + r * Math.sin(a)]); } if (width > 2 * r + 1e-8) points.push([width - r, 0]); for (let i = 1; i <= 24; i++) { const a = 1.5 * Math.PI + i * Math.PI / 48; points.push([width - r + r * Math.cos(a), r + r * Math.sin(a)]); } points.push([width, depth]); // When r===depth there is no straight vertical segment at either end. return points.filter((point, i) => i === 0 || Math.hypot(point[0] - points[i - 1][0], point[1] - points[i - 1][1]) > 1e-8); } /** Exact union accounting for a floor, nonoverlapping wall network, cavities * and U cuts. Changing walls and cutouts changes cost at fixed exterior size. */ export function cardInsertMetrics(input: ParameterValues) { const p = cardInsertValues(input), d = cardInsertDimensions(input), points = cardInsertNotch(input); 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]); } const notchArea = Math.abs(twiceArea) / 2, notchCount = d.bays.length * (p.rows + 1); const cavityArea = d.bays.reduce((sum, bay) => sum + bay.width * d.bayLength * p.rows, 0); const cavityPerimeter = d.bays.reduce((sum, bay) => sum + 2 * (bay.width + d.bayLength) * p.rows, 0); const { x, y, z } = d.bbox; return { volumeMm3: x * y * z - cavityArea * (z - p.floor) - notchCount * notchArea * p.wall, surfaceAreaMm2: 2 * x * y + 2 * (x + y) * z + cavityPerimeter * (z - p.floor) + notchCount * (-2 * notchArea + (perimeter - 2 * p.notch_width) * p.wall), bbox: d.bbox }; } export function cardInsertPrintability(input: ParameterValues) { return { fits: Object.values(cardInsertDimensions(input).bbox).every(value => value <= LIMITS.maxDimensionMm + 1e-6), instruction: "Print with the full floor flat on the bed and the wells facing upward. Open thumb cutouts need no supports. PLA or PETG, 100% infill.", notes: "One open insert; box, lid, cards and sleeves are separate. Measure below the closed lid and compare your actual deck thickness with the shown stack depth. Physical print and fit testing are pending." }; }