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:
parent
a55aa7b53b
commit
b247be13b0
8 changed files with 597 additions and 0 deletions
|
|
@ -147,6 +147,7 @@ const ROUTES = [
|
||||||
'/app-shop',
|
'/app-shop',
|
||||||
'/diy',
|
'/diy',
|
||||||
'/diy/deck-cost',
|
'/diy/deck-cost',
|
||||||
|
'/diy/deck-designer',
|
||||||
'/review',
|
'/review',
|
||||||
'/card',
|
'/card',
|
||||||
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
|
||||||
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
|
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
|
||||||
const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
|
||||||
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
|
||||||
|
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
|
||||||
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
|
||||||
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
|
||||||
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
|
||||||
|
|
@ -145,6 +146,7 @@ export function AppRouter() {
|
||||||
<Route path="/app-shop" element={<AppShopPage />} />
|
<Route path="/app-shop" element={<AppShopPage />} />
|
||||||
<Route path="/diy" element={<DiyPage />} />
|
<Route path="/diy" element={<DiyPage />} />
|
||||||
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
<Route path="/diy/deck-cost" element={<DeckCostPage />} />
|
||||||
|
<Route path="/diy/deck-designer" element={<DeckDesignerPage />} />
|
||||||
<Route path="/review" element={<AdminReviewPage />} />
|
<Route path="/review" element={<AdminReviewPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||||
|
|
|
||||||
282
src/components/DeckDesigner.tsx
Normal file
282
src/components/DeckDesigner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -799,6 +799,37 @@ diyUi: {
|
||||||
title: 'Deck cost calculator',
|
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.',
|
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',
|
source: 'Source',
|
||||||
comingSoon: 'Coming soon',
|
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.',
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
flatbar: { title: 'Build a flatbar', blurb: 'Cheap, portable and the best way to learn rails.' },
|
||||||
|
|
|
||||||
|
|
@ -799,6 +799,37 @@ diyUi: {
|
||||||
title: 'Calculadora de coste de tabla',
|
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.',
|
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',
|
source: 'Fuente',
|
||||||
comingSoon: 'Próximamente',
|
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.',
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
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.' },
|
flatbar: { title: 'Haz un flatbar', blurb: 'Barato, portátil y la mejor forma de aprender a deslizar.' },
|
||||||
|
|
|
||||||
206
src/lib/deckDesign.ts
Normal file
206
src/lib/deckDesign.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,14 @@ export const DIY_CARDS: DiyCard[] = [
|
||||||
blurbKey: 'diyUi.cards.deckCost.blurb',
|
blurbKey: 'diyUi.cards.deckCost.blurb',
|
||||||
to: '/diy/deck-cost',
|
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 ─────────────────────────────────────────────
|
// ── Ramps & wooden obstacles ─────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|
|
||||||
34
src/pages/DeckDesignerPage.tsx
Normal file
34
src/pages/DeckDesignerPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue