New /diy/deck-designer tool. src/lib/deckDesign.ts holds the pure geometry (outline, waist, rounded ends, kicks, concave) and the exporters; the component renders a live top view, side profile and concave section with presets, saved locally. Exports: true-size SVG, a tiled 1:1 PDF (A4/Letter, split across sheets with corner registration crosses), and an R12 DXF (outline polyline + 3/16" truck holes + centreline, $INSUNITS=4). Truck patterns: New school 53.975x41.275 mm and Old school 63.5x41.275 mm. Deliberately 2D: this is the outline a builder traces and cuts, not a SK8CAD clone (SK8CAD is proprietary; only its JSCAD engine is MIT, and we copied nothing).
206 lines
8.3 KiB
TypeScript
206 lines
8.3 KiB
TypeScript
/**
|
||
* 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<DeckHolePattern, { len: number; wid: number }> = {
|
||
// 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) => `<circle cx="${r(h.x)}" cy="${r(h.y)}" r="${DECK_HOLE_RADIUS}" fill="none" stroke="#000" stroke-width="0.3"/>`)
|
||
.join('');
|
||
const labelY = -W / 2 - 2;
|
||
const scaleY = W / 2 + M - 4;
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${r(vbW)}mm" height="${r(vbH)}mm" viewBox="${r(-M)} ${r(-W / 2 - M)} ${r(vbW)} ${r(vbH)}" shape-rendering="geometricPrecision">
|
||
<rect x="${r(-M)}" y="${r(-W / 2 - M)}" width="${r(vbW)}" height="${r(vbH)}" fill="#fff"/>
|
||
<line x1="0" y1="0" x2="${r(totalLen)}" y2="0" stroke="#cc0000" stroke-width="0.25" stroke-dasharray="6 4"/>
|
||
<path d="${deckPath(p)}" fill="none" stroke="#000" stroke-width="0.35"/>
|
||
<g fill="none" stroke="#000" stroke-width="0.3">${holes}</g>
|
||
<g stroke="#000" stroke-width="0.4">
|
||
<line x1="0" y1="${r(scaleY)}" x2="100" y2="${r(scaleY)}"/>
|
||
<line x1="0" y1="${r(scaleY - 2)}" x2="0" y2="${r(scaleY + 2)}"/>
|
||
<line x1="100" y1="${r(scaleY - 2)}" x2="100" y2="${r(scaleY + 2)}"/>
|
||
</g>
|
||
<text x="50" y="${r(scaleY - 3)}" font-size="5" text-anchor="middle" font-family="sans-serif">100 mm</text>
|
||
<text x="${r(M)}" y="${r(labelY)}" font-size="6" font-family="sans-serif">${r(totalLen)} × ${r(W)} mm · wheelbase ${r(p.wheelbase)} mm · 1:1 — print at 100%</text>
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
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');
|
||
}
|