import { LIMITS } from "@/config/business"; import type { ParameterValues } from "./catalog"; /** Original dimension-driven profiles shared by checkout and the browser CAD. * No reference meshes or artist outlines are used. Units are millimetres. */ export const FIBER_MODEL_IDS = ["yarn_bowl", "leaf_buttons", "yarn_bobbin", "crochet_basket_bottoms"] as const; export const isFiberModel = (id: string) => (FIBER_MODEL_IDS as readonly string[]).includes(id); export type Point = [number, number]; export type Hole = { x: number; y: number; radius: number }; export const BOWL_WALL = 3.2; export const BOWL_BASE = 4; export const PANEL_GAP = .6; export function polygonArea(points: Point[]) { return 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; } export function polygonPerimeter(points: Point[]) { return points.reduce((sum, a, i) => { const b = points[(i + 1) % points.length]; return sum + Math.hypot(a[0] - b[0], a[1] - b[1]); }, 0); } export function outlineBounds(points: Point[]) { const xs = points.map(p => p[0]), ys = points.map(p => p[1]); return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) }; } export function edgeDistance(point: Point, outline: Point[]) { return Math.min(...outline.map((a, i) => { const b = outline[(i + 1) % outline.length], dx = b[0] - a[0], dy = b[1] - a[1]; const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / (dx * dx + dy * dy || 1))); return Math.hypot(point[0] - a[0] - t * dx, point[1] - a[1] - t * dy); })); } export function insideOutline([x, y]: Point, outline: Point[]) { let inside = false; for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { const a = outline[i], b = outline[j]; if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside; } return inside; } function ellipse(x: number, y: number): Point[] { return Array.from({ length: 128 }, (_, i) => { const a = i * Math.PI / 64; return [x / 2 * Math.cos(a), y / 2 * Math.sin(a)]; }); } function roundedRectangle(x: number, y: number, radius: number): Point[] { const points: Point[] = []; for (let corner = 0; corner < 4; corner++) { const a = corner * Math.PI / 2, cx = Math.cos(a + Math.PI / 4) > 0 ? x / 2 - radius : -x / 2 + radius; const cy = Math.sin(a + Math.PI / 4) > 0 ? y / 2 - radius : -y / 2 + radius; for (let i = 0; i <= 12; i++) points.push([cx + radius * Math.cos(a + i * Math.PI / 24), cy + radius * Math.sin(a + i * Math.PI / 24)]); } return points; } function alongOutline(outline: Point[], count: number): Point[] { const perimeter = polygonPerimeter(outline); return Array.from({ length: count }, (_, i) => { let remaining = (i + .5) * perimeter / count; for (let j = 0; j < outline.length; j++) { const a = outline[j], b = outline[(j + 1) % outline.length], length = Math.hypot(b[0] - a[0], b[1] - a[1]); if (remaining <= length) return [a[0] + (b[0] - a[0]) * remaining / length, a[1] + (b[1] - a[1]) * remaining / length] as Point; remaining -= length; } return outline[0]; }); } /** Four leaf families are analytic silhouettes, with a distinct monstera rhythm. */ export function leafProfile(values: ParameterValues) { const p = values as Record, d = p.diameter; const outline: Point[] = Array.from({ length: 128 }, (_, i) => { const a = i * Math.PI / 64, y = .5 * Math.cos(a); let width = .34; if (p.style === 0) width *= 1 - .42 * Math.pow(Math.max(0, Math.cos(7 * a + .5)), 10) * Math.pow(Math.abs(Math.sin(a)), .6); if (p.style === 1) width = .23; // willow if (p.style === 2) width = .38 * (1 - .2 * Math.cos(6 * a)); // oak if (p.style === 3) width = .36 * (1 - .24 * Math.pow(Math.max(0, Math.cos(3 * a)), 4)); // ginkgo fan const x = d * width * Math.sin(a) * (p.style === 3 ? .85 + .3 * Math.cos(a) : 1); return [x, y * d]; }); const pitch = d * .105, radius = Math.max(.9, Math.min(1.5, d * .04)); const holes: Hole[] = p.holes === 2 ? [-1, 1].map(s => ({ x: 0, y: s * pitch, radius })) : [-1, 1].flatMap(x => [-1, 1].map(y => ({ x: x * pitch, y: y * pitch, radius }))); const columns = Math.ceil(Math.sqrt(p.quantity)), rows = Math.ceil(p.quantity / columns); const bounds = outlineBounds(outline), cellX = bounds.maxX - bounds.minX + 5, cellY = d + 5; const offsets: Point[] = Array.from({ length: p.quantity }, (_, i) => [(i % columns - (columns - 1) / 2) * cellX, (Math.floor(i / columns) - (rows - 1) / 2) * cellY]); return { outline, holes, offsets, bbox: { x: columns * cellX - 5, y: rows * cellY - 5, z: p.thickness } }; } function clipRectangle(outline: Point[], minX: number, maxX: number, minY: number, maxY: number) { let result = outline; for (const [axis, value, sign] of [[0, minX, 1], [0, maxX, -1], [1, minY, 1], [1, maxY, -1]]) { const input = result; result = []; for (let i = 0; i < input.length; i++) { const a = input[i], b = input[(i + 1) % input.length]; const aInside = (a[axis] - value) * sign >= 0, bInside = (b[axis] - value) * sign >= 0; if (aInside) result.push(a); if (aInside !== bInside) { const t = (value - a[axis]) / (b[axis] - a[axis]); result.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); } } } return result; } /** Oversize bottoms are separate panels with paired stitch holes on every seam. * The STL keeps the assembly coordinates; slicers can split its shells to plates. */ export function basketProfile(values: ParameterValues) { const p = values as Record, x = p.size, y = p.shape === 0 || p.shape === 2 ? x : p.width; const rounded = p.shape >= 2, corner = Math.min(12, y / 6, x / 6); const outline = rounded ? roundedRectangle(x, y, corner) : ellipse(x, y); const m = p.edge_margin; const inset = rounded ? roundedRectangle(x - 2 * m, y - 2 * m, Math.max(1, corner - m)) : ellipse(x - 2 * m, y - 2 * m); const nx = Math.ceil(x / LIMITS.maxDimensionMm), ny = Math.ceil(y / LIMITS.maxDimensionMm); const seamsX = Array.from({ length: nx - 1 }, (_, i) => -x / 2 + (i + 1) * x / nx); const seamsY = Array.from({ length: ny - 1 }, (_, i) => -y / 2 + (i + 1) * y / ny); const holes: Hole[] = alongOutline(inset, Math.max(4, Math.floor(polygonPerimeter(inset) / p.hole_spacing))) .map(([x, y]) => ({ x, y, radius: p.hole_diameter / 2 })) .filter(h => [...seamsX.map(s => Math.abs(h.x - s)), ...seamsY.map(s => Math.abs(h.y - s))].every(d => d >= h.radius + 2)); const addSeamPair = (a: Point, b: Point) => { const radius = Math.min(2, p.hole_diameter / 2); if ([a, b].every(([x, y]) => insideOutline([x, y], outline) && edgeDistance([x, y], outline) >= radius + 3 && holes.every(h => Math.hypot(h.x - x, h.y - y) >= h.radius + radius + 2))) { holes.push(...[a, b].map(([x, y]) => ({ x, y, radius }))); } }; const step = Math.max(14, p.hole_spacing); for (const seam of seamsX) for (let yy = -y / 2 + step; yy < y / 2 - 5; yy += step) { if (seamsY.every(s => Math.abs(yy - s) >= 8)) addSeamPair([seam - 5, yy], [seam + 5, yy]); } for (const seam of seamsY) for (let xx = -x / 2 + step; xx < x / 2 - 5; xx += step) { if (seamsX.every(s => Math.abs(xx - s) >= 8)) addSeamPair([xx, seam - 5], [xx, seam + 5]); } const panels = []; for (let row = 0; row < ny; row++) for (let col = 0; col < nx; col++) { const points = clipRectangle(outline, -x / 2 + col * x / nx + (col ? PANEL_GAP / 2 : 0), -x / 2 + (col + 1) * x / nx - (col < nx - 1 ? PANEL_GAP / 2 : 0), -y / 2 + row * y / ny + (row ? PANEL_GAP / 2 : 0), -y / 2 + (row + 1) * y / ny - (row < ny - 1 ? PANEL_GAP / 2 : 0)); if (points.length >= 3 && polygonArea(points) > 1) panels.push(points); } const partBounds = panels.map(outlineBounds); const bbox = { x: Math.max(...partBounds.map(b => b.maxX - b.minX)), y: Math.max(...partBounds.map(b => b.maxY - b.minY)), z: p.thickness }; return { outline, holes, panels, seamsX, seamsY, bbox, assembly: { x, y, z: p.thickness } }; } /** Radius/height pairs of the independent bowl silhouette. */ export function bowlProfile(values: ParameterValues) { const p = values as Record, r = p.diameter / 2, h = p.height; const levels: Point[] = p.style === 1 ? [[r * .68, 0], [r * .98, h * .38], [r * .84, h * .83], [r * .9, h * .94], [r, h]] : [[r * .72, 0], [r * .78, h * .12], [r * .98, h * .66], [r, h]]; const segments = p.style === 1 ? 12 : p.style === 3 ? 6 : 96; const radiusAt = (z: number) => { const i = Math.max(1, levels.findIndex(l => l[1] >= z)); const a = levels[i - 1], b = levels[i]; return a[0] + (b[0] - a[0]) * (z - a[1]) / (b[1] - a[1]); }; const inner: Point[] = [[radiusAt(BOWL_BASE) - BOWL_WALL, BOWL_BASE], ...levels.filter(l => l[1] > BOWL_BASE).map(([r, z]) => [r - BOWL_WALL, z] as Point)]; return { levels, inner, segments, bbox: { x: p.diameter, y: p.diameter, z: h } }; } export function fiberDimensions(id: string, values: ParameterValues) { const p = values as Record; if (id === "crochet_basket_bottoms") return basketProfile(values).bbox; if (id === "leaf_buttons") return leafProfile(values).bbox; if (id === "yarn_bobbin") return { x: p.flange_diameter, y: p.flange_diameter, z: p.length }; return bowlProfile(values).bbox; } export function validateFiberConstraints(id: string, values: ParameterValues) { const p = values as Record; if (id === "yarn_bobbin") { if (p.core_diameter < p.spindle_hole + 4) throw new Error("Keep at least 2 mm of core wall around the spindle hole."); if (p.flange_diameter < p.core_diameter + 10) throw new Error("The flange needs at least 5 mm of yarn-retaining lip around the core."); if (p.length < (p.flange_diameter - p.core_diameter) / 2 + 16) throw new Error("Increase length or reduce flange diameter to leave room for the support-free upper shoulder and winding core."); } if (id === "leaf_buttons") { const leaf = leafProfile(values); if (leaf.holes.some(h => !insideOutline([h.x, h.y], leaf.outline) || edgeDistance([h.x, h.y], leaf.outline) < h.radius + 1.2)) throw new Error("Increase button diameter to preserve material around the sewing holes."); } if (id === "crochet_basket_bottoms") { if (p.edge_margin < p.hole_diameter / 2 + 2) throw new Error("Keep at least 2 mm of material between each crochet hole and the outer edge."); if (p.hole_spacing < p.hole_diameter + 2.5) throw new Error("Increase hole spacing to keep at least 2.5 mm between crochet holes."); const width = p.shape === 0 || p.shape === 2 ? p.size : p.width; if (Math.min(p.size, width) < 2 * p.edge_margin + p.hole_diameter + 8) throw new Error("Reduce edge margin or increase bottom size to leave a usable solid center."); } if (Object.values(fiberDimensions(id, values)).some(d => !Number.isFinite(d) || d <= 0 || d > LIMITS.maxDimensionMm + 1e-6)) { throw new Error(`This configuration exceeds the A1's ${LIMITS.maxDimensionMm} mm working limit on its 256 mm bed. Reduce the dimensions or button quantity.`); } } function revolvedVolume(levels: Point[], segments: number) { const factor = segments * Math.sin(2 * Math.PI / segments) / 2; return levels.slice(1).reduce((sum, b, i) => { const a = levels[i]; return sum + factor * (b[1] - a[1]) * (a[0] ** 2 + a[0] * b[0] + b[0] ** 2) / 3; }, 0); } /** Solid material estimates, including set count, through holes and panel gaps. * Bowl slots and relief use a conservative allowance, not a slicer estimate. */ export function fiberMetrics(id: string, values: ParameterValues) { const p = values as Record, bbox = fiberDimensions(id, values); let volumeMm3: number, surfaceAreaMm2: number; if (id === "leaf_buttons" || id === "crochet_basket_bottoms") { const profile = id === "leaf_buttons" ? leafProfile(values) : basketProfile(values); const outlines = "panels" in profile ? profile.panels : [profile.outline]; const count = id === "leaf_buttons" ? p.quantity : 1; const area = outlines.reduce((sum, polygon) => sum + polygonArea(polygon), 0) - profile.holes.reduce((sum, h) => sum + Math.PI * h.radius ** 2, 0); const perimeter = outlines.reduce((sum, polygon) => sum + polygonPerimeter(polygon), 0) + profile.holes.reduce((sum, h) => sum + 2 * Math.PI * h.radius, 0); volumeMm3 = area * p.thickness * count; surfaceAreaMm2 = (2 * area + perimeter * p.thickness) * count; } else if (id === "yarn_bobbin") { const r = p.core_diameter / 2, f = p.flange_diameter / 2, hole = p.spindle_hole / 2, rise = f - r; volumeMm3 = Math.PI * (r * r * (p.length - 6 - rise) + f * f * 6 + rise * (r * r + r * f + f * f) / 3 - hole * hole * p.length); surfaceAreaMm2 = Math.PI * (2 * (f * f - hole * hole) + (f * f - r * r) + 2 * f * 6 + 2 * r * (p.length - 6 - rise) + (r + f) * Math.SQRT2 * rise + 2 * hole * p.length); } else { const profile = bowlProfile(values), r = p.diameter / 2; volumeMm3 = revolvedVolume(profile.levels, profile.segments) - revolvedVolume(profile.inner, profile.segments); volumeMm3 -= BOWL_WALL * (5 * p.height * .48 + p.feed_holes * Math.PI * 9); if (p.style === 2) volumeMm3 += r * p.height * .035 * 4; if (p.style === 4) volumeMm3 += 2 * Math.PI * r * 5; surfaceAreaMm2 = 4 * Math.PI * r * p.height + 2 * Math.PI * r * r * .6; } return { volumeMm3, surfaceAreaMm2, bbox }; } export function fiberPrintability(id: string, values: ParameterValues) { const p = values as Record, bbox = fiberDimensions(id, values); const fits = Object.values(bbox).every(d => d > 0 && d <= LIMITS.maxDimensionMm + 1e-6); const basket = id === "crochet_basket_bottoms" ? basketProfile(values) : undefined; const instruction = basket ? basket.panels.length > 1 ? `${basket.panels.length} panels, each at most ${bbox.x.toFixed(1)} × ${bbox.y.toFixed(1)} mm. Print flat on separate plates; lace paired seam holes with strong cord before crocheting the outer edge. The preview shows the full assembled size.` : "Print the bottom flat. Crochet through the evenly spaced perimeter holes. Round and square use the main size; oval and rectangle also use width." : id === "leaf_buttons" ? `Print all ${p.quantity} buttons flat, sewing holes vertical. Cart quantity counts sets; each set contains ${p.quantity} buttons.` : id === "yarn_bobbin" ? "Print upright with the lower flange on the bed. The 45° upper shoulder supports the upper flange. Spindle hole is the finished design diameter; measure your spindle and allow fit clearance." : "Print base-down. The open rim slot lets you insert yarn without cutting it; additional 6 mm feed holes are closed guides. Use short-hole bridging at the guides."; return { fits, instruction, notes: "Original design by Devan / 3DPrinterVerse. Physical print testing is pending. Prices use estimated solid volume. PLA and PETG; hand clean only." }; }