From 12803052fdd76aea5d9d245ccffd38a36f58abba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 08:08:51 +0000 Subject: [PATCH] kasuti: four new motifs + custom pattern editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Rose, Totem, Figurine, and Snowflake to the motif reel — inspired by patterns 1/2/4 on the reference sketch and the red snowflake sample. Each is an Eulerian graph so an alternating tour exists (36/24/34/52 stitches respectively). Appends a "+ Make your own" tile at the end of the reel that opens a dialog-based pattern editor: click between dots on a 14×14 grid to toggle stitch segments. The editor reports connectivity and Eulerian status live and only enables "Use this motif" once the pattern is connected. The saved custom pattern is centered into the main grid and selected immediately. https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw --- .../interactives/KasutiEmbroidery.tsx | 524 +++++++++++++++++- 1 file changed, 520 insertions(+), 4 deletions(-) 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;