/** * Parametric 2D skateboard-deck geometry — a self-contained, offline tool. * * This is deliberately a **2D** designer (top-view outline + a printable 1:1 * template + DXF for CNC), NOT a clone of SK8CAD. SK8CAD is built on JSCAD and * is proprietary; we don't copy it. What we can own is the outline maths and a * printable template, which is what a home builder actually traces and cuts. * * Geometry model (all millimetres): * - x runs nose-ward from the tail tip (0) to the nose tip (`deckLength`). * - the deck is full width at each truck axle and dips slightly at the waist; * - each end is a rounded (superellipse) cap, so a template looks like a deck. * * Pure functions only — no React, no DOM — so the maths is easy to reason about. */ export type DeckHolePattern = 'new' | 'old'; export interface DeckParams { /** Maximum width (at the trucks), mm. */ width: number; /** Distance between the two truck axles, mm. */ wheelbase: number; /** Front axle to the nose tip, mm. */ nose: number; /** Rear axle to the tail tip, mm. */ tail: number; /** Fraction narrower at the centre (0–0.15). */ waist: number; /** Superellipse exponent for the rounded ends (2 = elliptical). */ endRound: number; /** Tail kick angle, degrees. */ tailKick: number; /** Nose kick angle, degrees. */ noseKick: number; /** Concave depth across the width, mm. */ concave: number; holePattern: DeckHolePattern; /** Deck thickness, mm. */ thickness: number; } export interface Pt { x: number; y: number } /** 3/16" (4.7625 mm) mounting hole → radius. */ export const DECK_HOLE_RADIUS = 2.38; /** Mounting-hole patterns: lengthwise × widthwise spacing, mm. */ export const DECK_HOLE_SPACING: Record = { // New school: 2.125" × 1.625" (the modern standard). new: { len: 53.975, wid: 41.275 }, // Old school: 2.5" × 1.625". old: { len: 63.5, wid: 41.275 }, }; export const DEFAULT_DECK: DeckParams = { width: 210, wheelbase: 368, nose: 232, tail: 212, waist: 0.05, endRound: 2, tailKick: 15, noseKick: 18, concave: 10, holePattern: 'new', thickness: 11, }; export interface DeckPreset extends DeckParams { name: string } export const DECK_PRESETS: DeckPreset[] = [ { name: 'Street popsicle · 8.25"', width: 210, wheelbase: 368, nose: 232, tail: 212, waist: 0.05, endRound: 2, tailKick: 15, noseKick: 18, concave: 10, holePattern: 'new', thickness: 11 }, { name: 'Cruiser · 8.5"', width: 216, wheelbase: 356, nose: 214, tail: 192, waist: 0.06, endRound: 2, tailKick: 16, noseKick: 20, concave: 8, holePattern: 'new', thickness: 11 }, { name: 'Old-school · 10"', width: 254, wheelbase: 406, nose: 190, tail: 166, waist: 0.03, endRound: 2.4, tailKick: 12, noseKick: 14, concave: 6, holePattern: 'old', thickness: 12 }, { name: 'Longboard · 38"', width: 245, wheelbase: 700, nose: 140, tail: 125, waist: 0.02, endRound: 2, tailKick: 10, noseKick: 12, concave: 6, holePattern: 'new', thickness: 12 }, { name: 'Fingerboard · 96 mm', width: 30, wheelbase: 60, nose: 18, tail: 18, waist: 0.05, endRound: 2, tailKick: 18, noseKick: 20, concave: 3, holePattern: 'new', thickness: 4 }, ]; /** Total tip-to-tip length. */ export function deckLength(p: DeckParams): number { return p.nose + p.wheelbase + p.tail; } /** Axle positions (rear then front) measured from the tail tip. */ export function deckAxles(p: DeckParams): [number, number] { return [p.tail, p.tail + p.wheelbase]; } function halfWidth(p: DeckParams, x: number, totalLen: number): number { const hw = p.width / 2; const e = Math.max(1.2, p.endRound); const cap = (u: number) => Math.pow(Math.max(0, 1 - Math.pow(Math.max(0, u), e)), 1 / e); const rT = Math.min(hw, p.tail * 0.95); const rN = Math.min(hw, p.nose * 0.95); let f = 1; if (x < rT) f = cap((rT - x) / rT); if (x > totalLen - rN) f = Math.min(f, cap((x - (totalLen - rN)) / rN)); const [ax, ay] = deckAxles(p); if (x > ax && x < ay) f *= 1 - p.waist * Math.sin((Math.PI * (x - ax)) / (ay - ax)); return hw * f; } /** Closed top-view outline, y centred on 0 (build the SVG path from this). */ export function deckOutline(p: DeckParams, samples = 200): Pt[] { const totalLen = deckLength(p); const top: Pt[] = []; const bottom: Pt[] = []; for (let i = 0; i <= samples; i++) { const x = (totalLen * i) / samples; const hw = halfWidth(p, x, totalLen); top.push({ x, y: -hw }); bottom.push({ x, y: hw }); } const pts = [...top]; // Bottom edge back the other way; skip the tips (already in `top`). for (let i = samples - 1; i >= 1; i--) pts.push(bottom[i]); return pts; } /** Four mounting holes per truck. */ export function deckHoles(p: DeckParams): Pt[] { const sp = DECK_HOLE_SPACING[p.holePattern]; const out: Pt[] = []; for (const ax of deckAxles(p)) { for (const dx of [-sp.len / 2, sp.len / 2]) { for (const dy of [-sp.wid / 2, sp.wid / 2]) out.push({ x: ax + dx, y: dy }); } } return out; } /** Tension polyline for the profile: tail tip → tail axle → nose axle → nose tip. */ export function deckSidePoints(p: DeckParams): Pt[] { const totalLen = deckLength(p); const tailRise = p.tail * Math.tan((p.tailKick * Math.PI) / 180); const noseRise = p.nose * Math.tan((p.noseKick * Math.PI) / 180); return [ { x: 0, y: tailRise }, { x: p.tail, y: 0 }, { x: totalLen - p.nose, y: 0 }, { x: totalLen, y: noseRise }, ]; } const r = (n: number) => Number(n.toFixed(3)); /** SVG `d` string for the top-view outline. */ export function deckPath(p: DeckParams): string { const pts = deckOutline(p); return `M ${pts.map((pt) => `${r(pt.x)} ${r(pt.y)}`).join(' L ')} Z`; } /** * Standalone SVG of the deck at **true physical size** (width/height in mm), so * a browser prints it 1:1 at 100% (or tiles it itself). This is the vector * master for CNC/laser and the reference for the label. */ export function deckSvg(p: DeckParams): string { const totalLen = deckLength(p); const W = p.width; const M = 8; const vbW = totalLen + M * 2; const vbH = W + M * 2; const holes = deckHoles(p) .map((h) => ``) .join(''); const labelY = -W / 2 - 2; const scaleY = W / 2 + M - 4; return ` ${holes} 100 mm ${r(totalLen)} × ${r(W)} mm · wheelbase ${r(p.wheelbase)} mm · 1:1 — print at 100% `; } const d4 = (n: number) => n.toFixed(4); /** Minimal R12 ASCII DXF (mm) — outline polyline + mounting holes + centreline. */ export function deckDxf(p: DeckParams): string { const pts = deckOutline(p); const holes = deckHoles(p); const totalLen = deckLength(p); const out: string[] = [ '0', 'SECTION', '2', 'HEADER', '9', '$INSUNITS', '70', '4', // 4 = millimetres '9', '$MEASUREMENT', '70', '1', // metric '0', 'ENDSEC', '0', 'SECTION', '2', 'ENTITIES', // Outline (closed polyline). '0', 'POLYLINE', '8', 'OUTLINE', '66', '1', '70', '1', ...pts.flatMap((pt) => ['0', 'VERTEX', '8', 'OUTLINE', '10', d4(pt.x), '20', d4(pt.y)]), '0', 'SEQEND', // Mounting holes. ...holes.flatMap((h) => ['0', 'CIRCLE', '8', 'HOLES', '10', d4(h.x), '20', d4(h.y), '40', d4(DECK_HOLE_RADIUS)]), // Centreline. '0', 'LINE', '8', 'CENTER', '10', '0.0000', '20', '0.0000', '11', d4(totalLen), '21', '0.0000', '0', 'ENDSEC', '0', 'EOF', '', ]; return out.join('\n'); }