import { LIMITS } from "@/config/business"; import type { ParameterValues } from "./catalog"; import { edgeDistance, insideOutline, outlineBounds, polygonArea, polygonPerimeter, type Point, type Hole } from "./fiber-crafts"; export const FIBER_TOOL_IDS = ["custom_buttons", "knitting_needle_caps", "wpi_yarn_gauge", "double_pointed_needle_box", "needle_garage", "extrusion_corner_vertex"] as const; export const isFiberTool = (id: string) => (FIBER_TOOL_IDS as readonly string[]).includes(id); export const BUTTON_STYLES = ["Plain", "Pumpkin", "Star", "Ladybug", "3D flower", "Ouroboros serpent", "Five-hole star stitch", "Cat", "Cat with whisker holes", "Oversized", "Leaf", "Cherry blossom", "Skull", "Cute skull", "Heart", "Ring", "Tentacle", "Dragon", "Spool flower", "Spool star", "Spool heart"]; export const NEEDLE_SIZES: [string, number][] = [["0",2],["1",2.25],["1.5",2.5],["2",2.75],["2.5",3],["3",3.25],["4",3.5],["5",3.75],["6",4],["7",4.5],["8",5],["9",5.5],["10",6],["10.5",6.5],["10.75",7],["11",8],["13",9],["15",10]]; export const WPI_REFERENCE = ["0 LACE 30-40+", "1 SUPER FINE 14-30", "2 FINE 12-18", "3 LIGHT 11-15", "4 MEDIUM 9-12", "5 BULKY 6-9", "6 SUPER BULKY 5-6", "7 JUMBO 1-4"]; // ASCII codes preserve text in the existing numeric CAD and checkout snapshots. export const textCodes = (text: string) => text.length ? [...text].map(c => c.charCodeAt(0)) : [32]; export const codesText = (codes: number | number[]) => Array.isArray(codes) ? String.fromCharCode(...codes) : ""; export const TEXT_CHARACTERS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-+."; export function layout(count: number, x: number, y: number, gap = 5) { const columns = Math.ceil(Math.sqrt(count)), rows = Math.ceil(count / columns); return { offsets: Array.from({length: count}, (_, i) => [(i % columns - (columns - 1) / 2) * (x + gap), (Math.floor(i / columns) - (rows - 1) / 2) * (y + gap)] as Point), x: columns * (x + gap) - gap, y: rows * (y + gap) - gap }; } function radial(count: number, radius: (angle: number, index: number) => number): Point[] { return Array.from({length: count}, (_, i) => {const a = 2 * Math.PI * i / count; return [Math.cos(a) * radius(a, i), Math.sin(a) * radius(a, i)];}); } export function buttonProfile(values: ParameterValues) { const p = values as Record, s = p.style, d = p.diameter; let points = radial(96, () => .5); if ([2,19].includes(s)) points = radial(10, (_, i) => i % 2 ? .29 : .5).map(([x,y]) => [-y,x]); if ([4,11,18].includes(s)) points = radial(120, a => .43 + .07 * Math.cos(5 * a)); if (s === 1) { points = radial(96, a => .43 + .025 * Math.cos(6 * a)).map(([x,y])=>[x,y*.9] as Point); points.splice(22,5,[.055,.37],[.08,.55],[-.015,.56],[-.055,.37]); } if ([7,8].includes(s)) points = [[-.43,-.28],[-.5,0],[-.4,.26],[-.38,.5],[-.12,.34],[.12,.34],[.38,.5],[.4,.26],[.5,0],[.43,-.28],[.23,-.43],[-.23,-.43]]; if (s === 10) points = radial(96,()=>1).map(([x,y])=>[x*.32*(1-.15*Math.cos(y*10)),y*.5]); if ([12,13].includes(s)) points = [[-.25,-.45],[.25,-.45],[.28,-.25],[.44,-.07],[.48,.2],[.3,.46],[-.3,.46],[-.48,.2],[-.44,-.07],[-.28,-.25]]; if ([14,20].includes(s)) points = radial(128,()=>1).map((_,i)=> {const a=i*Math.PI/64;return [16*Math.sin(a)**3/32,(13*Math.cos(a)-5*Math.cos(2*a)-2*Math.cos(3*a)-Math.cos(4*a)+2)/32];}); if (s === 16) points = radial(160,a=>.35+.15*Math.pow((1+Math.cos(5*a))/2,3)); if (s === 17) points = radial(96,a=>.43+.07*Math.max(0,Math.cos(3*a))**6); // Normalize the largest span to the requested diameter, without copying any outline. const b=outlineBounds(points), scale=d/Math.max(b.maxX-b.minX,b.maxY-b.minY); const outline=points.map(([x,y])=>[(x-(b.minX+b.maxX)/2)*scale,(y-(b.minY+b.maxY)/2)*scale] as Point); const ring = [5,15].includes(s), spool=s>=18, holeRadius=p.hole_diameter/2; const pitch=d*(ring?.355:spool?.18:s===6?.23:.145); const holes: Hole[]=Array.from({length:p.holes},(_,i)=>{const a=2*Math.PI*i/p.holes+Math.PI/2+(spool?.21:0);return {x:pitch*Math.cos(a),y:pitch*Math.sin(a),radius:holeRadius};}); const centerHole = ring ? d*.215 : spool ? d*.075 : 0; const extraHoles: Hole[]=s===8 ? [-1,1].flatMap(sign=>[-1,0,1].map(i=>({x:sign*d*(.28+.02*Math.abs(i)),y:d*(-.08+i*.1),radius:Math.max(.45,d*.015)}))) : []; const bounds=outlineBounds(outline), plate=layout(p.quantity,bounds.maxX-bounds.minX,bounds.maxY-bounds.minY); return {outline,holes,extraHoles,centerHole,offsets:plate.offsets,bbox:{x:plate.x,y:plate.y,z:p.thickness+(p.shank?p.hole_diameter+3:0)+(s>=18?8:s===4?1.5:0)}}; } export function capProfile(values: ParameterValues) { const p=values as Record, bore=(p.needle_diameter-p.grip)/2, outer=p.needle_diameter/2+p.wall; const plate=layout(p.set_count,outer*2,outer*2,3); return {bore,outer,...plate,bbox:{x:plate.x,y:plate.y,z:p.depth+2.4}}; } export function gaugeOutline(shape: number): Point[] { if(shape===2) return radial(128,()=>56); if(shape===1) return [[-50,-45],[50,-45],[50,33],[38,45],[-50,45]]; return Array.from({length:4},(_,i)=>Array.from({length:13},(_,j)=>{const a=(i*90+j*7.5)*Math.PI/180;return [(i===0||i===3?44:-44)+6*Math.cos(a),(i<2?39:-39)+6*Math.sin(a)] as Point;})).flat(); } export function needleCaseProfile(id: string, values: ParameterValues) { const p=values as Record, wall=2.4, length=p.needle_length+12; const innerWidth=p.count*(p.needle_diameter+1.4)+2; const width=innerWidth+wall*2, height=Math.max(id==="needle_garage"?14:16,p.needle_diameter+10); const lidLength=p.lid===0?length-2.8:length+4.6, lidWidth=p.lid===0?width-3.2:width+4.6; return {length,width,height,wall,innerWidth,lidLength,lidWidth,lidY:width/2+6+lidWidth/2,bbox:{x:Math.max(length,lidLength),y:width+6+lidWidth,z:height}}; } export function fiberToolDimensions(id:string,values:ParameterValues) { const p=values as Record; if(id==="custom_buttons")return buttonProfile(values).bbox; if(id==="knitting_needle_caps")return capProfile(values).bbox; if(id==="wpi_yarn_gauge")return {x:p.shape===2?112:100,y:p.shape===2?112:90,z:p.thickness}; if(id==="extrusion_corner_vertex")return {x:p.profile*2,y:p.profile*2,z:p.profile*2}; return needleCaseProfile(id,values).bbox; } export function validateFiberTool(id:string,values:ParameterValues) { const p=values as Record; if(id==="custom_buttons") { const b=buttonProfile(values),holes=p.shank?b.extraHoles:[...b.holes,...b.extraHoles]; if(holes.some(h=>!insideOutline([h.x,h.y],b.outline)||edgeDistance([h.x,h.y],b.outline)0&&Math.hypot(h.x,h.y)holes.slice(i+1).some(o=>Math.hypot(h.x-o.x,h.y-o.y)!TEXT_CHARACTERS.includes(String.fromCharCode(n))))throw new Error("Personalization accepts A-Z, 0-9, spaces, hyphen, plus and period."); if(id==="double_pointed_needle_box"||id==="needle_garage") { const c=needleCaseProfile(id,values); if(c.height-3.6-p.project_slot!Number.isFinite(v)||v<=0||v>LIMITS.maxDimensionMm+1e-6))throw new Error("This layout exceeds the A1's 245 mm working area on its 256 mm bed. Reduce dimensions or set count."); } export function fiberToolMetrics(id:string,values:ParameterValues) { const p=values as Record,bbox=fiberToolDimensions(id,values); let volumeMm3=0,surfaceAreaMm2=0; if(id==="custom_buttons") { const b=buttonProfile(values),area=polygonArea(b.outline)-Math.PI*b.centerHole**2-(p.shank?0:b.holes.reduce((sum,h)=>sum+Math.PI*h.radius**2,0))-b.extraHoles.reduce((sum,h)=>sum+Math.PI*h.radius**2,0); volumeMm3=(area*p.thickness+(p.shank?(p.hole_diameter+4)*3*(p.hole_diameter+3)-Math.PI*(p.hole_diameter/2)**2*3:0)+(p.style===4?p.diameter**2*.24:0)+(p.style>=18?4*Math.PI*Math.max(.9,p.diameter*.04)**2*8:0))*p.quantity; surfaceAreaMm2=(2*area+polygonPerimeter(b.outline)*p.thickness)*p.quantity; } else if(id==="knitting_needle_caps") { const c=capProfile(values); volumeMm3=Math.PI*(c.outer**2*(p.depth+2.4)-c.bore**2*p.depth)*p.set_count; surfaceAreaMm2=2*Math.PI*(c.outer*(p.depth+2.4)+c.bore*p.depth+c.outer**2)*p.set_count; } else if(id==="wpi_yarn_gauge") { const area=polygonArea(gaugeOutline(p.shape))-18*25.4; volumeMm3=area*p.thickness-180;surfaceAreaMm2=area*2+polygonPerimeter(gaugeOutline(p.shape))*p.thickness; } else if(id==="extrusion_corner_vertex") { const s=p.profile*2,t=p.thickness,n=p.ways; volumeMm3=s*s*t*n-(n===3?3:1)*s*t*t+(n===3?t**3:0)+3*(s-t)**2; volumeMm3-=n*2*Math.PI*((p.bolt+.5)/2)**2*t; surfaceAreaMm2=n*2*s*s+6*s*t; } else { const c=needleCaseProfile(id,values),{length:l,width:w,height:h,wall:t}=c; volumeMm3=l*w*t+(2*l+2*w-4*t)*(h-t)*t-(l-18)*t*(p.project_slot+3.6); if(id==="double_pointed_needle_box")volumeMm3+=(p.count-1)*(l-2*t)*.8*1.2; volumeMm3+=p.lid===0?c.lidLength*c.lidWidth*2.4-(2*(l-2*t)*1.1*2.8+(w-2*t)*t*3.6):c.lidLength*c.lidWidth*2.2+(2*c.lidLength+2*c.lidWidth-8)*2*5-(l-18)*2*4.8; surfaceAreaMm2=4*l*w+4*(l+w)*h; } return {volumeMm3,surfaceAreaMm2,bbox}; } export function fiberToolPrintability(id:string,values:ParameterValues) { const p=values as Record,bbox=fiberToolDimensions(id,values); const instruction=id==="custom_buttons"?`${p.quantity} buttons per set. ${p.shank?"Rear shanks replace the face sewing holes; print face-down and tune support for relief.":"Print flat with the holes vertical."} ${p.style>=18?"Four raised pegs and a center cord opening make a miniature spool loom; 30 mm or larger is recommended.":""}` :id==="knitting_needle_caps"?`${p.set_count} caps per set. TPU recommended and supplied: the bore is ${p.grip.toFixed(2)} mm smaller than the needle for a flexible grip. Print closed end down. Measure the needle shaft in mm; fit varies by material.` :id==="wpi_yarn_gauge"?"Print flat at 100% scale. The open wrap window is exactly 25.4 mm (1 inch). Wrap adjacent strands without stretching or overlap. Engraved WPI ranges are approximate; confirm with a swatch." :id==="extrusion_corner_vertex"?`${p.profile} × ${p.profile} extrusion, ${p.ways}-way corner, M${p.bolt} clearance holes for separately supplied T-nuts and screws. Print base-down; use PETG for workshop fixtures. Confirm your extrusion slot and fastener dimensions.` :`${p.count} needles, ${p.needle_length} mm long × ${p.needle_diameter} mm shaft. Print the tray and ${p.lid===0?"slide lid":"snap lid"} flat, as laid out. The ${p.project_slot} mm side opening lets the project remain on the needles. ${p.lid===0?"Slide the lid through the lowered end wall.":"Flip the lid onto the tray; align its side opening with the project. End tongues engage matching detents. PETG recommended."} Allowance is 0.3 mm per fitted side; test the fit before loading needles.`; return {fits:Object.values(bbox).every(v=>v>0&&v<=LIMITS.maxDimensionMm),instruction,notes:"Original design by Devan / 3DPrinterVerse. Physical print testing is pending. Prices include the entire set and both case parts, using estimated solid volume."}; }