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).
This commit is contained in:
SkateSpots 2026-10-05 11:16:34 +07:00
parent a55aa7b53b
commit b247be13b0
8 changed files with 597 additions and 0 deletions

View file

@ -147,6 +147,7 @@ const ROUTES = [
'/app-shop',
'/diy',
'/diy/deck-cost',
'/diy/deck-designer',
'/review',
'/card',
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',

View file

@ -20,6 +20,7 @@ const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
@ -145,6 +146,7 @@ export function AppRouter() {
<Route path="/app-shop" element={<AppShopPage />} />
<Route path="/diy" element={<DiyPage />} />
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
<Route path="/review" element={<AdminReviewPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} />

View file

@ -0,0 +1,282 @@
import { useMemo, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Download, FileText } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import {
DECK_HOLE_RADIUS,
DECK_PRESETS,
DEFAULT_DECK,
deckDxf,
deckHoles,
deckLength,
deckOutline,
deckPath,
deckSidePoints,
deckSvg,
type DeckParams,
} from '@/lib/deckDesign';
/**
* Parametric 2D deck designer — outline + printable 1:1 template + DXF.
*
* Deliberately 2D (a template you trace and cut), not a 3D CAD clone. State is
* local-only (localStorage). See `src/lib/deckDesign.ts` for the geometry.
*/
export function DeckDesigner() {
const { t } = useTranslation();
const { toast } = useToast();
const [raw, setRaw] = useLocalStorage<DeckParams>('skatespots:deck-design:v1', DEFAULT_DECK);
const [busy, setBusy] = useState(false);
const p = useMemo<DeckParams>(() => ({ ...DEFAULT_DECK, ...raw }), [raw]);
const patch = (next: Partial<DeckParams>) => setRaw({ ...DEFAULT_DECK, ...raw, ...next });
const totalLen = deckLength(p);
const holes = useMemo(() => deckHoles(p), [p]);
const inches = (mm: number) => (mm / 25.4).toFixed(2);
const label = 'text-[11px] font-semibold text-gray-400';
const download = (name: string, text: string, mime: string) => {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
const exportSvg = () => download(`deck-${Math.round(totalLen)}x${Math.round(p.width)}mm.svg`, deckSvg(p), 'image/svg+xml');
const exportDxf = () => download(`deck-${Math.round(totalLen)}x${Math.round(p.width)}mm.dxf`, deckDxf(p), 'application/dxf');
/** Tiled 1:1 PDF: split the outline across A4/Letter sheets with registration marks. */
const exportPdf = async (paper: 'a4' | 'letter') => {
setBusy(true);
try {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ unit: 'mm', format: paper, orientation: 'landscape' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const M = 10;
const tileW = pageW - 2 * M;
const tileH = pageH - 2 * M;
const W = p.width;
const pts = deckOutline(p);
const cols = Math.max(1, Math.ceil(totalLen / tileW));
const rows = Math.max(1, Math.ceil(W / tileH));
const tx = (x: number, c: number) => M + x - c * tileW;
const ty = (y: number, r: number) => M + (y + W / 2) - r * tileH;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r > 0 || c > 0) doc.addPage();
// Outline, drawn as deltas (doc.lines) so no path-API dependency.
const deltas: [number, number][] = [];
for (let i = 1; i < pts.length; i++) deltas.push([pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y]);
deltas.push([pts[0].x - pts[pts.length - 1].x, pts[0].y - pts[pts.length - 1].y]);
doc.setDrawColor(0); doc.setLineWidth(0.25);
doc.lines(deltas, tx(pts[0].x, c), ty(pts[0].y, r), [1, 1], 'S', true);
for (const h of holes) doc.circle(tx(h.x, c), ty(h.y, r), DECK_HOLE_RADIUS, 'S');
// Registration crosses at the tile corners (adjacent pages align on them).
doc.setDrawColor(150); doc.setLineWidth(0.15);
for (const [px, py] of [[M, M], [M + tileW, M], [M, M + tileH], [M + tileW, M + tileH]] as [number, number][]) {
doc.line(px - 3, py, px + 3, py);
doc.line(px, py - 3, px, py + 3);
}
doc.setFontSize(7); doc.setTextColor(110);
doc.text(`Deck template · ${Math.round(totalLen)} × ${Math.round(W)} mm · 1:1 · print at 100% · tile ${r + 1},${c + 1} of ${rows}×${cols}`, M, pageH - 4);
}
}
doc.save(`deck-template-1to1-${paper}.pdf`);
} catch {
toast({ title: 'Could not build the PDF', variant: 'destructive' });
} finally {
setBusy(false);
}
};
const field = (key: keyof DeckParams, labelText: string, opts?: { min?: number; max?: number; step?: number; hint?: string }) => (
<label className="space-y-1">
<span className={label}>{labelText}</span>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={opts?.min}
max={opts?.max}
step={opts?.step ?? 1}
value={p[key] as number}
onChange={(e) => patch({ [key]: Number(e.target.value) } as Partial<DeckParams>)}
className="h-8 bg-black/40 border-white/10"
/>
{opts?.hint && <span className="shrink-0 text-[10px] text-gray-500">{opts.hint}</span>}
</div>
</label>
);
return (
<div className="space-y-5">
{/* Presets */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.presets')}</span>
{DECK_PRESETS.map((preset) => (
<button
key={preset.name}
type="button"
onClick={() => setRaw({ ...DEFAULT_DECK, ...preset })}
className="min-h-0 rounded-full border border-white/15 px-3 py-1 text-xs font-semibold text-gray-300 hover:border-[#ccff00]/60 hover:text-white"
>
{preset.name}
</button>
))}
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)]">
{/* Inputs */}
<div className="space-y-4">
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.dimensions')}</p>
<div className="grid grid-cols-2 gap-3">
{field('width', t('diyUi.designer.width'), { min: 20, hint: `≈ ${inches(p.width)}″` })}
{field('wheelbase', t('diyUi.designer.wheelbase'), { min: 30, hint: `≈ ${inches(p.wheelbase)}″` })}
{field('nose', t('diyUi.designer.nose'), { min: 5, hint: `≈ ${inches(p.nose)}″` })}
{field('tail', t('diyUi.designer.tail'), { min: 5, hint: `≈ ${inches(p.tail)}″` })}
</div>
<p className="text-[11px] text-gray-500">
{t('diyUi.designer.length')} <span className="font-bold text-gray-300">{totalLen.toFixed(0)} mm</span> ({inches(totalLen)}″)
</p>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.shape')}</p>
<div className="grid grid-cols-2 gap-3">
{field('waist', t('diyUi.designer.waist'), { min: 0, max: 0.15, step: 0.01 })}
{field('endRound', t('diyUi.designer.endRound'), { min: 1.2, max: 4, step: 0.1 })}
{field('concave', t('diyUi.designer.concave'), { min: 0, hint: 'mm' })}
{field('thickness', t('diyUi.designer.thickness'), { min: 2, hint: 'mm' })}
</div>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.kicks')}</p>
<div className="grid grid-cols-2 gap-3">
{field('tailKick', t('diyUi.designer.tailKick'), { min: 0, max: 40, hint: '°' })}
{field('noseKick', t('diyUi.designer.noseKick'), { min: 0, max: 40, hint: '°' })}
</div>
</section>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.hardware')}</p>
<div className="flex gap-2">
{(['new', 'old'] as const).map((pattern) => (
<button
key={pattern}
type="button"
onClick={() => patch({ holePattern: pattern })}
className={`min-h-0 flex-1 rounded-md border px-2 py-1.5 text-xs font-semibold ${p.holePattern === pattern ? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white' : 'border-white/15 text-gray-300 hover:bg-white/5'}`}
>
{t(`diyUi.designer.hole${pattern === 'new' ? 'New' : 'Old'}`)}
</button>
))}
</div>
<p className="text-[10px] text-gray-500">{t('diyUi.designer.holeSpacing')}</p>
</section>
</div>
{/* Preview + export */}
<div className="space-y-4">
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.topView')}</p>
<span className="text-[10px] text-gray-500">{Math.round(totalLen)} × {Math.round(p.width)} mm</span>
</div>
<svg viewBox={`${-8} ${-p.width / 2 - 8} ${totalLen + 16} ${p.width + 16}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<line x1={0} y1={0} x2={totalLen} y2={0} stroke="#7f1d1d" strokeWidth={1} strokeDasharray="6 4" vectorEffect="non-scaling-stroke" />
<path d={deckPath(p)} fill="rgba(204,255,0,0.07)" stroke="#ccff00" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
{holes.map((h, i) => (
<circle key={i} cx={h.x} cy={h.y} r={DECK_HOLE_RADIUS} fill="none" stroke="#f43f5e" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
))}
</svg>
</section>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<p className="mb-2 text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.sideView')}</p>
<SideView p={p} />
</section>
<section className="rounded-xl border border-white/10 bg-black/30 p-3">
<p className="mb-2 text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.concaveSection')}</p>
<CrossSection p={p} />
</section>
</div>
<section className="space-y-3 rounded-xl border border-white/10 bg-black/30 p-3">
<p className="text-[11px] font-black uppercase tracking-wider text-gray-500">{t('diyUi.designer.export')}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={exportSvg} className="gap-1.5 bg-[#ccff00] text-black hover:bg-[#b8e600]">
<Download className="h-3.5 w-3.5" /> SVG
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void exportPdf('a4')} className="gap-1.5 border-white/20">
<FileText className="h-3.5 w-3.5" /> {t('diyUi.designer.pdfA4')}
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void exportPdf('letter')} className="gap-1.5 border-white/20">
<FileText className="h-3.5 w-3.5" /> {t('diyUi.designer.pdfLetter')}
</Button>
<Button size="sm" variant="outline" onClick={exportDxf} className="gap-1.5 border-white/20">
<Download className="h-3.5 w-3.5" /> DXF
</Button>
</div>
<p className="text-[11px] text-gray-500">{t('diyUi.designer.printNote')}</p>
<p className="text-[11px] text-gray-500">{t('diyUi.designer.note')}</p>
</section>
</div>
</div>
</div>
);
}
/** Small profile drawing: flat middle, kicked nose and tail. */
function SideView({ p }: { p: DeckParams }) {
const sp = deckSidePoints(p);
const totalLen = deckLength(p);
const maxRise = Math.max(...sp.map((s) => s.y), 1);
const H = maxRise + p.thickness + 4;
const flip = (y: number) => H - y;
const top = sp.map((s) => `${s.x} ${flip(s.y)}`).join(' L ');
const bottom = [...sp].reverse().map((s) => `${s.x} ${flip(s.y - p.thickness)}`).join(' L ');
return (
<svg viewBox={`${-4} ${-4} ${totalLen + 8} ${H + 8}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<line x1={0} y1={flip(0)} x2={totalLen} y2={flip(0)} stroke="#374151" strokeWidth={1} vectorEffect="non-scaling-stroke" />
<path d={`M ${top} L ${bottom} Z`} fill="rgba(204,255,0,0.10)" stroke="#ccff00" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
</svg>
);
}
/** Small cross-section drawing: the concave arc across the width. */
function CrossSection({ p }: { p: DeckParams }) {
const W = p.width;
const dip = p.concave;
const samples = 40;
const pts: string[] = [];
for (let i = 0; i <= samples; i++) {
const x = (W * i) / samples;
const t = (2 * x) / W - 1; // -1..1
const y = -dip * (1 - t * t); // parabola, deepest at the centre
pts.push(`${x} ${y}`);
}
const H = Math.max(dip, 2) + 4;
const d = `M 0 0 L ${pts.join(' L ')} L ${W} 0`;
return (
<svg viewBox={`${-4} ${-H} ${W + 8} ${H + 8}`} className="h-auto w-full" preserveAspectRatio="xMidYMid meet">
<path d={d} fill="rgba(204,255,0,0.10)" stroke="#ccff00" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
<line x1={0} y1={0} x2={W} y2={0} stroke="#374151" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
<text x={W / 2} y={-H + 3} fontSize={Math.max(4, W / 30)} textAnchor="middle" fill="#9ca3af" fontFamily="sans-serif">
{dip} mm concave · {Math.round(W)} mm
</text>
</svg>
);
}

