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).
282 lines
14 KiB
TypeScript
282 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|