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("mount_diameter", "Measured dew shield outside diameter", 100, 40, 270, .01), number("pattern_diameter", "Grating pattern diameter", 80, 28, 240), number("focal_length", "Working focal length", 600, 150, 3000, 1), number("clearance", "Total sleeve diameter clearance", .6, .25, 1.5, .05), number("sleeve_depth", "Sleeve engagement depth", 12, 5, 30), { key: "factor", label: "Bahtinov spacing factor", default: 150, min: 150, max: 200, step: 25, integer: true, unit: "", kind: "number", choices: [{ value: 150, label: "150 · wider slits" }, { value: 175, label: "175" }, { value: 200, label: "200 · finer slits" }] }, { key: "order", label: "Diffraction spacing", default: 1, min: 1, max: 3, step: 1, integer: true, unit: "", kind: "number", choices: [{ value: 1, label: "Standard · first order" }, { value: 3, label: "Coarser · third order" }] }, number("wall", "Sleeve wall thickness", 2.4, 1.6, 5), number("face_thickness", "Grating face thickness", 2, 1.6, 4), number("center_web", "Center support web width", 2.4, 2.4, 6), ]; export const BAHTINOV_MASK: CustomizerModel = { id: "bahtinov_mask", slug: "custom-bahtinov-mask", name: "Custom-fit Bahtinov focusing mask", description: "Fit an unusual telescope dew shield or round lens hood using its measured outside diameter. Set the pattern diameter and working focal length independently. One black PETG slip-over mask with a removal tab per quantity. Nighttime star focusing only. Physical print, fit and optical testing are pending; presets make no equipment-fit claim.", sourcePath: "src/lib/customizer/cad/bahtinov-mask.ts", sourceUrl: "https://3dprinterverse.com/designs/bahtinov-mask", geometryMode: "browser-csg", sourceKind: "store-original", family: "measured-optical-accessories", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-bahtinov-mask.stl", imagePath: "/images/galaxy/custom-bahtinov-mask.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: "medium", label: "100 mm hood · 600 mm focal length", parameters: {}, description: "Example only. Measure the actual round hood, check the clear opening and keep the mask face clear of the optics." }, { id: "compact-lens", label: "65 mm hood · 200 mm lens · third order", parameters: { mount_diameter: 65, pattern_diameter: 50, focal_length: 200, order: 3, sleeve_depth: 8 }, description: "Coarser third-order spacing keeps the bars printable on a short telephoto example. Optical performance has not been tested." }, { id: "small-refractor", label: "88 mm hood · 420 mm focal length", parameters: { mount_diameter: 88, pattern_diameter: 70, focal_length: 420, sleeve_depth: 10 } }, { id: "large", label: "180 mm hood · 1200 mm focal length", parameters: { mount_diameter: 180, pattern_diameter: 150, focal_length: 1200, sleeve_depth: 16, face_thickness: 2.4, center_web: 3.2 } }, ], }; export function maskValues(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)) || (p.choices && !p.choices.some(c => c.value === value))) throw new Error(`Choose supported ${p.label.toLowerCase()}.`); values[p.key] = value; } return values as Record<"mount_diameter" | "pattern_diameter" | "focal_length" | "clearance" | "sleeve_depth" | "factor" | "order" | "wall" | "face_thickness" | "center_web", number>; } export type MaskPoint = [number, number]; export const MASK_SEGMENTS = 192; const facetCos = Math.cos(Math.PI / MASK_SEGMENTS); export const MASK_TAB_EXTENSION = 12; const tabHalfWidth = 8; export function maskDimensions(input: ParameterValues) { const p = maskValues(input); // Circumscribe the bore so opposite flats preserve the requested clearance. const innerRadius = (p.mount_diameter + p.clearance) / (2 * facetCos); const outerRadius = (innerRadius + p.wall) / facetCos; // A 0.4 mm face ledge supports the sleeve and keeps the tab junction clear of // coincident faceted edges when the two extrusions are joined. const faceRadius = outerRadius + .4; const pitch = p.order * p.focal_length / p.factor; return { innerRadius, outerRadius, faceRadius, pitch, slitWidth: pitch / 2, barWidth: pitch / 2, minimumSleeveDiameter: p.mount_diameter + p.clearance, tabHoleX: faceRadius + 6, bbox: { x: faceRadius * 2 + MASK_TAB_EXTENSION, y: faceRadius * 2, z: p.face_thickness + p.sleeve_depth } }; } export function maskCircle(radius: number): MaskPoint[] { return Array.from({ length: MASK_SEGMENTS }, (_, i) => [radius * Math.cos(2 * Math.PI * i / MASK_SEGMENTS), radius * Math.sin(2 * Math.PI * i / MASK_SEGMENTS)]); } /** Clip a convex polygon to nx*x + ny*y <= limit. Shared with analytical pricing; * the browser builder extrudes these exact polygons rather than a reference STL. */ function clip(points: MaskPoint[], nx: number, ny: number, limit: number): MaskPoint[] { const result: MaskPoint[] = []; points.forEach((b, i) => { const a = points[(i + points.length - 1) % points.length]; const da = nx * a[0] + ny * a[1] - limit, db = nx * b[0] + ny * b[1] - limit; if ((da <= 0) !== (db <= 0)) { const t = da / (da - db); result.push([a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]); } if (db <= 0) result.push(b); }); return result.filter((p, i) => Math.hypot(p[0] - result[(i + result.length - 1) % result.length][0], p[1] - result[(i + result.length - 1) % result.length][1]) > 1e-8); } export function maskPolygonMetrics(points: MaskPoint[]) { let twiceArea = 0, perimeter = 0; points.forEach((a, i) => { const 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 }; } export function maskFaceOutline(input: ParameterValues): MaskPoint[] { const { faceRadius: r } = maskDimensions(input), circle = maskCircle(r); // Locate y=8 on the actual faceted circle, preserving its complete footprint. let attachX = 0; for (let i = 0; i < MASK_SEGMENTS / 4; i++) { const a = circle[i], b = circle[i + 1]; if (a[1] <= tabHalfWidth && b[1] >= tabHalfWidth) { attachX = a[0] + (b[0] - a[0]) * (tabHalfWidth - a[1]) / (b[1] - a[1]); break; } } const trimmed = clip(circle, 1, 0, attachX); // clip starts at the upper intersection and travels CCW around the back. return [[r + MASK_TAB_EXTENSION, -tabHalfWidth], [r + MASK_TAB_EXTENSION, tabHalfWidth], ...trimmed]; } /** Classic three grating regions: upper half at 0°, lower quarters at ±20°. * A solid T web and the uncut rim connect every bar; all holes are enclosed. * Pitch = diffraction order * focal length / factor, with equal slit/bar widths. */ export function maskGratingRegions(input: ParameterValues): MaskPoint[][][] { const p = maskValues(input), d = maskDimensions(input), r = p.pattern_diameter / 2; const circle = maskCircle(r), w = p.center_web / 2; const regions = [clip(circle, 0, -1, -w), clip(clip(circle, 0, 1, -w), 1, 0, -w), clip(clip(circle, 0, 1, -w), -1, 0, -w)]; return regions.map((region, index) => { const angle = [0, 20, -20][index] * Math.PI / 180, nx = -Math.sin(angle), ny = Math.cos(angle); const holes: MaskPoint[][] = []; const count = Math.ceil(r / d.pitch) + 1; for (let i = -count; i <= count; i++) { const center = i * d.pitch; const polygon = clip(clip(region, nx, ny, center + d.slitWidth / 2), -nx, -ny, -center + d.slitWidth / 2); // Ignore vanishing tangent holes rather than emit zero-area contours. if (polygon.length >= 3 && maskPolygonMetrics(polygon).area > 1e-5) holes.push(polygon); } return holes; }); } export function maskFaceHoles(input: ParameterValues): MaskPoint[][] { const d = maskDimensions(input); return [...maskGratingRegions(input).flat(), maskCircle(2.5).map(([x, y]): MaskPoint => [x + d.tabHoleX, y])]; } export function validateMask(input: ParameterValues) { const p = maskValues(input), d = maskDimensions(input); if (Object.values(d.bbox).some(value => value > LIMITS.maxDimensionMm + 1e-6)) throw new Error(`The complete mask including its removal tab exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Reduce the measured diameter or sleeve wall.`); if (p.mount_diameter - p.pattern_diameter < 6 - 1e-7) throw new Error("Leave at least 3 mm of seating rim between the grating pattern and the measured hood outside edge. Reduce pattern diameter or use a larger hood."); if (d.barWidth < 1.2 - 1e-7) throw new Error("Slits and bars must each be at least 1.2 mm wide for printing. Choose third-order spacing or a lower factor; verify your working focal length."); if (d.pitch > p.pattern_diameter / 8 + 1e-7) throw new Error("Keep at least eight grating periods across the pattern. Increase pattern diameter or the spacing factor, or choose first-order spacing."); if (p.center_web > p.pattern_diameter / 8) throw new Error("Keep the center web within one eighth of the pattern diameter so all three gratings remain open."); } /** Exact faceted extrusion metrics, excluding the shared face/collar contact. */ export function maskMetrics(input: ParameterValues) { const p = maskValues(input), d = maskDimensions(input), face = maskPolygonMetrics(maskFaceOutline(input)); const holes = maskFaceHoles(input).map(maskPolygonMetrics); const outside = maskPolygonMetrics(maskCircle(d.outerRadius)), inside = maskPolygonMetrics(maskCircle(d.innerRadius)); const faceArea = face.area - holes.reduce((sum, hole) => sum + hole.area, 0); return { volumeMm3: faceArea * p.face_thickness + (outside.area - inside.area) * p.sleeve_depth, surfaceAreaMm2: 2 * faceArea + (face.perimeter + holes.reduce((sum, hole) => sum + hole.perimeter, 0)) * p.face_thickness + (outside.perimeter + inside.perimeter) * p.sleeve_depth, bbox: d.bbox }; } export function maskPrintability(input: ParameterValues) { return { fits: Object.values(maskDimensions(input).bbox).every(v => v <= LIMITS.maxDimensionMm + 1e-6), instruction: "Print the grating face flat on the bed, sleeve upward. Black PETG, 100% infill, 0.4 mm nozzle; no supports. Clear all stringing from the slits before use.", notes: "One slip-over mask per quantity. Lift by the tab, keep the face clear of the glass, and remove after focusing. Physical print, fit and optical testing are pending. Nighttime use only; never a solar filter." }; }