kasuti: four new motifs + custom pattern editor
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
This commit is contained in:
parent
197f7b681b
commit
12803052fd
1 changed files with 520 additions and 4 deletions
|
|
@ -1,10 +1,18 @@
|
||||||
import confetti from 'canvas-confetti';
|
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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -178,6 +186,215 @@ const PATTERNS: Pattern[] = [
|
||||||
[[1, 1], [0, 1]],
|
[[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) ──────────────────
|
// ─── 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 KasutiEmbroidery: React.FC = () => {
|
||||||
const [patternId, setPatternId] = useState<string>(PATTERNS[0].id);
|
const [patternId, setPatternId] = useState<string>(PATTERNS[0].id);
|
||||||
|
const [customPattern, setCustomPattern] = useState<Pattern | null>(null);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [currentVertex, setCurrentVertex] = useState<V | null>(null);
|
const [currentVertex, setCurrentVertex] = useState<V | null>(null);
|
||||||
const [startVertex, setStartVertex] = useState<V | null>(null);
|
const [startVertex, setStartVertex] = useState<V | null>(null);
|
||||||
const [activeSide, setActiveSide] = useState<Side>('front');
|
const [activeSide, setActiveSide] = useState<Side>('front');
|
||||||
|
|
@ -267,10 +486,15 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
const celebratedRef = useRef(false);
|
const celebratedRef = useRef(false);
|
||||||
|
|
||||||
|
const visiblePatterns = useMemo(
|
||||||
|
() => (customPattern ? [...PATTERNS, customPattern] : PATTERNS),
|
||||||
|
[customPattern]
|
||||||
|
);
|
||||||
|
|
||||||
const placed = useMemo(() => {
|
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);
|
return placePattern(p);
|
||||||
}, [patternId]);
|
}, [patternId, visiblePatterns]);
|
||||||
|
|
||||||
const drawnFront = useMemo(() => {
|
const drawnFront = useMemo(() => {
|
||||||
const s = new Set<EdgeKey>();
|
const s = new Set<EdgeKey>();
|
||||||
|
|
@ -433,7 +657,7 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Motif picker"
|
aria-label="Motif picker"
|
||||||
>
|
>
|
||||||
{PATTERNS.map((p) => {
|
{visiblePatterns.map((p) => {
|
||||||
const active = p.id === patternId;
|
const active = p.id === patternId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -455,10 +679,37 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditorOpen(true)}
|
||||||
|
aria-label="Design your own motif"
|
||||||
|
className="shrink-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border p-2 transition-colors min-w-[88px] hover:border-primary/60 hover:bg-primary/5"
|
||||||
|
>
|
||||||
|
<div className="h-[60px] flex items-center justify-center">
|
||||||
|
<div className="w-9 h-9 rounded-full border-2 border-primary/60 flex items-center justify-center">
|
||||||
|
<Plus className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">Make your own</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">draw a motif</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<PatternEditor
|
||||||
|
open={editorOpen}
|
||||||
|
onOpenChange={setEditorOpen}
|
||||||
|
initial={customPattern}
|
||||||
|
onSave={(p) => {
|
||||||
|
setCustomPattern(p);
|
||||||
|
setPatternId(p.id);
|
||||||
|
resetTrace();
|
||||||
|
setEditorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap rounded-xl border bg-muted/40 p-3">
|
<div className="flex items-center justify-between gap-3 flex-wrap rounded-xl border bg-muted/40 p-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|
@ -812,4 +1063,269 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── 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<EdgeKey>): Array<readonly [V, V]> {
|
||||||
|
const out: Array<readonly [V, V]> = [];
|
||||||
|
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<readonly [V, V]>): boolean {
|
||||||
|
if (edges.length === 0) return false;
|
||||||
|
const adj = new Map<string, string[]>();
|
||||||
|
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<string>([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<readonly [V, V]>): number {
|
||||||
|
const deg = new Map<string, number>();
|
||||||
|
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<readonly [V, V]>): 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<readonly [V, V]> = 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<PatternEditorProps> = ({ open, onOpenChange, initial, onSave }) => {
|
||||||
|
const [edges, setEdges] = useState<Set<EdgeKey>>(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<EdgeKey>();
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg md:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Design your own motif</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Click between two dots to add or remove a stitch line. The motif must be{' '}
|
||||||
|
<em>connected</em> for the puzzle to start. For a complete alternating tour, every
|
||||||
|
vertex should have an even number of incident lines.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex justify-center overflow-auto">
|
||||||
|
<svg
|
||||||
|
width={EDITOR_DIM}
|
||||||
|
height={EDITOR_DIM}
|
||||||
|
className="rounded-lg bg-card"
|
||||||
|
style={{ outline: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* 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 (
|
||||||
|
<circle
|
||||||
|
key={`d${r}-${c}`}
|
||||||
|
cx={EDITOR_PAD + c * EDITOR_CELL}
|
||||||
|
cy={EDITOR_PAD + r * EDITOR_CELL}
|
||||||
|
r={1}
|
||||||
|
className="fill-muted-foreground/40"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<line
|
||||||
|
key={`e${i}`}
|
||||||
|
x1={ax}
|
||||||
|
y1={ay}
|
||||||
|
x2={bx}
|
||||||
|
y2={by}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-foreground"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<rect
|
||||||
|
key={`hit${i}`}
|
||||||
|
x={horizontal ? x : x - 5}
|
||||||
|
y={horizontal ? y - 5 : y}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
fill="transparent"
|
||||||
|
onClick={() => toggleEdge(a, b)}
|
||||||
|
className="cursor-pointer hover:fill-primary/10"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-center space-y-0.5">
|
||||||
|
<p className="text-muted-foreground">{count} stitch{count === 1 ? '' : 'es'}</p>
|
||||||
|
{count > 0 && !connected && (
|
||||||
|
<p className="text-destructive">
|
||||||
|
Not connected — every drawn segment must reach every other segment.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{count > 0 && connected && oddCount === 0 && (
|
||||||
|
<p className="text-success">
|
||||||
|
Connected and every vertex has even degree — a complete alternating tour exists.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{count > 0 && connected && oddCount > 0 && (
|
||||||
|
<p className="text-amber-600 dark:text-amber-400">
|
||||||
|
Connected, but {oddCount} vertex{oddCount === 1 ? '' : 'es'} {oddCount === 1 ? 'has' : 'have'} odd degree — you may not be able to close the tour.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button variant="outline" onClick={handleClear} disabled={count === 0}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!connected}>
|
||||||
|
Use this motif
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default KasutiEmbroidery;
|
export default KasutiEmbroidery;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue