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 = .05): CustomizerParameter => ({ key, label, default: value, min, max, step, kind: "number", unit: "mm" }); const parameters: CustomizerParameter[] = [ number("case_diameter", "Measured case seat inside diameter", 33, 18, 120), number("movement_diameter", "Measured movement outside diameter", 26, 10, 110), number("height", "Finished ring height", 3, 1.2, 12, .1), number("stem_gap", "Finished stem gap width", 4, 1.2, 20), { ...number("ring_count", "Rings per set", 3, 1, 3, 1), unit: "", integer: true, choices: [{ value: 1, label: "One nominal ring" }, { value: 3, label: "Three fit trials · less / nominal / more clearance" }] }, number("case_clearance", "Total case diameter clearance", .2, 0, 1, .01), number("movement_clearance", "Total movement diameter clearance", .2, 0, 1, .01), number("fit_step", "Fit trial diameter step", .1, .05, .3, .01), ]; export const WATCH_SPACER: CustomizerModel = { id: "watch_spacer", slug: "custom-watch-movement-spacer", name: "Custom watch movement spacer", description: "Center an unusual round watch movement in a measured round case seat. Independently set both diameters, ring height and the full-height stem gap. Choose one nominal ring or three fit trials; use only one ring in the watch. PETG, with no printed locating ledge, dial-foot holes or clamp pockets. Physical print and fit testing are pending; presets are measurement examples, without movement or case compatibility claims.", sourcePath: "src/lib/customizer/cad/watch-spacer.ts", sourceUrl: "https://3dprinterverse.com/designs/watch-spacer", sourceKind: "store-original", geometryMode: "browser-csg", family: "measured-repair", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-watch-movement-spacer.stl", imagePath: "/images/galaxy/custom-watch-movement-spacer.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["PETG"], infillPct: 100, priceCentsByMaterial: { PETG: 1200 }, presets: [ { id: "round-fit-trials", label: "Round seat · 33 / 26 mm · three trials", parameters: {}, description: "Three separate rings with the same height and stem opening. Trial steps change both diameter clearances together; they are examples, not guaranteed printer tolerances." }, { id: "slim-single", label: "Slim straight spacer · 31.5 / 27 mm · one ring", parameters: { case_diameter: 31.5, movement_diameter: 27, height: 2, stem_gap: 3, ring_count: 1 }, description: "One straight ring for radial centering. Existing axial retention and stem alignment must already work." }, { id: "small-movement", label: "Small movement · 29 / 19.5 mm · three trials", parameters: { case_diameter: 29, movement_diameter: 19.5, height: 3.6, stem_gap: 3.5, case_clearance: .25, movement_clearance: .15 } }, { id: "large-seat", label: "Large round seat · 46 / 36 mm · three trials", parameters: { case_diameter: 46, movement_diameter: 36, height: 4.5, stem_gap: 5, fit_step: .15, case_clearance: .3, movement_clearance: .3 } }, ], }; export function spacerParameterVisible(key: string, count: number) { return key !== "fit_step" || count === 3; } export function spacerValues(input: ParameterValues) { const values: Record = {}; for (const p of parameters) { const value = p.key === "fit_step" && Number(input.ring_count ?? 3) === 1 ? p.default : input[p.key] ?? p.default; if (typeof value !== "number" || !Number.isFinite(value) || value < p.min || value > p.max || p.choices && !p.choices.some(c => c.value === value)) throw new Error(`Enter supported ${p.label.toLowerCase()}.`); values[p.key] = value; } return values; } export type SpacerPoint = [number, number]; const SEGMENTS = 384; // Outer vertices stay within the case envelope. Inner tangent facets enclose // the complete requested movement circle, including between sample directions. function circle(radius: number, tangent: boolean): SpacerPoint[] { const r = tangent ? radius / Math.cos(Math.PI / SEGMENTS) : radius; return Array.from({ length: SEGMENTS }, (_, i) => [r * Math.cos(i * 2 * Math.PI / SEGMENTS), r * Math.sin(i * 2 * Math.PI / SEGMENTS)]); } function majorArc(points: SpacerPoint[], halfGap: number): SpacerPoint[] { const first = points.findIndex(p => p[1] >= halfGap), last = points.findLastIndex(p => p[1] <= -halfGap); if (first <= 0 || last <= first) throw new Error("The stem gap must be narrower than the movement opening."); const intersection = (a: SpacerPoint, b: SpacerPoint, y: number): SpacerPoint => [a[0] + (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]), y]; const arc = [intersection(points[first - 1], points[first], halfGap), ...points.slice(first, last + 1), intersection(points[last], points[(last + 1) % points.length], -halfGap)]; return arc.filter((p, i) => !i || Math.hypot(p[0] - arc[i - 1][0], p[1] - arc[i - 1][1]) > 1e-9); } export function spacerDimensions(input: ParameterValues) { const p = spacerValues(input), steps = p.ring_count === 3 ? [-p.fit_step, 0, p.fit_step] : [0]; let cursor = 0; const rings = steps.map((step, index) => { const outside = p.case_diameter - p.case_clearance - step, inside = p.movement_diameter + p.movement_clearance + step; if (inside <= 0 || outside <= inside || p.stem_gap >= inside) throw new Error("Leave room between the movement and case for a printable spacer, with a narrower stem gap."); const outline = [...majorArc(circle(outside / 2, false), p.stem_gap / 2), ...majorArc(circle(inside / 2, true), p.stem_gap / 2).reverse()]; const minX = Math.min(...outline.map(v => v[0])), maxX = Math.max(...outline.map(v => v[0])); const x = cursor - minX; cursor += maxX - minX + 4; return { label: p.ring_count === 1 ? "Nominal" : ["A · less clearance", "B · nominal", "C · more clearance"][index], outside, inside, caseClearance: p.case_clearance + step, movementClearance: p.movement_clearance + step, minimumWall: outside / 2 * Math.cos(Math.PI / SEGMENTS) - inside / 2 / Math.cos(Math.PI / SEGMENTS), outline, x }; }); const width = cursor - 4; return { rings: rings.map(r => ({ ...r, x: r.x - width / 2 })), bbox: { x: width, y: Math.max(...rings.map(r => r.outside)), z: p.height } }; } export function validateSpacer(input: ParameterValues) { const p = spacerValues(input); if (p.ring_count === 3 && Math.min(p.case_clearance, p.movement_clearance) < p.fit_step - 1e-9) throw new Error("Both diameter clearances must be at least the fit trial step; the least-clearance ring cannot have an interference fit."); const d = spacerDimensions(p); if (d.rings.some(r => r.minimumWall < 1.2 - 1e-8)) throw new Error("Every trial ring needs at least 1.2 mm of radial wall for the A1’s 0.4 mm nozzle. Reduce the movement opening or clearances, or use a larger case seat."); if (d.rings.some(r => p.stem_gap > .6 * r.inside + 1e-8)) throw new Error("Keep the full-height stem gap at most 60% of the smallest movement opening so the ring remains a connected centering arc."); if (Object.values(d.bbox).some(v => v > LIMITS.maxDimensionMm + 1e-8)) throw new Error(`The complete ring set exceeds the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Choose one ring or reduce the measured diameter.`); } export function spacerMetrics(input: ParameterValues) { const p = spacerValues(input), d = spacerDimensions(p); let area = 0, perimeter = 0; for (const { outline } of d.rings) { let signed = 0; outline.forEach((a, i) => { const b = outline[(i + 1) % outline.length]; signed += a[0] * b[1] - b[0] * a[1]; perimeter += Math.hypot(b[0] - a[0], b[1] - a[1]); }); area += Math.abs(signed) / 2; } return { volumeMm3: area * p.height, surfaceAreaMm2: area * 2 + perimeter * p.height, bbox: d.bbox }; } export function spacerPrintability(input: ParameterValues) { return { fits: Object.values(spacerDimensions(input).bbox).every(v => v <= LIMITS.maxDimensionMm), instruction: "Print each ring flat on a broad face, PETG at 100% infill with a 0.4 mm nozzle; the stem gap passes through the full height. No supports. The complete set includes a 4 mm gap between rings.", notes: "Straight round seats and radial centering only. Use one trial ring, with separate existing axial retention. Check stem, rotor, balance, dial feet, clamps and caseback clearance. Clean all print debris before a dry fit. Physical print and fit tests are pending; printed dimensions can vary." }; }