SkateSpots/src/lib/deckDesign.ts
SkateSpots b247be13b0 DIY: parametric 2D deck designer with 1:1 printable template
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).
2026-10-05 11:16:34 +07:00

206 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
}