diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 037524d..0a61849 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -147,6 +147,7 @@ const ROUTES = [ '/app-shop', '/diy', '/diy/deck-cost', + '/diy/deck-designer', '/review', '/card', '/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index aef3c2d..541c760 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/DeckDesigner.tsx b/src/components/DeckDesigner.tsx new file mode 100644 index 0000000..c359ab5 --- /dev/null +++ b/src/components/DeckDesigner.tsx @@ -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('skatespots:deck-design:v1', DEFAULT_DECK); + const [busy, setBusy] = useState(false); + + const p = useMemo(() => ({ ...DEFAULT_DECK, ...raw }), [raw]); + const patch = (next: Partial) => 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 }) => ( + + ); + + return ( +
+ {/* Presets */} +
+ {t('diyUi.designer.presets')} + {DECK_PRESETS.map((preset) => ( + + ))} +
+ +
+ {/* Inputs */} +
+
+

{t('diyUi.designer.dimensions')}

+
+ {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)}″` })} +
+

+ {t('diyUi.designer.length')} {totalLen.toFixed(0)} mm ({inches(totalLen)}″) +

+
+ +
+

{t('diyUi.designer.shape')}

+
+ {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' })} +
+
+ +
+

{t('diyUi.designer.kicks')}

+
+ {field('tailKick', t('diyUi.designer.tailKick'), { min: 0, max: 40, hint: '°' })} + {field('noseKick', t('diyUi.designer.noseKick'), { min: 0, max: 40, hint: '°' })} +
+
+ +
+

{t('diyUi.designer.hardware')}

+
+ {(['new', 'old'] as const).map((pattern) => ( + + ))} +
+

{t('diyUi.designer.holeSpacing')}

+
+
+ + {/* Preview + export */} +
+
+
+

{t('diyUi.designer.topView')}

+ {Math.round(totalLen)} × {Math.round(p.width)} mm +
+ + + + {holes.map((h, i) => ( + + ))} + +
+ +
+
+

{t('diyUi.designer.sideView')}

+ +
+
+

{t('diyUi.designer.concaveSection')}

+ +
+
+ +
+

{t('diyUi.designer.export')}

+
+ + + + +
+

{t('diyUi.designer.printNote')}

+

{t('diyUi.designer.note')}

+
+
+
+
+ ); +} + +/** 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 ( + + + + + ); +} + +/** 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 ( + + + + + {dip} mm concave · {Math.round(W)} mm + + + ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5e450c9..5ff599b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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.' }, diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 0d1bf0a..f714073 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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.' }, diff --git a/src/lib/deckDesign.ts b/src/lib/deckDesign.ts new file mode 100644 index 0000000..0411d49 --- /dev/null +++ b/src/lib/deckDesign.ts @@ -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 = { + // 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) => ``) + .join(''); + const labelY = -W / 2 - 2; + const scaleY = W / 2 + M - 4; + return ` + + + + ${holes} + + + + + + 100 mm + ${r(totalLen)} × ${r(W)} mm · wheelbase ${r(p.wheelbase)} mm · 1:1 — print at 100% + +`; +} + +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'); +} diff --git a/src/lib/diyGuides.ts b/src/lib/diyGuides.ts index c765fe1..bf78f38 100644 --- a/src/lib/diyGuides.ts +++ b/src/lib/diyGuides.ts @@ -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 ───────────────────────────────────────────── { diff --git a/src/pages/DeckDesignerPage.tsx b/src/pages/DeckDesignerPage.tsx new file mode 100644 index 0000000..439e366 --- /dev/null +++ b/src/pages/DeckDesignerPage.tsx @@ -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 ( +
+ + + {t('diyUi.backToDiy')} + + +
+

+ + {t('diyUi.designer.title')} +

+

{t('diyUi.designer.intro')}

+
+ + +
+ ); +}