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 = .01): CustomizerParameter => ({ key, label, default: value, min, max, step, unit: "mm", kind: "number" }); const parameters: CustomizerParameter[] = [ { ...number("shape", "Leg outline", 0, 0, 1, 1), unit: "", integer: true, choices: [{ value: 0, label: "Rounded rectangle or square" }, { value: 1, label: "Ellipse or circle" }] }, number("foot_width", "Measured width at leg foot", 25.4, 8, 140), number("foot_length", "Measured length at leg foot", 19.05, 8, 140), number("lip_width", "Measured width at sleeve lip", 28, 8, 140), number("lip_length", "Measured length at sleeve lip", 21.5, 8, 140), number("depth", "Sleeve engagement up the leg", 20, 8, 45), { ...number("caps_per_set", "Identical caps per set", 4, 1, 4, 1), unit: "", integer: true, choices: [1, 2, 4].map(value => ({ value, label: `${value} ${value === 1 ? "cap" : "caps"}` })) }, number("foot_radius", "Measured corner radius at foot", 1.5, .5, 70), number("lip_radius", "Measured corner radius at lip", 1.5, .5, 70), number("clearance", "Total fit allowance", -.2, -.4, 1.5, .05), number("wall", "Horizontal sleeve wall", 2.4, 2, 5, .1), number("floor", "Solid sole thickness", 3, 2.4, 5, .1), ]; export const FURNITURE_LEG_CAP: CustomizerModel = { id: "furniture_leg_cap", slug: "custom-furniture-leg-caps", name: "Custom tapered furniture leg caps", description: "TPU sleeves for unusual upright, flat-ended furniture legs. Match the actual outline at the foot and again at the sleeve lip, including a centered taper. Choose one, two or four identical caps per set. Standard rubber caps or felt pads can cost less when they fit. Physical print, fit, wear and floor-finish testing are pending; presets are measurement examples.", sourcePath: "src/lib/customizer/cad/furniture-leg-cap.ts", sourceUrl: "https://3dprinterverse.com/designs/furniture-leg-cap", sourceKind: "store-original", geometryMode: "browser-csg", family: "measured-furniture-repair", kind: "print", parameters, defaults: Object.fromEntries(parameters.map(p => [p.key, p.default])), stlPath: "/models/parametric/custom-furniture-leg-caps.stl", imagePath: "/images/galaxy/custom-furniture-leg-caps.webp", volumeMm3: 1, surfaceAreaMm2: 1, bbox: { x: 1, y: 1, z: 1 }, printable: true, physicalPrintTested: false, materials: ["TPU"], infillPct: 100, priceCentsByMaterial: { TPU: 1500 }, presets: [ { id: "tapered-rectangle", label: "Tapered rectangle · four caps", parameters: {}, description: "Example: 25.4 × 19.05 mm at the foot, 28 × 21.5 mm at a lip 20 mm up. Measure each leg; front and rear legs can differ." }, { id: "straight-odd", label: "Odd straight rectangle · two caps", parameters: { foot_width: 31.7, foot_length: 17.8, lip_width: 31.7, lip_length: 17.8, foot_radius: 2, lip_radius: 2, depth: 18, caps_per_set: 2 } }, { id: "tapered-ellipse", label: "Tapered ellipse · four caps", parameters: { shape: 1, foot_width: 30, foot_length: 20, lip_width: 34, lip_length: 23, depth: 24 }, description: "A true ellipse has continuously curved sides. An obround or irregular oval needs a different outline." }, { id: "round-single", label: "Round 22.2 mm leg · one cap", parameters: { shape: 1, foot_width: 22.2, foot_length: 22.2, lip_width: 22.2, lip_length: 22.2, depth: 16, caps_per_set: 1 } }, ], }; export const CAP_SEGMENTS = 96; export const CAP_PRINT_GAP = 4; export type CapPoint = [number, number]; export const capParameterVisible = (key: string, shape: number) => !["foot_radius", "lip_radius"].includes(key) || shape === 0; export function capValues(input: ParameterValues) { const values: Record = {}; for (const p of parameters) { const value = capParameterVisible(p.key, Number(input.shape ?? 0)) ? input[p.key] ?? p.default : 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<"shape" | "foot_width" | "foot_length" | "lip_width" | "lip_length" | "depth" | "caps_per_set" | "foot_radius" | "lip_radius" | "clearance" | "wall" | "floor", number>; } /** Shared outward normals give corresponding vertices at both measured levels. * Tangent facets enclose the curve adjusted by the signed normal allowance; * adding wall offset preserves the chosen horizontal wall around all corners. */ export function capSupport(input: ParameterValues, level: "foot" | "lip", angle: number, offset = 0) { const p = capValues(input), nx = Math.cos(angle), ny = Math.sin(angle); const a = p[`${level}_width`] / 2, b = p[`${level}_length`] / 2, r = p[`${level}_radius`]; return (p.shape === 1 ? Math.hypot(a * nx, b * ny) : (a - r) * Math.abs(nx) + (b - r) * Math.abs(ny) + r) + offset; } export function capOutline(input: ParameterValues, level: "foot" | "lip", offset = 0): CapPoint[] { return Array.from({ length: CAP_SEGMENTS }, (_, i) => { const a = i * 2 * Math.PI / CAP_SEGMENTS, b = (i + 1) * 2 * Math.PI / CAP_SEGMENTS; const u = capSupport(input, level, a, offset), v = capSupport(input, level, b, offset), det = Math.sin(b - a); return [(u * Math.sin(b) - v * Math.sin(a)) / det, (v * Math.cos(a) - u * Math.cos(b)) / det]; }); } export function capDimensions(input: ParameterValues) { const p = capValues(input), width = p.lip_width + p.clearance + 2 * p.wall, length = p.lip_length + p.clearance + 2 * p.wall; const columns = p.caps_per_set === 1 ? 1 : 2, rows = p.caps_per_set === 4 ? 2 : 1; const centers: CapPoint[] = Array.from({ length: p.caps_per_set }, (_, i) => [(i % columns - (columns - 1) / 2) * (width + CAP_PRINT_GAP), (Math.floor(i / columns) - (rows - 1) / 2) * (length + CAP_PRINT_GAP)]); return { width, length, columns, rows, centers, footOpening: [p.foot_width + p.clearance, p.foot_length + p.clearance], lipOpening: [p.lip_width + p.clearance, p.lip_length + p.clearance], bbox: { x: columns * width + (columns - 1) * CAP_PRINT_GAP, y: rows * length + (rows - 1) * CAP_PRINT_GAP, z: p.floor + p.depth } }; } export function validateLegCap(input: ParameterValues) { const p = capValues(input); if (-p.clearance > .02 * Math.min(p.foot_width, p.foot_length, p.lip_width, p.lip_length) + 1e-9) throw new Error("Keep snug-fit interference within 2% of the smallest measured span. Use a less negative fit allowance."); for (const level of ["foot", "lip"] as const) { const a = p[`${level}_width`] / 2, b = p[`${level}_length`] / 2; if (p.shape === 0 && p[`${level}_radius`] > Math.min(a, b) + 1e-9) throw new Error(`The ${level} corner radius cannot exceed half its smaller measured side.`); if (p.shape === 1 && Math.min(a, b) ** 2 / Math.max(a, b) < .8) throw new Error("This ellipse has very tight end curvature. Increase its short span or reduce its long span."); } // Every facet must widen towards the entry, including diagonal corners. for (let i = 0; i < CAP_SEGMENTS; i++) { const angle = i * 2 * Math.PI / CAP_SEGMENTS; const growth = capSupport(p, "lip", angle) - capSupport(p, "foot", angle); if (growth < -1e-7) throw new Error("The sleeve lip must contain the entire foot outline. Increase lip dimensions or reduce its corner radius; reverse tapers cannot slide onto the leg."); } // Check actual loft vertices too: the advancing corner can move faster than // either side. Limit horizontal movement to half the vertical rise for TPU. const low = capOutline(p, "foot", p.clearance / 2 + p.wall), high = capOutline(p, "lip", p.clearance / 2 + p.wall); if (low.some((a, i) => Math.hypot(high[i][0] - a[0], high[i][1] - a[1]) > p.depth / 2 + 1e-7)) throw new Error("This taper is too steep for the upright TPU print. Increase engagement depth or bring the foot and lip measurements closer."); if (Object.values(capDimensions(p).bbox).some(v => v > LIMITS.maxDimensionMm + 1e-6)) throw new Error(`The complete cap set and gaps exceed the ${LIMITS.maxDimensionMm} mm usable area on the Bambu A1’s 256 mm bed. Reduce the size or choose fewer caps per set.`); } const area = (points: CapPoint[]) => Math.abs(points.reduce((sum, a, i) => { const b = points[(i + 1) % points.length]; return sum + a[0] * b[1] - b[0] * a[1]; }, 0)) / 2; const perimeter = (points: CapPoint[]) => points.reduce((sum, a, i) => { const b = points[(i + 1) % points.length]; return sum + Math.hypot(b[0] - a[0], b[1] - a[1]); }, 0); function loftVolume(a: CapPoint[], b: CapPoint[], height: number) { const middle = a.map((v, i) => [(v[0] + b[i][0]) / 2, (v[1] + b[i][1]) / 2] as CapPoint); return height * (area(a) + 4 * area(middle) + area(b)) / 6; } function loftSurface(a: CapPoint[], b: CapPoint[], height: number) { return a.reduce((sum, v, i) => { const j = (i + 1) % a.length, edge = [a[j][0] - v[0], a[j][1] - v[1]], shift = [b[i][0] - v[0], b[i][1] - v[1]]; const nextEdge = [b[j][0] - b[i][0], b[j][1] - b[i][1]]; const cross = (x: number[], y: number[]) => Math.hypot(x[1] * height, x[0] * height, x[0] * y[1] - x[1] * y[0]); return sum + (cross(edge, shift) + cross(nextEdge, shift)) / 2; }, 0); } /** Exact polygon prismoid integration, including the sole and every cap. */ export function capMetrics(input: ParameterValues) { const p = capValues(input), innerFoot = capOutline(p, "foot", p.clearance / 2), innerLip = capOutline(p, "lip", p.clearance / 2); const outerFoot = capOutline(p, "foot", p.clearance / 2 + p.wall), outerLip = capOutline(p, "lip", p.clearance / 2 + p.wall); return { volumeMm3: p.caps_per_set * (area(outerFoot) * p.floor + loftVolume(outerFoot, outerLip, p.depth) - loftVolume(innerFoot, innerLip, p.depth)), surfaceAreaMm2: p.caps_per_set * (area(outerFoot) + area(innerFoot) + area(outerLip) - area(innerLip) + perimeter(outerFoot) * p.floor + loftSurface(outerFoot, outerLip, p.depth) + loftSurface(innerFoot, innerLip, p.depth)), bbox: capDimensions(p).bbox, }; } export function capPrintability(input: ParameterValues) { const p = capValues(input); return { fits: Object.values(capDimensions(p).bbox).every(v => v <= LIMITS.maxDimensionMm + 1e-6), instruction: "Print soles flat, openings up, with 4 mm between caps. TPU, 100% infill, 0.4 mm nozzle; direct spool feed on the A1, no supports.", notes: `${p.caps_per_set} identical ${p.caps_per_set === 1 ? "cap" : "caps"} per set; cart quantity counts sets. Upright, solid or already capped flat-ended legs only. Check an unloaded fit and floor finish first. Physical print, fit and wear tests are pending.` }; }