View file

@ -799,6 +799,37 @@ diyUi: {
title: 'Deck cost calculator',
intro: 'What a deck costs to make — labour, equipment, materials, process time, packaging and margin — and a fair selling price. Saved on this device.',
},
designer: {
title: 'Deck designer',
intro: 'Shape a skateboard or longboard outline, then export a printable 1:1 template, an SVG or a DXF for CNC. Saved on this device.',
presets: 'Presets',
dimensions: 'Dimensions',
shape: 'Shape',
kicks: 'Kicks',
hardware: 'Truck holes',
width: 'Max width',
wheelbase: 'Wheelbase',
nose: 'Nose length',
tail: 'Tail length',
length: 'Total length',
waist: 'Waist',
endRound: 'End roundness',
concave: 'Concave',
thickness: 'Thickness',
tailKick: 'Tail kick',
noseKick: 'Nose kick',
holeNew: 'New school (2.125″)',
holeOld: 'Old school (2.5″)',
holeSpacing: 'New school is 2.125″ × 1.625″ (54 × 41 mm); old school is 2.5″ × 1.625″. 3/16″ mounting holes.',
topView: 'Top view',
sideView: 'Side profile',
concaveSection: 'Concave section',
export: 'Export',
pdfA4: '1:1 PDF · A4',
pdfLetter: '1:1 PDF · Letter',
printNote: 'Print the PDF at 100% (no “fit to page”), then tape the tiles using the corner crosses. The SVG is true-size too.',
note: 'This is the 2D outline you trace and cut. For full 3D shapes and mold design, a real CAD tool is still the right choice.',
},
source: 'Source',
comingSoon: 'Coming soon',
footer: 'Step-by-step guides are on the way. For now: find the people who build in The Lab, map your spot, and buy or sell in the Marketplace. Work safely — pads, gloves and eye protection.',
@ -813,6 +844,7 @@ diyUi: {
ossBuild: { title: 'Build a skateboard', blurb: 'Step-by-step deck fabrication — veneer, molds, presses and finishing.' },
sk8cad: { title: 'SK8CAD', blurb: 'Open-source CAD for deck shapes, concave and mold design.' },
deckCost: { title: 'Deck cost & pricing', blurb: 'Work out materials, labour and a fair selling price before you build.' },
deckDesigner: { title: 'Deck designer', blurb: 'Shape an outline and print a 1:1 template — or export SVG/DXF for CNC.' },
ledgeBox: { title: 'Build a ledge or box', blurb: 'A beginner-friendly wooden obstacle you can build in a weekend.' },
quarterpipe: { title: 'Build a quarterpipe', blurb: 'Ramp plans, transition and coping for a backyard mini.' },
flatbar: { title: 'Build a flatbar', blurb: 'Cheap, portable and the best way to learn rails.' },

View file

@ -799,6 +799,37 @@ diyUi: {
title: 'Calculadora de coste de tabla',
intro: 'Lo que cuesta hacer una tabla — mano de obra, equipo, materiales, tiempo de proceso, embalaje y margen — y un precio de venta justo. Se guarda en este dispositivo.',
},
designer: {
title: 'Diseñador de tablas',
intro: 'Diseña el contorno de un skate o longboard y exporta una plantilla 1:1 para imprimir, un SVG o un DXF para CNC. Se guarda en este dispositivo.',
presets: 'Ajustes predefinidos',
dimensions: 'Dimensiones',
shape: 'Forma',
kicks: 'Kicks',
hardware: 'Taladros de ejes',
width: 'Anchura máxima',
wheelbase: 'Distancia entre ejes',
nose: 'Largo del nose',
tail: 'Largo del tail',
length: 'Largo total',
waist: 'Cintura',
endRound: 'Redondeo de puntas',
concave: 'Cóncavo',
thickness: 'Grosor',
tailKick: 'Kick trasero',
noseKick: 'Kick delantero',
holeNew: 'New school (2,125″)',
holeOld: 'Old school (2,5″)',
holeSpacing: 'New school es 2,125″ × 1,625″ (54 × 41 mm); old school es 2,5″ × 1,625″. Taladros de 3/16″.',
topView: 'Vista superior',
sideView: 'Perfil lateral',
concaveSection: 'Sección del cóncavo',
export: 'Exportar',
pdfA4: 'PDF 1:1 · A4',
pdfLetter: 'PDF 1:1 · Letter',
printNote: 'Imprime el PDF al 100% (sin «ajustar a la página») y une las hojas con las cruces de las esquinas. El SVG también es tamaño real.',
note: 'Este es el contorno 2D que calcas y cortas. Para formas y moldes en 3D, una herramienta CAD completa sigue siendo lo adecuado.',
},
source: 'Fuente',
comingSoon: 'Próximamente',
footer: 'Las guías paso a paso están en camino. Por ahora: encuentra a quien construye en The Lab, mapea tu spot y compra o vende en el Marketplace. Trabaja con seguridad: protecciones, guantes y gafas.',
@ -813,6 +844,7 @@ diyUi: {
ossBuild: { title: 'Construye un skate', blurb: 'Fabricación de tablas paso a paso: chapa, moldes, prensas y acabado.' },
sk8cad: { title: 'SK8CAD', blurb: 'CAD de código abierto para formas, cóncavo y moldes.' },
deckCost: { title: 'Coste y precio de la tabla', blurb: 'Calcula materiales, mano de obra y un precio justo antes de construir.' },
deckDesigner: { title: 'Diseñador de tablas', blurb: 'Diseña el contorno e imprime una plantilla 1:1, o exporta SVG/DXF para CNC.' },
ledgeBox: { title: 'Haz un bordillo o cajón', blurb: 'Un obstáculo de madera para principiantes que puedes montar en un fin de semana.' },
quarterpipe: { title: 'Haz un quarterpipe', blurb: 'Planos, transición y coping para un mini de patio.' },
flatbar: { title: 'Haz un flatbar', blurb: 'Barato, portátil y la mejor forma de aprender a deslizar.' },

206
src/lib/deckDesign.ts Normal file
View file

@ -0,0 +1,206 @@
/**
* 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');
}

View file

@ -65,6 +65,14 @@ export const DIY_CARDS: DiyCard[] = [
blurbKey: 'diyUi.cards.deckCost.blurb',
to: '/diy/deck-cost',
},
{
id: 'deckDesigner',
category: 'boards',
emoji: '📐',
titleKey: 'diyUi.cards.deckDesigner.title',
blurbKey: 'diyUi.cards.deckDesigner.blurb',
to: '/diy/deck-designer',
},
// ── Ramps & wooden obstacles ─────────────────────────────────────────────
{

View file

@ -0,0 +1,34 @@
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { ArrowLeft, Ruler } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { DeckDesigner } from '@/components/DeckDesigner';
/** DIY tool page — the parametric 2D deck designer / 1:1 template. */
export default function DeckDesignerPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Deck Designer — SkateSpots DIY',
description: 'Design a skateboard or longboard outline, then export a printable 1:1 template, SVG or DXF — concave, kicks, wheelbase and truck holes included.',
});
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
<Link to="/diy" className="mb-4 inline-flex items-center text-sm text-muted-foreground transition-colors hover:text-foreground">
<ArrowLeft className="mr-2 h-4 w-4" />
{t('diyUi.backToDiy')}
</Link>
<header className="mb-6">
<h1 className="flex items-center gap-2 text-2xl md:text-3xl font-black uppercase tracking-tight text-white">
<Ruler className="h-6 w-6 text-[#ccff00]" />
{t('diyUi.designer.title')}
</h1>
<p className="mt-2 max-w-2xl text-sm text-gray-400">{t('diyUi.designer.intro')}</p>
</header>
<DeckDesigner />
</div>
);
}