diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx index 8c609fc..d265df7 100644 --- a/src/components/interactives/KasutiEmbroidery.tsx +++ b/src/components/interactives/KasutiEmbroidery.tsx @@ -1,10 +1,18 @@ import confetti from 'canvas-confetti'; -import { RotateCcw, Undo2 } from 'lucide-react'; +import { Plus, RotateCcw, Undo2 } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -178,6 +186,215 @@ const PATTERNS: Pattern[] = [ [[1, 1], [0, 1]], ], }, + // Rose — central square with four corner-shared petal squares (pattern 1 from the + // reference sheet). Each corner of the central square carries a 2×2 petal. + { + id: 'rose', + title: 'Rose', + description: '36 stitches', + bbox: [7, 7], + edges: [ + // Central 1×1 square at (3,3)..(4,4) + [[3, 3], [3, 4]], + [[3, 4], [4, 4]], + [[4, 4], [4, 3]], + [[4, 3], [3, 3]], + // NW petal: 2×2 square with corner (3,3) + [[1, 1], [1, 2]], + [[1, 2], [1, 3]], + [[1, 3], [2, 3]], + [[2, 3], [3, 3]], + [[3, 3], [3, 2]], + [[3, 2], [3, 1]], + [[3, 1], [2, 1]], + [[2, 1], [1, 1]], + // NE petal: corner (3,4) + [[1, 4], [1, 5]], + [[1, 5], [1, 6]], + [[1, 6], [2, 6]], + [[2, 6], [3, 6]], + [[3, 6], [3, 5]], + [[3, 5], [3, 4]], + [[3, 4], [2, 4]], + [[2, 4], [1, 4]], + // SW petal: corner (4,3) + [[4, 1], [4, 2]], + [[4, 2], [4, 3]], + [[4, 3], [5, 3]], + [[5, 3], [6, 3]], + [[6, 3], [6, 2]], + [[6, 2], [6, 1]], + [[6, 1], [5, 1]], + [[5, 1], [4, 1]], + // SE petal: corner (4,4) + [[4, 4], [4, 5]], + [[4, 5], [4, 6]], + [[4, 6], [5, 6]], + [[5, 6], [6, 6]], + [[6, 6], [6, 5]], + [[6, 5], [6, 4]], + [[6, 4], [5, 4]], + [[5, 4], [4, 4]], + ], + }, + // Totem — top two boxes, middle square, wide base (pattern 2 from the reference). + { + id: 'totem', + title: 'Totem', + description: '24 stitches', + bbox: [3, 5], + edges: [ + // Top-left box (0..1, 1..2) + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [1, 1]], + [[1, 1], [0, 1]], + // Top-right box (0..1, 3..4) + [[0, 3], [0, 4]], + [[0, 4], [1, 4]], + [[1, 4], [1, 3]], + [[1, 3], [0, 3]], + // Middle square (1..2, 2..3) — shares corners (1,2) and (1,3) + [[1, 2], [1, 3]], + [[1, 3], [2, 3]], + [[2, 3], [2, 2]], + [[2, 2], [1, 2]], + // Base (2..3, 0..5) — passes through (2,2),(2,3) so their degree stays even + [[2, 0], [2, 1]], + [[2, 1], [2, 2]], + [[2, 2], [2, 3]], + [[2, 3], [2, 4]], + [[2, 4], [2, 5]], + [[2, 5], [3, 5]], + [[3, 5], [3, 4]], + [[3, 4], [3, 3]], + [[3, 3], [3, 2]], + [[3, 2], [3, 1]], + [[3, 1], [3, 0]], + [[3, 0], [2, 0]], + ], + }, + // Figurine — head / arms / body / legs (pattern 4 from the reference). + { + id: 'figurine', + title: 'Figurine', + description: '34 stitches', + bbox: [5, 6], + edges: [ + // Head (0..1, 3..4) + [[0, 3], [0, 4]], + [[0, 4], [1, 4]], + [[1, 4], [1, 3]], + [[1, 3], [0, 3]], + // Left arm: 2×2 rect (1..2, 1..3), corner-shared at (1,3) + [[1, 1], [1, 2]], + [[1, 2], [1, 3]], + [[1, 3], [2, 3]], + [[2, 3], [2, 2]], + [[2, 2], [2, 1]], + [[2, 1], [1, 1]], + // Right arm: 2×2 rect (1..2, 4..6), corner-shared at (1,4) + [[1, 4], [1, 5]], + [[1, 5], [1, 6]], + [[1, 6], [2, 6]], + [[2, 6], [2, 5]], + [[2, 5], [2, 4]], + [[2, 4], [1, 4]], + // Body: 1×2 rect (2..4, 3..4) — corner-shared with left arm at (2,3) + // and with right arm at (2,4) + [[2, 3], [2, 4]], + [[2, 4], [3, 4]], + [[3, 4], [4, 4]], + [[4, 4], [4, 3]], + [[4, 3], [3, 3]], + [[3, 3], [2, 3]], + // Left leg: 2×2 rect (4..5, 1..3), corner-shared with body at (4,3) + [[4, 1], [4, 2]], + [[4, 2], [4, 3]], + [[4, 3], [5, 3]], + [[5, 3], [5, 2]], + [[5, 2], [5, 1]], + [[5, 1], [4, 1]], + // Right leg: corner-shared with body at (4,4) + [[4, 4], [4, 5]], + [[4, 5], [4, 6]], + [[4, 6], [5, 6]], + [[5, 6], [5, 5]], + [[5, 5], [5, 4]], + [[5, 4], [4, 4]], + ], + }, + // Snowflake — central square, four inner petals, four outer 2×2 petals. 4-fold + // symmetric, richer motif inspired by the red kasuti sample. + { + id: 'snowflake', + title: 'Snowflake', + description: '52 stitches', + bbox: [7, 7], + edges: [ + // Central 1×1 square at (3,3)..(4,4) + [[3, 3], [3, 4]], + [[3, 4], [4, 4]], + [[4, 4], [4, 3]], + [[4, 3], [3, 3]], + // Inner NW petal (2,2)..(3,3) + [[2, 2], [2, 3]], + [[2, 3], [3, 3]], + [[3, 3], [3, 2]], + [[3, 2], [2, 2]], + // Inner NE petal (2,4)..(3,5) + [[2, 4], [2, 5]], + [[2, 5], [3, 5]], + [[3, 5], [3, 4]], + [[3, 4], [2, 4]], + // Inner SW petal (4,2)..(5,3) + [[4, 2], [4, 3]], + [[4, 3], [5, 3]], + [[5, 3], [5, 2]], + [[5, 2], [4, 2]], + // Inner SE petal (4,4)..(5,5) + [[4, 4], [4, 5]], + [[4, 5], [5, 5]], + [[5, 5], [5, 4]], + [[5, 4], [4, 4]], + // Outer NW 2×2 square sharing (2,2) + [[0, 0], [0, 1]], + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [2, 2]], + [[2, 2], [2, 1]], + [[2, 1], [2, 0]], + [[2, 0], [1, 0]], + [[1, 0], [0, 0]], + // Outer NE 2×2 sharing (2,5) + [[0, 5], [0, 6]], + [[0, 6], [0, 7]], + [[0, 7], [1, 7]], + [[1, 7], [2, 7]], + [[2, 7], [2, 6]], + [[2, 6], [2, 5]], + [[2, 5], [1, 5]], + [[1, 5], [0, 5]], + // Outer SW 2×2 sharing (5,2) + [[5, 0], [5, 1]], + [[5, 1], [5, 2]], + [[5, 2], [6, 2]], + [[6, 2], [7, 2]], + [[7, 2], [7, 1]], + [[7, 1], [7, 0]], + [[7, 0], [6, 0]], + [[6, 0], [5, 0]], + // Outer SE 2×2 sharing (5,5) + [[5, 5], [5, 6]], + [[5, 6], [5, 7]], + [[5, 7], [6, 7]], + [[6, 7], [7, 7]], + [[7, 7], [7, 6]], + [[7, 6], [7, 5]], + [[7, 5], [6, 5]], + [[6, 5], [5, 5]], + ], + }, ]; // ─── Pattern placement (centered in GRID_SIZE x GRID_SIZE) ────────────────── @@ -258,6 +475,8 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern, const KasutiEmbroidery: React.FC = () => { const [patternId, setPatternId] = useState(PATTERNS[0].id); + const [customPattern, setCustomPattern] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); const [currentVertex, setCurrentVertex] = useState(null); const [startVertex, setStartVertex] = useState(null); const [activeSide, setActiveSide] = useState('front'); @@ -267,10 +486,15 @@ const KasutiEmbroidery: React.FC = () => { const [completed, setCompleted] = useState(false); const celebratedRef = useRef(false); + const visiblePatterns = useMemo( + () => (customPattern ? [...PATTERNS, customPattern] : PATTERNS), + [customPattern] + ); + const placed = useMemo(() => { - const p = PATTERNS.find((x) => x.id === patternId) ?? PATTERNS[0]; + const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0]; return placePattern(p); - }, [patternId]); + }, [patternId, visiblePatterns]); const drawnFront = useMemo(() => { const s = new Set(); @@ -433,7 +657,7 @@ const KasutiEmbroidery: React.FC = () => { role="radiogroup" aria-label="Motif picker" > - {PATTERNS.map((p) => { + {visiblePatterns.map((p) => { const active = p.id === patternId; return ( ); })} + + + { + setCustomPattern(p); + setPatternId(p.id); + resetTrace(); + setEditorOpen(false); + }} + /> + {/* Status bar */}
@@ -812,4 +1063,269 @@ const FabricPanel: React.FC = ({ ); }; +// ─── Pattern editor modal ────────────────────────────────────────────────── + +const EDITOR_SIZE = 14; // cells +const EDITOR_CELL = 22; // px +const EDITOR_PAD = 14; +const EDITOR_DIM = EDITOR_SIZE * EDITOR_CELL + EDITOR_PAD * 2; + +function edgeListFromKeys(keys: Set): Array { + const out: Array = []; + for (const k of keys) { + const [a, b] = k.split('|'); + const [r1, c1] = a.split(',').map(Number); + const [r2, c2] = b.split(',').map(Number); + out.push([[r1, c1] as V, [r2, c2] as V]); + } + return out; +} + +function isConnected(edges: Array): boolean { + if (edges.length === 0) return false; + const adj = new Map(); + for (const [a, b] of edges) { + const ka = vKey(a); + const kb = vKey(b); + if (!adj.has(ka)) adj.set(ka, []); + if (!adj.has(kb)) adj.set(kb, []); + adj.get(ka)!.push(kb); + adj.get(kb)!.push(ka); + } + const start = adj.keys().next().value as string | undefined; + if (!start) return false; + const seen = new Set([start]); + const queue = [start]; + while (queue.length) { + const node = queue.shift()!; + for (const next of adj.get(node) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + return seen.size === adj.size; +} + +function oddDegreeVertices(edges: Array): number { + const deg = new Map(); + for (const [a, b] of edges) { + deg.set(vKey(a), (deg.get(vKey(a)) ?? 0) + 1); + deg.set(vKey(b), (deg.get(vKey(b)) ?? 0) + 1); + } + let odd = 0; + for (const d of deg.values()) if (d % 2 !== 0) odd++; + return odd; +} + +function normalizePattern(edges: Array): Pattern | null { + if (edges.length === 0) return null; + let minR = Infinity, + minC = Infinity, + maxR = -Infinity, + maxC = -Infinity; + for (const [a, b] of edges) { + for (const [r, c] of [a, b]) { + if (r < minR) minR = r; + if (r > maxR) maxR = r; + if (c < minC) minC = c; + if (c > maxC) maxC = c; + } + } + const shifted: Array = edges.map(([a, b]) => [ + [a[0] - minR, a[1] - minC] as V, + [b[0] - minR, b[1] - minC] as V, + ]); + return { + id: 'custom', + title: 'Custom', + description: `${edges.length} stitches`, + bbox: [maxR - minR, maxC - minC], + edges: shifted, + }; +} + +interface PatternEditorProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initial: Pattern | null; + onSave: (p: Pattern) => void; +} + +const PatternEditor: React.FC = ({ open, onOpenChange, initial, onSave }) => { + const [edges, setEdges] = useState>(new Set()); + + // When the modal opens, seed from the initial custom pattern (if any), centred in the + // editor grid. Resetting here avoids stale state between open/close cycles. + useEffect(() => { + if (!open) return; + if (!initial) { + setEdges(new Set()); + return; + } + const [br, bc] = initial.bbox; + const offR = Math.max(0, Math.floor((EDITOR_SIZE - br) / 2)); + const offC = Math.max(0, Math.floor((EDITOR_SIZE - bc) / 2)); + const seed = new Set(); + for (const [a, b] of initial.edges) { + const sa: V = [a[0] + offR, a[1] + offC]; + const sb: V = [b[0] + offR, b[1] + offC]; + seed.add(edgeKey(sa, sb)); + } + setEdges(seed); + }, [open, initial]); + + const toggleEdge = useCallback((a: V, b: V) => { + setEdges((prev) => { + const next = new Set(prev); + const k = edgeKey(a, b); + if (next.has(k)) next.delete(k); + else next.add(k); + return next; + }); + }, []); + + const edgeList = useMemo(() => edgeListFromKeys(edges), [edges]); + const connected = useMemo(() => isConnected(edgeList), [edgeList]); + const oddCount = useMemo(() => oddDegreeVertices(edgeList), [edgeList]); + const count = edges.size; + + const allEdges: Array<[V, V]> = useMemo(() => { + const out: Array<[V, V]> = []; + for (let r = 0; r <= EDITOR_SIZE; r++) { + for (let c = 0; c < EDITOR_SIZE; c++) { + out.push([[r, c], [r, c + 1]]); // horizontal + } + } + for (let r = 0; r < EDITOR_SIZE; r++) { + for (let c = 0; c <= EDITOR_SIZE; c++) { + out.push([[r, c], [r + 1, c]]); // vertical + } + } + return out; + }, []); + + const handleSave = useCallback(() => { + if (!connected) return; + const pattern = normalizePattern(edgeList); + if (!pattern) return; + // Main grid has GRID_SIZE cells; patterns larger than that will not fit. + const [br, bc] = pattern.bbox; + if (br > GRID_SIZE || bc > GRID_SIZE) return; + onSave(pattern); + }, [connected, edgeList, onSave]); + + const handleClear = useCallback(() => setEdges(new Set()), []); + + return ( + + + + Design your own motif + + Click between two dots to add or remove a stitch line. The motif must be{' '} + connected for the puzzle to start. For a complete alternating tour, every + vertex should have an even number of incident lines. + + + +
+ + {/* Grid dots */} + {Array.from({ length: (EDITOR_SIZE + 1) * (EDITOR_SIZE + 1) }).map((_, i) => { + const r = Math.floor(i / (EDITOR_SIZE + 1)); + const c = i % (EDITOR_SIZE + 1); + return ( + + ); + })} + + {/* Drawn edges */} + {edgeList.map(([a, b], i) => { + const ax = EDITOR_PAD + a[1] * EDITOR_CELL; + const ay = EDITOR_PAD + a[0] * EDITOR_CELL; + const bx = EDITOR_PAD + b[1] * EDITOR_CELL; + const by = EDITOR_PAD + b[0] * EDITOR_CELL; + return ( + + ); + })} + + {/* Clickable hit areas */} + {allEdges.map(([a, b], i) => { + const horizontal = a[0] === b[0]; + const x = EDITOR_PAD + Math.min(a[1], b[1]) * EDITOR_CELL; + const y = EDITOR_PAD + Math.min(a[0], b[0]) * EDITOR_CELL; + const w = horizontal ? EDITOR_CELL : 10; + const h = horizontal ? 10 : EDITOR_CELL; + return ( + toggleEdge(a, b)} + className="cursor-pointer hover:fill-primary/10" + /> + ); + })} + +
+ +
+

{count} stitch{count === 1 ? '' : 'es'}

+ {count > 0 && !connected && ( +

+ Not connected — every drawn segment must reach every other segment. +

+ )} + {count > 0 && connected && oddCount === 0 && ( +

+ Connected and every vertex has even degree — a complete alternating tour exists. +

+ )} + {count > 0 && connected && oddCount > 0 && ( +

+ Connected, but {oddCount} vertex{oddCount === 1 ? '' : 'es'} {oddCount === 1 ? 'has' : 'have'} odd degree — you may not be able to close the tour. +

+ )} +
+ + + + + +
+
+ ); +}; + export default KasutiEmbroidery;