"use client"; import { useEffect, useRef, type ReactNode } from "react"; type BlueprintGridProps = { children?: ReactNode; className?: string; /** Text in the title block, bottom right. */ title?: string; /** Paper colour. */ paper?: string; /** Line colour. */ ink?: string; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; }; type Pt = [number, number]; type Label = { x: number; y: number; text: string }; type Stroke = { pts: Pt[]; len: number; bold?: boolean; dash?: boolean; label?: Label }; const CELL = 12; const TEETH = [24, 32, 40, 48]; /* Seconds to draft one sheet at speed 1. */ const DRAW_TIME = 16; const circle = (cx: number, cy: number, r: number): Pt[] => Array.from({ length: 49 }, (_, i) => [cx + r * Math.cos((i / 48) * Math.PI * 2), cy + r * Math.sin((i / 48) * Math.PI * 2)]); const stroke = (pts: Pt[], opts: Omit = {}): Stroke => { let len = 0; for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return { pts, len, ...opts }; }; /* 45 degree section lines, cut to a rectangle. */ function hatch(l: number, t: number, r: number, b: number, gap: number) { const out: Stroke[] = []; for (let c = l + t + gap; c < r + b; c += gap) { const x0 = Math.max(l, c - b); const x1 = Math.min(r, c - t); if (x1 > x0) out.push(stroke([[x0, c - x0], [x1, c - x1]])); } return out; } /* One sheet: a spur gear in front view and, when there is room, in section beside it. Sizes come from the tooth count, so the numbers on the drawing are the true ones for that gear. */ function draft(w: number, h: number, teeth: number) { const wide = w / h > 1.25; const u = Math.min(wide ? w * 0.5 : w, h) / 100; const cx = wide ? w * 0.34 : w / 2; const cy = h * 0.46; const R = 33 * u; const root = R * 0.86; const bore = R * 0.2; const size = 2.5; const tip = (teeth + 2) * size; const strokes: Stroke[] = []; const reach = wide ? w * 0.92 : cx + R + 6 * u; strokes.push(stroke([[cx - R - 6 * u, cy], [reach, cy]], { dash: true })); strokes.push(stroke([[cx, cy - R - 6 * u], [cx, cy + R + 6 * u]], { dash: true })); const pitch = (Math.PI * 2) / teeth; const tooth: Pt[] = []; for (let i = 0; i < teeth; i++) { const a = i * pitch; for (const [da, r] of [[0, root], [0.16, R], [0.38, R], [0.54, root], [1, root]] as const) { tooth.push([cx + r * Math.cos(a + da * pitch), cy + r * Math.sin(a + da * pitch)]); } } tooth.push(tooth[0]); strokes.push(stroke(tooth, { bold: true })); strokes.push(stroke(circle(cx, cy, bore), { bold: true })); strokes.push(stroke(circle(cx, cy, R * 0.52), { dash: true })); for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2 + 0.26; strokes.push(stroke(circle(cx + R * 0.52 * Math.cos(a), cy + R * 0.52 * Math.sin(a), R * 0.08), { bold: true })); } const under = cy + R + 9 * u; strokes.push(stroke([[cx - R, cy + R * 0.4], [cx - R, under + 3]])); strokes.push(stroke([[cx + R, cy + R * 0.4], [cx + R, under + 3]])); strokes.push(stroke([[cx - R, under], [cx + R, under]], { label: { x: cx - R + 4, y: under - 5, text: `Ø${tip}` } })); const hole: Pt = [cx + R * 0.52 * Math.cos(0.26 + (Math.PI * 5) / 3), cy + R * 0.52 * Math.sin(0.26 + (Math.PI * 5) / 3)]; strokes.push(stroke([hole, [cx + R * 0.9, cy - R * 1.02], [cx + R * 1.28, cy - R * 1.02]], { label: { x: cx + R * 0.92, y: cy - R * 1.02 - 5, text: `Z${teeth} M${size}` } })); if (wide) { const x = w * 0.66; const t = 16 * u; const web = t * 0.3; const y = (k: number) => cy + k * R; strokes.push(stroke([[cx, y(-1)], [x, y(-1)]], { dash: true })); strokes.push(stroke([[cx, y(1)], [x, y(1)]], { dash: true })); for (const s of [-1, 1]) { strokes.push( stroke( [ [x, y(s)], [x + t, y(s)], [x + t, y(s * 0.72)], [x + t - web, y(s * 0.72)], [x + t - web, y(s * 0.36)], [x + t, y(s * 0.36)], [x + t, y(s * 0.2)], [x, y(s * 0.2)], [x, y(s * 0.36)], [x + web, y(s * 0.36)], [x + web, y(s * 0.72)], [x, y(s * 0.72)], [x, y(s)], ], { bold: true }, ), ); const band = (a: number, b: number) => [Math.min(y(s * a), y(s * b)), Math.max(y(s * a), y(s * b))]; const [t1, b1] = band(1, 0.72); const [t2, b2] = band(0.72, 0.36); const [t3, b3] = band(0.36, 0.2); strokes.push(...hatch(x, t1, x + t, b1, 2.4 * u), ...hatch(x + web, t2, x + t - web, b2, 2.4 * u), ...hatch(x, t3, x + t, b3, 2.4 * u)); } const side = x + t + 8 * u; strokes.push(stroke([[x + t + 2, y(-1)], [side + 3, y(-1)]])); strokes.push(stroke([[x + t + 2, y(1)], [side + 3, y(1)]])); strokes.push(stroke([[side, y(-1)], [side, y(1)]], { label: { x: side + 5, y: cy - 5, text: `${teeth * size} PCD` } })); } return { strokes, total: strokes.reduce((sum, s) => sum + s.len, 0) }; } /* BlueprintGrid: cyanotype drafting paper. A drafting head traces a gear, section and dimensions line by line, then the sheet clears for the next size. The pointer reads coordinates off it. */ export function BlueprintGrid({ children, className = "", title = "SPUR GEAR", paper = "#0d3b78", ink = "#dcecff", speed = 1, }: BlueprintGridProps) { const rootRef = useRef(null); const canvasRef = useRef(null); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!root || !canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const sheet = document.createElement("canvas"); let w = 0; let h = 0; let raf = 0; let last = 0; let turn = 0; let sheetNo = 1; let plan = draft(1, 1, TEETH[0]); let done = 0; let hold = 0; let fade = 1; let aim: Pt | null = null; const paintPaper = () => { const g = sheet.getContext("2d"); if (!g) return; g.setTransform(1, 0, 0, 1, 0, 0); g.fillStyle = paper; g.fillRect(0, 0, w, h); g.strokeStyle = ink; for (const [every, alpha] of [[1, 0.06], [5, 0.13]]) { g.globalAlpha = alpha; g.beginPath(); for (let x = 0; x < w; x += CELL * every) g.rect(Math.round(x) + 0.5, -1, 0, h + 2); for (let y = 0; y < h; y += CELL * every) g.rect(-1, Math.round(y) + 0.5, w + 2, 0); g.stroke(); } // Sun-printed paper is never even: a soft bleach in the middle, darker at the edges. const age = g.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.hypot(w, h) / 1.7); age.addColorStop(0, "rgba(120,180,255,0.1)"); age.addColorStop(1, "rgba(2,14,40,0.42)"); g.globalAlpha = 1; g.fillStyle = age; g.fillRect(0, 0, w, h); g.globalAlpha = 0.5; g.strokeRect(8.5, 8.5, w - 17, h - 17); g.globalAlpha = 1; }; const tell = (l: Label) => { ctx.fillText(l.text, l.x, l.y); }; const draw = () => { ctx.drawImage(sheet, 0, 0, w, h); ctx.strokeStyle = ink; ctx.fillStyle = ink; ctx.lineJoin = "round"; ctx.font = `${Math.max(9, Math.min(w, h) * 0.022)}px ui-monospace, Menlo, Consolas, monospace`; ctx.globalAlpha = fade; let left = done; let pen: Pt = [w / 2, h / 2]; for (const s of plan.strokes) { if (left <= 0) break; ctx.lineWidth = s.bold ? 1.5 : 0.8; ctx.setLineDash(s.dash ? [12, 3, 2, 3] : []); ctx.beginPath(); ctx.moveTo(s.pts[0][0], s.pts[0][1]); pen = s.pts[0]; for (let i = 1; i < s.pts.length && left > 0; i++) { const [ax, ay] = s.pts[i - 1]; const [bx, by] = s.pts[i]; const leg = Math.hypot(bx - ax, by - ay); const t = Math.min(1, left / (leg || 1)); pen = [ax + (bx - ax) * t, ay + (by - ay) * t]; ctx.lineTo(pen[0], pen[1]); left -= leg; } ctx.stroke(); if (left > 0 && s.label) tell(s.label); } ctx.setLineDash([]); if (w >= 420) { const bw = 158; const bh = 48; const x = w - bw - 16; const y = h - bh - 16; ctx.lineWidth = 1; ctx.strokeRect(x, y, bw, bh); ctx.beginPath(); ctx.moveTo(x, y + 24); ctx.lineTo(x + bw, y + 24); ctx.moveTo(x + 104, y + 24); ctx.lineTo(x + 104, y + bh); ctx.stroke(); ctx.font = "10px ui-monospace, Menlo, Consolas, monospace"; ctx.fillText(title, x + 7, y + 16); ctx.fillText(`SHEET ${String(sheetNo).padStart(2, "0")}`, x + 7, y + 40); ctx.fillText("1:2", x + 114, y + 40); } ctx.globalAlpha = 0.3 * fade; ctx.lineWidth = 0.8; if (!still && done < plan.total) { // The drafting arm: one rule across the sheet and one down it, meeting at the pen. ctx.beginPath(); ctx.moveTo(0, pen[1]); ctx.lineTo(w, pen[1]); ctx.moveTo(pen[0], 0); ctx.lineTo(pen[0], h); ctx.stroke(); ctx.globalAlpha = fade; ctx.fillRect(pen[0] - 2, pen[1] - 2, 4, 4); } ctx.globalAlpha = 1; if (aim) { const [x, y] = aim; ctx.globalAlpha = 0.65; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; const read = `X${((x / CELL) * 5).toFixed(1)} Y${((y / CELL) * 5).toFixed(1)}`; const across = x + 10 + ctx.measureText(read).width > w - 12; ctx.textAlign = across ? "right" : "left"; ctx.fillText(read, x + (across ? -8 : 8), y < 28 ? y + 18 : y - 8); ctx.textAlign = "left"; } }; const nextSheet = () => { turn = (turn + 1) % TEETH.length; sheetNo += 1; plan = draft(w, h, TEETH[turn]); done = 0; hold = 0; fade = 1; }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0; last = now; if (done < plan.total) { done = Math.min(plan.total, done + (plan.total / DRAW_TIME) * dt); } else if (hold < 5) { hold += dt; } else { fade -= dt / 1.5; if (fade <= 0) nextSheet(); } draw(); raf = requestAnimationFrame(frame); }; const start = () => { if (still || raf) return; last = 0; raf = requestAnimationFrame(frame); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); w = canvas.clientWidth; h = canvas.clientHeight; if (!w || !h) return; for (const c of [canvas, sheet]) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); } paintPaper(); plan = draft(w, h, TEETH[turn]); // Part done on arrival, so the first look is a drawing, not an empty grid. done = still ? plan.total : plan.total * 0.45; draw(); }; const point = (e: PointerEvent) => { const box = canvas.getBoundingClientRect(); aim = [e.clientX - box.left, e.clientY - box.top]; if (still) draw(); }; const leave = () => { aim = null; if (still) draw(); }; // Redraw on size change; only animate while on screen. const ro = new ResizeObserver(resize); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); ro.observe(canvas); io.observe(canvas); root.addEventListener("pointermove", point); root.addEventListener("pointerleave", leave); return () => { stop(); ro.disconnect(); io.disconnect(); root.removeEventListener("pointermove", point); root.removeEventListener("pointerleave", leave); }; }, [title, paper, ink, speed]); return (
{children}
); }