# Geometric Puzzles & Dissections Animated geometric puzzles: Dudeney dissections, tangrams, tessellations, Penrose tiles, and more. SVG + GSAP for precise animated transformations. ## Table of Contents - [Dudeney Dissections](#dudeney-dissections) - [Interactive Tangram](#interactive-tangram) - [Tessellations](#tessellations) - [Penrose Tiles](#penrose-tiles) - [Polyominoes](#polyominoes) - [Geometric Transformations](#geometric-transformations) - [Dissection Puzzle Framework](#dissection-puzzle-framework) --- ## Dudeney Dissections The famous equilateral triangle → square dissection with 4 hinged pieces. ```tsx 'use client' import { useRef, useState } from 'react' import { gsap, useGSAP } from '@/lib/gsap' // Triangle-to-square: 4 pieces with precise SVG paths // Coordinates based on Dudeney's original 4-piece hinged dissection const TRIANGLE_PIECES = [ { id: 'A', tri: 'M 0,173.2 L 50,86.6 L 100,173.2 Z', sq: 'M 0,0 L 100,0 L 100,86.6 L 0,86.6 Z' }, { id: 'B', tri: 'M 50,86.6 L 100,0 L 150,86.6 Z', sq: 'M 100,0 L 200,0 L 200,86.6 L 100,86.6 Z' }, { id: 'C', tri: 'M 100,173.2 L 150,86.6 L 200,173.2 Z', sq: 'M 0,86.6 L 100,86.6 L 100,173.2 L 0,173.2 Z' }, { id: 'D', tri: 'M 50,86.6 L 100,173.2 L 150,86.6 L 100,0 Z', sq: 'M 100,86.6 L 200,86.6 L 200,173.2 L 100,173.2 Z' }, ] const COLORS = ['#f43f5e', '#8b5cf6', '#06b6d4', '#f59e0b'] export function DudeneyDissection() { const svgRef = useRef(null) const [isSquare, setIsSquare] = useState(false) useGSAP(() => { // Initial setup — start as triangle TRIANGLE_PIECES.forEach((piece, i) => { const el = svgRef.current!.querySelector(`#piece-${piece.id}`) if (el) { gsap.set(el, { attr: { d: piece.tri } }) } }) }, { scope: svgRef }) const morph = () => { const target = !isSquare TRIANGLE_PIECES.forEach((piece, i) => { const el = svgRef.current!.querySelector(`#piece-${piece.id}`) if (el) { gsap.to(el, { attr: { d: target ? piece.sq : piece.tri }, duration: 1.5, ease: 'power2.inOut', delay: i * 0.15, }) } }) setIsSquare(target) } return (
{TRIANGLE_PIECES.map((piece, i) => ( ))}
) } ``` ## Interactive Tangram 7 draggable pieces with snap-to-position. ```tsx 'use client' import { useRef, useState, useCallback } from 'react' import { gsap } from '@/lib/gsap' interface TangramPiece { id: string; name: string; points: string; color: string homeX: number; homeY: number; homeRotate: number } const PIECES: TangramPiece[] = [ { id: 'lg1', name: 'Large Triangle 1', points: '0,0 200,0 100,100', color: '#ef4444', homeX: 0, homeY: 0, homeRotate: 0 }, { id: 'lg2', name: 'Large Triangle 2', points: '0,0 100,100 0,200', color: '#f97316', homeX: 0, homeY: 0, homeRotate: 0 }, { id: 'md', name: 'Medium Triangle', points: '0,0 100,0 50,50', color: '#eab308', homeX: 100, homeY: 100, homeRotate: 0 }, { id: 'sm1', name: 'Small Triangle 1', points: '0,0 100,0 50,50', color: '#22c55e', homeX: 100, homeY: 0, homeRotate: 90 }, { id: 'sm2', name: 'Small Triangle 2', points: '0,0 100,0 50,50', color: '#06b6d4', homeX: 50, homeY: 150, homeRotate: 180 }, { id: 'sq', name: 'Square', points: '0,0 50,0 50,50 0,50', color: '#8b5cf6', homeX: 100, homeY: 50, homeRotate: 45 }, { id: 'par', name: 'Parallelogram', points: '0,0 50,0 100,50 50,50', color: '#ec4899', homeX: 50, homeY: 50, homeRotate: 0 }, ] // Target shapes: each piece's target transform const TARGETS = { house: { lg1: { x: 50, y: 100, r: 0 }, lg2: { x: 0, y: 200, r: -90 }, md: { x: 25, y: 50, r: 0 }, sm1: { x: 0, y: 100, r: 90 }, sm2: { x: 150, y: 100, r: 0 }, sq: { x: 75, y: 150, r: 0 }, par: { x: 100, y: 100, r: 0 } }, cat: { lg1: { x: 30, y: 80, r: 45 }, lg2: { x: 30, y: 80, r: -45 }, md: { x: 80, y: 180, r: 180 }, sm1: { x: 0, y: 0, r: 0 }, sm2: { x: 120, y: 0, r: 90 }, sq: { x: 60, y: 30, r: 0 }, par: { x: 60, y: 130, r: 0 } }, } export function Tangram() { const svgRef = useRef(null) const [activeTarget, setActiveTarget] = useState(null) const dragState = useRef<{ id: string; startX: number; startY: number; offsetX: number; offsetY: number } | null>(null) const animateToTarget = useCallback((target: keyof typeof TARGETS) => { const t = TARGETS[target] PIECES.forEach((piece, i) => { const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement const data = t[piece.id as keyof typeof t] if (el && data) { gsap.to(el, { x: data.x, y: data.y, rotation: data.r, duration: 0.8, ease: 'back.out(1.2)', delay: i * 0.08, transformOrigin: 'center center', }) } }) setActiveTarget(target) }, []) const scatter = useCallback(() => { PIECES.forEach((piece, i) => { const el = svgRef.current!.querySelector(`#tangram-${piece.id}`) as SVGGElement if (el) { gsap.to(el, { x: Math.random() * 250, y: Math.random() * 250, rotation: Math.random() * 360, duration: 0.6, ease: 'power2.out', delay: i * 0.05, }) } }) setActiveTarget(null) }, []) const handlePointerDown = useCallback((e: React.PointerEvent, pieceId: string) => { const svg = svgRef.current! const pt = svg.createSVGPoint() pt.x = e.clientX pt.y = e.clientY const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse()) const el = svg.querySelector(`#tangram-${pieceId}`) as SVGGElement const transform = gsap.getProperty(el) dragState.current = { id: pieceId, startX: svgPt.x, startY: svgPt.y, offsetX: (transform('x') as number) || 0, offsetY: (transform('y') as number) || 0, } ;(e.target as Element).setPointerCapture(e.pointerId) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!dragState.current) return const svg = svgRef.current! const pt = svg.createSVGPoint() pt.x = e.clientX pt.y = e.clientY const svgPt = pt.matrixTransform(svg.getScreenCTM()!.inverse()) const el = svg.querySelector(`#tangram-${dragState.current.id}`) if (el) { gsap.set(el, { x: dragState.current.offsetX + (svgPt.x - dragState.current.startX), y: dragState.current.offsetY + (svgPt.y - dragState.current.startY), }) } }, []) const handlePointerUp = useCallback(() => { dragState.current = null }, []) return (
{PIECES.map(piece => ( handlePointerDown(e, piece.id)} className="cursor-grab active:cursor-grabbing" > ))}
{Object.keys(TARGETS).map(target => ( ))}
) } ``` ## Tessellations Regular, semi-regular, and Escher-style animated tessellations. ```tsx 'use client' import { useRef, useEffect } from 'react' type TessType = 'triangular' | 'hexagonal' | 'cairo' export function Tessellation({ type = 'hexagonal', animate = true }: { type?: TessType; animate?: boolean }) { const canvasRef = useRef(null) const animRef = useRef(0) useEffect(() => { const canvas = canvasRef.current! const ctx = canvas.getContext('2d')! const w = canvas.offsetWidth const h = canvas.offsetHeight canvas.width = w * 2 canvas.height = h * 2 ctx.scale(2, 2) let time = 0 const drawHexGrid = (t: number) => { const size = 30 const hSpacing = size * Math.sqrt(3) const vSpacing = size * 1.5 for (let row = -1; row < h / vSpacing + 1; row++) { for (let col = -1; col < w / hSpacing + 1; col++) { const x = col * hSpacing + (row % 2 ? hSpacing / 2 : 0) const y = row * vSpacing ctx.beginPath() for (let i = 0; i < 6; i++) { const angle = ((60 * i - 30) * Math.PI) / 180 const px = x + size * Math.cos(angle + t * 0.3) const py = y + size * Math.sin(angle + t * 0.3) if (i === 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) } ctx.closePath() const hue = ((x + y) * 0.5 + t * 50) % 360 ctx.fillStyle = `hsla(${hue}, 60%, 50%, 0.7)` ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.3)' ctx.lineWidth = 1 ctx.stroke() } } } const drawTriGrid = (t: number) => { const size = 35 const height = size * Math.sqrt(3) / 2 for (let row = -1; row < h / height + 1; row++) { for (let col = -1; col < w / size + 1; col++) { const upward = (row + col) % 2 === 0 const x = col * (size / 2) const y = row * height ctx.beginPath() if (upward) { ctx.moveTo(x, y + height) ctx.lineTo(x + size / 2, y) ctx.lineTo(x + size, y + height) } else { ctx.moveTo(x, y) ctx.lineTo(x + size, y) ctx.lineTo(x + size / 2, y + height) } ctx.closePath() const hue = ((row * 40 + col * 20) + t * 30) % 360 ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.8)` ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.2)' ctx.stroke() } } } const drawCairo = (t: number) => { const size = 40 for (let row = -1; row < h / size + 2; row++) { for (let col = -1; col < w / size + 2; col++) { const x = col * size const y = row * size const wobble = Math.sin(t + col * 0.3 + row * 0.3) * 3 // Cairo pentagon approximation ctx.beginPath() ctx.moveTo(x + wobble, y) ctx.lineTo(x + size * 0.7, y + wobble) ctx.lineTo(x + size, y + size * 0.3) ctx.lineTo(x + size * 0.5, y + size * 0.7 + wobble) ctx.lineTo(x, y + size * 0.4) ctx.closePath() const hue = ((col + row) * 30 + t * 20) % 360 ctx.fillStyle = `hsla(${hue}, 45%, 55%, 0.8)` ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.3)' ctx.stroke() } } } const render = () => { ctx.clearRect(0, 0, w, h) const t = animate ? time : 0 switch (type) { case 'hexagonal': drawHexGrid(t); break case 'triangular': drawTriGrid(t); break case 'cairo': drawCairo(t); break } if (animate) { time += 0.01 animRef.current = requestAnimationFrame(render) } } render() return () => cancelAnimationFrame(animRef.current) }, [type, animate]) return } ``` ## Penrose Tiles Kite and dart aperiodic tiling with deflation generation. ```tsx 'use client' import { useRef, useEffect } from 'react' type PenroseTile = { type: 'kite' | 'dart'; vertices: [number, number][] } const PHI = (1 + Math.sqrt(5)) / 2 function generatePenrose(depth: number, cx: number, cy: number, radius: number): PenroseTile[] { // Start with a sun (10 kites) let tiles: PenroseTile[] = [] for (let i = 0; i < 10; i++) { const a1 = ((i * 36) * Math.PI) / 180 const a2 = (((i + 1) * 36) * Math.PI) / 180 const mid = (((i * 36 + 18)) * Math.PI) / 180 tiles.push({ type: 'kite', vertices: [ [cx, cy], [cx + Math.cos(a1) * radius, cy + Math.sin(a1) * radius], [cx + Math.cos(mid) * radius / PHI, cy + Math.sin(mid) * radius / PHI], [cx + Math.cos(a2) * radius, cy + Math.sin(a2) * radius], ], }) } // Subdivide for (let d = 0; d < depth; d++) { const newTiles: PenroseTile[] = [] for (const tile of tiles) { const [A, B, C, D] = tile.vertices if (tile.type === 'kite') { const E: [number, number] = [A[0] + (B[0] - A[0]) / PHI, A[1] + (B[1] - A[1]) / PHI] const F: [number, number] = [A[0] + (D[0] - A[0]) / PHI, A[1] + (D[1] - A[1]) / PHI] newTiles.push({ type: 'kite', vertices: [A, E, C, F] }) newTiles.push({ type: 'dart', vertices: [E, B, C, E] }) newTiles.push({ type: 'dart', vertices: [F, C, D, F] }) } else { const E: [number, number] = [B[0] + (A[0] - B[0]) / PHI, B[1] + (A[1] - B[1]) / PHI] newTiles.push({ type: 'kite', vertices: [E, B, C, E] }) newTiles.push({ type: 'dart', vertices: [A, E, C, D] }) } } tiles = newTiles } return tiles } export function PenroseTiling({ depth = 3 }: { depth?: number }) { const canvasRef = useRef(null) const animRef = useRef(0) useEffect(() => { const canvas = canvasRef.current! const ctx = canvas.getContext('2d')! const w = canvas.offsetWidth const h = canvas.offsetHeight canvas.width = w * 2 canvas.height = h * 2 ctx.scale(2, 2) const tiles = generatePenrose(depth, w / 2, h / 2, Math.min(w, h) * 0.45) let drawn = 0 const animate = () => { if (drawn >= tiles.length) return const batch = Math.min(10, tiles.length - drawn) for (let i = 0; i < batch; i++) { const tile = tiles[drawn + i] ctx.beginPath() tile.vertices.forEach(([x, y], j) => { if (j === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) }) ctx.closePath() ctx.fillStyle = tile.type === 'kite' ? `hsla(220, 60%, 50%, 0.7)` : `hsla(40, 70%, 55%, 0.7)` ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.4)' ctx.lineWidth = 0.5 ctx.stroke() } drawn += batch if (drawn < tiles.length) { animRef.current = requestAnimationFrame(animate) } } animate() return () => cancelAnimationFrame(animRef.current) }, [depth]) return } ``` ## Polyominoes Pentomino puzzle with animated placement. ```tsx 'use client' import { useRef, useCallback } from 'react' import { gsap, useGSAP } from '@/lib/gsap' // All 12 pentomino shapes (relative cell positions) const PENTOMINOES: { name: string; cells: [number, number][]; color: string }[] = [ { name: 'F', cells: [[0,1],[1,0],[1,1],[1,2],[2,2]], color: '#ef4444' }, { name: 'I', cells: [[0,0],[0,1],[0,2],[0,3],[0,4]], color: '#f97316' }, { name: 'L', cells: [[0,0],[1,0],[2,0],[3,0],[3,1]], color: '#eab308' }, { name: 'N', cells: [[0,0],[1,0],[1,1],[2,1],[3,1]], color: '#22c55e' }, { name: 'P', cells: [[0,0],[0,1],[1,0],[1,1],[2,0]], color: '#06b6d4' }, { name: 'T', cells: [[0,0],[0,1],[0,2],[1,1],[2,1]], color: '#8b5cf6' }, { name: 'U', cells: [[0,0],[0,2],[1,0],[1,1],[1,2]], color: '#ec4899' }, { name: 'V', cells: [[0,0],[1,0],[2,0],[2,1],[2,2]], color: '#14b8a6' }, { name: 'W', cells: [[0,0],[1,0],[1,1],[2,1],[2,2]], color: '#f43f5e' }, { name: 'X', cells: [[0,1],[1,0],[1,1],[1,2],[2,1]], color: '#a855f7' }, { name: 'Y', cells: [[0,0],[1,0],[1,1],[2,0],[3,0]], color: '#fb923c' }, { name: 'Z', cells: [[0,0],[0,1],[1,1],[2,1],[2,2]], color: '#38bdf8' }, ] const CELL_SIZE = 28 export function PentominoShowcase() { const svgRef = useRef(null) useGSAP(() => { const pieces = svgRef.current!.querySelectorAll('.pentomino-group') gsap.from(pieces, { scale: 0, rotation: 180, opacity: 0, duration: 0.6, stagger: 0.1, ease: 'back.out(1.7)', transformOrigin: 'center center', }) }, { scope: svgRef }) return ( {PENTOMINOES.map((piece, pi) => { const offsetX = (pi % 6) * 80 + 10 const offsetY = Math.floor(pi / 6) * 100 + 10 return ( {piece.cells.map(([r, c], ci) => ( ))} {piece.name} ) })} ) } ``` ## Geometric Transformations Animated rotation, reflection, dilation, and composition. ```tsx 'use client' import { useRef, useState } from 'react' import { gsap, useGSAP } from '@/lib/gsap' type TransformType = 'rotate' | 'reflect' | 'dilate' | 'compose' export function GeometricTransform({ type = 'rotate' }: { type?: TransformType }) { const svgRef = useRef(null) const [playing, setPlaying] = useState(false) const animate = () => { if (playing) return setPlaying(true) const shape = svgRef.current!.querySelector('#transform-shape')! const ghost = svgRef.current!.querySelector('#transform-ghost')! gsap.set(ghost, { opacity: 0.3 }) const tl = gsap.timeline({ onComplete: () => setPlaying(false) }) switch (type) { case 'rotate': tl.to(shape, { rotation: 90, duration: 1.5, ease: 'power2.inOut', transformOrigin: '200 200' }) .to(shape, { rotation: 0, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 }) break case 'reflect': tl.to(shape, { scaleX: -1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' }) .to(shape, { scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 }) break case 'dilate': tl.to(shape, { scale: 1.8, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' }) .to(shape, { scale: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 }) break case 'compose': tl.to(shape, { rotation: 45, scale: 1.3, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200' }) .to(shape, { scaleX: -1, duration: 0.8, ease: 'power2.inOut', transformOrigin: '200 200' }) .to(shape, { rotation: 0, scale: 1, scaleX: 1, duration: 1, ease: 'power2.inOut', transformOrigin: '200 200', delay: 0.5 }) break } } return (
{/* Axes */} {/* Ghost (original position) */} {/* Shape */} {/* Center marker */}
) } ``` ## Dissection Puzzle Framework Reusable framework for any SVG-based dissection puzzle. ```tsx 'use client' import { useRef, useCallback } from 'react' import { gsap, useGSAP } from '@/lib/gsap' interface PuzzlePiece { id: string path: string // SVG path data color: string states: Record } interface DissectionPuzzleProps { pieces: PuzzlePiece[] viewBox?: string initialState: string className?: string } export function DissectionPuzzle({ pieces, viewBox = '0 0 400 400', initialState, className }: DissectionPuzzleProps) { const svgRef = useRef(null) const currentState = useRef(initialState) useGSAP(() => { pieces.forEach(piece => { const el = svgRef.current!.querySelector(`#dp-${piece.id}`) const state = piece.states[initialState] if (el && state) { gsap.set(el, { x: state.x, y: state.y, rotation: state.rotation, scale: state.scale ?? 1, transformOrigin: 'center center', }) } }) }, { scope: svgRef }) const transitionTo = useCallback((targetState: string) => { if (currentState.current === targetState) return pieces.forEach((piece, i) => { const el = svgRef.current!.querySelector(`#dp-${piece.id}`) const state = piece.states[targetState] if (el && state) { gsap.to(el, { x: state.x, y: state.y, rotation: state.rotation, scale: state.scale ?? 1, duration: 1.2, ease: 'power2.inOut', delay: i * 0.1, transformOrigin: 'center center', }) } }) currentState.current = targetState }, [pieces]) const availableStates = pieces[0] ? Object.keys(pieces[0].states) : [] return (
{pieces.map(piece => ( ))}
{availableStates.map(state => ( ))}
) } // Example usage: // ``` ## Choosing the Right Puzzle | Puzzle | Best For | Complexity | |--------|----------|------------| | Dudeney | Math demonstrations, educational | Medium | | Tangram | Interactive play, creativity | Low-Medium | | Tessellation | Backgrounds, patterns | Low | | Penrose | Mathematical beauty, wow factor | High | | Polyominoes | Game-like interactions | Medium | | Transformations | Educational, geometric concepts | Low |