// Command whiteboard is an Excalidraw-style sketchpad: a hand-drawn ("rough")
// shape tool set plus a smooth, variable-width freehand pen, over a toolbar with
// color swatches, three stroke widths, undo/redo, and clear.
//
// Two rendering ideas drive it:
//
//   - Rough shapes. Rectangles, diamonds, ellipses, lines and arrows are drawn
//     as slightly wobbled paths, each edge stroked twice with independent
//     jitter — the overlapping double pass is what reads as hand-drawn. The
//     wobble is deterministic per element: every element carries a stable int64
//     seed, and each frame re-seeds a math/rand PRNG from it, so shapes never
//     shimmer between frames.
//   - Freehand pen. Raw drag points become a filled outline (perfect-freehand
//     style): a left and right rail offset from the centerline by a per-point
//     half-width that tapers up at the start and down at the end, joined by
//     rounded caps. A single tap renders as a filled dot.
//
// Elements are retained in a slice with a snapshot undo/redo stack; the eraser
// removes whole elements by hit-testing their outline.
//
//	go run ./examples/whiteboard
package main

import (
	"log"
	"math"
	"math/rand"
	"slices"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/shell"
	"github.com/doug/gophics/widget"
)

// tool is the active drawing mode selected in the toolbar.
type tool int

const (
	toolPen tool = iota
	toolRect
	toolDiamond
	toolEllipse
	toolLine
	toolArrow
	toolEraser
	numTools
)

const (
	numColors = 6
	toolbarH  = 56
)

var (
	palette = [numColors]paint.Color{
		paint.RGB(0.11, 0.12, 0.14), // ink
		paint.RGB(0.90, 0.24, 0.24), // red
		paint.RGB(0.20, 0.50, 0.95), // blue
		paint.RGB(0.18, 0.70, 0.42), // green
		paint.RGB(0.97, 0.60, 0.18), // orange
		paint.RGB(0.60, 0.35, 0.85), // purple
	}
	widths = [3]float32{2.5, 5, 11}

	paper     = paint.RGB(0.99, 0.99, 0.99)
	toolbarBg = paint.RGB(0.96, 0.96, 0.97)
	borderCol = paint.RGB(0.85, 0.86, 0.88)
	ringCol   = paint.RGB(0.20, 0.50, 0.95)
	btnBg     = paint.RGB(1, 1, 1)
	btnFg     = paint.RGB(0.28, 0.30, 0.34)
	activeFg  = paint.RGB(1, 1, 1)
)

// element is one committed drawing: its tool, geometry, style and a stable
// random seed. For freehand pen, pts holds the raw centerline; for shapes, a and
// b are two opposite corners (or line/arrow endpoints). cache is the built path
// (a filled outline for the pen, a wobbled stroke network for shapes); it is
// rebuilt from the same seed, so it is byte-for-byte stable across frames.
type element struct {
	tool  tool
	pts   []geom.Pt
	a, b  geom.Pt
	col   paint.Color
	w     float32
	seed  int64
	cache *paint.Path
}

func (e *element) rng() *rand.Rand { return rand.New(rand.NewSource(e.seed)) }

// build (re)generates the element's retained path. Rough shapes seed their
// wobble from e.seed so the path is identical every rebuild.
func (e *element) build() {
	p := paint.NewPath()
	switch e.tool {
	case toolPen:
		buildFreehand(p, e.pts, e.w)
	case toolLine:
		roughLineInto(p, e.a, e.b, e.rng())
	case toolArrow:
		roughArrowInto(p, e.a, e.b, e.rng())
	case toolRect:
		roughRectInto(p, bounds(e.a, e.b), e.rng())
	case toolDiamond:
		roughDiamondInto(p, bounds(e.a, e.b), e.rng())
	case toolEllipse:
		roughEllipseInto(p, bounds(e.a, e.b), e.rng())
	}
	e.cache = p
}

// hit reports whether p lands on the element's outline (used by the eraser).
func (e *element) hit(p geom.Pt) bool {
	tol := e.w/2 + 6
	switch e.tool {
	case toolPen:
		return polyHit(e.pts, p, e.w*2+4, false) // the pen paints a thick band
	case toolLine, toolArrow:
		return segDist(e.a, e.b, p) <= tol
	case toolRect:
		return polyHit(rectPts(bounds(e.a, e.b)), p, tol, true)
	case toolDiamond:
		return polyHit(diamondPts(bounds(e.a, e.b)), p, tol, true)
	case toolEllipse:
		return polyHit(ellipsePts(bounds(e.a, e.b), 28), p, tol, true)
	}
	return false
}

type Board struct{}

func (Board) CreateState() widget.State { return &board{} }

type board struct {
	widget.StateBase[Board]
	elements []*element
	cur      *element // in-progress element during a drag
	last     geom.Pt
	erased   bool // an erase gesture has already pushed its undo snapshot
	undo     [][]*element
	redo     [][]*element

	col  paint.Color
	w    float32
	tool tool
	ctx  widget.Ctx

	// hit-test rects, set during draw
	swatch   [numColors]geom.Rect
	toolBtn  [numTools]geom.Rect
	widthBtn [3]geom.Rect
	undoBtn  geom.Rect
	redoBtn  geom.Rect
	clearBtn geom.Rect
	drawArea geom.Rect
}

// stateHook, if set, receives the state on mount — for tests to drive input.
var stateHook func(*board)

func (s *board) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.col = palette[0]
	s.w = widths[1]
	s.tool = toolPen
	s.elements = sampleElements()
	if stateHook != nil {
		stateHook(s)
	}
}

func (s *board) snapshot() []*element { return append([]*element(nil), s.elements...) }

// pushUndo records the current elements so the next mutation can be reversed.
func (s *board) pushUndo() {
	s.undo = append(s.undo, s.snapshot())
	s.redo = nil
}

func (s *board) doUndo() {
	if len(s.undo) == 0 {
		return
	}
	s.redo = append(s.redo, s.snapshot())
	s.elements = s.undo[len(s.undo)-1]
	s.undo = s.undo[:len(s.undo)-1]
	s.ctx.Invalidate()
}

func (s *board) doRedo() {
	if len(s.redo) == 0 {
		return
	}
	s.undo = append(s.undo, s.snapshot())
	s.elements = s.redo[len(s.redo)-1]
	s.redo = s.redo[:len(s.redo)-1]
	s.ctx.Invalidate()
}

func (s *board) doClear() {
	if len(s.elements) == 0 {
		return
	}
	s.pushUndo()
	s.elements = nil
	s.ctx.Invalidate()
}

// startElement begins a new in-progress element under the pointer.
func (s *board) startElement(p geom.Pt) {
	e := &element{tool: s.tool, col: s.col, w: s.w, seed: rand.Int63()}
	if s.tool == toolPen {
		e.pts = []geom.Pt{p}
	} else {
		e.a, e.b = p, p
	}
	s.cur = e
	s.last = p
	s.ctx.Invalidate()
}

// drag advances the current gesture: pen appends a point, shapes move their end
// corner, the eraser removes whatever the pointer touches.
func (s *board) drag(p geom.Pt) {
	if s.tool == toolEraser {
		s.eraseAt(p)
		return
	}
	if s.cur == nil {
		return
	}
	if s.cur.tool == toolPen {
		if !s.drawArea.Contains(p) { // drop points that leave the canvas
			return
		}
		if dist(p, s.last) < 1.2 { // skip near-duplicate points
			return
		}
		s.cur.pts = append(s.cur.pts, p)
		s.cur.cache = nil
		s.last = p
		s.ctx.Invalidate()
		return
	}
	s.cur.b = clampToRect(p, s.drawArea)
	s.cur.cache = nil
	s.ctx.Invalidate()
}

// commit finishes the current gesture, retaining a non-degenerate element.
func (s *board) commit() {
	if s.cur == nil {
		return
	}
	e := s.cur
	s.cur = nil
	if e.tool != toolPen && dist(e.a, e.b) < 2 {
		s.ctx.Invalidate() // a bare click with a shape tool draws nothing
		return
	}
	e.build()
	s.pushUndo()
	s.elements = append(s.elements, e)
	s.ctx.Invalidate()
}

// eraseAt removes the topmost element under p; the first removal of a gesture
// pushes one undo snapshot so the whole gesture reverts as a unit.
func (s *board) eraseAt(p geom.Pt) {
	for i, v := range slices.Backward(s.elements) {
		if v.hit(p) {
			if !s.erased {
				s.pushUndo()
				s.erased = true
			}
			s.elements = append(s.elements[:i:i], s.elements[i+1:]...)
			s.ctx.Invalidate()
			return
		}
	}
}

func (s *board) onPress(p geom.Pt) {
	if s.drawArea.Contains(p) {
		s.erased = false
		if s.tool == toolEraser {
			s.eraseAt(p)
		} else {
			s.startElement(p)
		}
		return
	}
	s.cur = nil // a toolbar press is not a drawing
	for i := range palette {
		if s.swatch[i].Contains(p) {
			s.col = palette[i]
			s.ctx.Invalidate()
			return
		}
	}
	for i := range numTools {
		if s.toolBtn[i].Contains(p) {
			s.tool = i
			s.ctx.Invalidate()
			return
		}
	}
	switch {
	case s.widthBtn[0].Contains(p):
		s.w = widths[0]
		s.ctx.Invalidate()
	case s.widthBtn[1].Contains(p):
		s.w = widths[1]
		s.ctx.Invalidate()
	case s.widthBtn[2].Contains(p):
		s.w = widths[2]
		s.ctx.Invalidate()
	case s.undoBtn.Contains(p):
		s.doUndo()
	case s.redoBtn.Contains(p):
		s.doRedo()
	case s.clearBtn.Contains(p):
		s.doClear()
	}
}

func (s *board) Build(_ widget.Ctx) widget.Widget {
	return widget.Interactive{
		Gestures: widget.Gestures{
			OnPress:   func(p geom.Pt) { s.onPress(p) },
			OnDrag:    func(pos, _ geom.Pt) { s.drag(pos) },
			OnRelease: func() { s.commit() }, // fires after a drag
			OnTap:     func() { s.commit() }, // fires after a tap (a dot)
			OnKey: func(k shell.Key) {
				if k.Kind == shell.KeyPress && (k.Code == shell.KeyBackspace || k.Code == shell.KeyDelete) {
					s.doUndo()
				}
			},
		},
		Child: widget.Canvas{Clip: true, Draw: s.draw},
	}
}

func (s *board) render(c paint.Canvas, e *element) {
	if e.cache == nil {
		e.build()
	}
	if e.tool == toolPen {
		c.FillPath(e.cache, e.col)
	} else {
		c.StrokePath(e.cache, e.w, e.col)
	}
}

func (s *board) draw(c paint.Canvas, sz geom.Size) {
	s.drawArea = geom.RectXYWH(0, toolbarH, sz.W, sz.H-toolbarH)
	c.FillRect(s.drawArea, paper)

	for _, e := range s.elements {
		s.render(c, e)
	}
	if s.cur != nil {
		s.render(c, s.cur)
	}

	s.drawToolbar(c, sz)
}

func (s *board) drawToolbar(c paint.Canvas, sz geom.Size) {
	c.FillRect(geom.RectXYWH(0, 0, sz.W, toolbarH), toolbarBg)
	c.Line(geom.Pt{X: 0, Y: toolbarH}, geom.Pt{X: sz.W, Y: toolbarH}, 1, borderCol)

	cy := float32(toolbarH) / 2
	x := float32(14)

	// Tool selector: a rounded square per tool with a small vector icon.
	const tb = 30
	for i := range numTools {
		rect := geom.RectXYWH(x, cy-tb/2, tb, tb)
		s.toolBtn[i] = rect
		fg := btnFg
		if s.tool == i {
			c.FillRRect(rect, 7, ringCol)
			fg = activeFg
		}
		toolIcon(c, i, geom.InsetsAll(8).Inset(rect), fg)
		x += tb + 4
	}

	x += 12 // separator

	// Color swatches.
	r := float32(9)
	for i := range palette {
		rect := geom.RectXYWH(x, cy-r, 2*r, 2*r)
		s.swatch[i] = rect
		if s.col == palette[i] {
			c.FillRRect(geom.RectXYWH(x-3, cy-r-3, 2*r+6, 2*r+6), r+3, ringCol)
		}
		c.FillRRect(rect, r, palette[i])
		x += 2*r + 8
	}

	x += 12 // separator

	// Width options: three dots sized by their stroke width.
	for i, w := range widths {
		cell := geom.RectXYWH(x, 0, 30, toolbarH)
		s.widthBtn[i] = cell
		if s.w == w {
			c.FillRRect(geom.RectXYWH(x+1, cy-15, 28, 30), 8, ringCol)
		}
		dot := w + 3
		fg := btnFg
		if s.w == w {
			fg = activeFg
		}
		c.FillRRect(geom.RectXYWH(x+15-dot/2, cy-dot/2, dot, dot), dot/2, fg)
		x += 30
	}

	// Right-aligned action buttons.
	bw, bh := float32(52), float32(32)
	rx := sz.W - 14 - bw
	s.clearBtn = geom.RectXYWH(rx, cy-bh/2, bw, bh)
	s.redoBtn = geom.RectXYWH(rx-6-bw, cy-bh/2, bw, bh)
	s.undoBtn = geom.RectXYWH(rx-2*(6+bw), cy-bh/2, bw, bh)
	s.textBtn(c, s.undoBtn, "Undo", len(s.undo) > 0)
	s.textBtn(c, s.redoBtn, "Redo", len(s.redo) > 0)
	s.textBtn(c, s.clearBtn, "Clear", len(s.elements) > 0)
}

func (s *board) textBtn(c paint.Canvas, r geom.Rect, label string, enabled bool) {
	c.FillRRect(r, 7, btnBg)
	c.StrokeRRect(r, 7, 1, borderCol)
	fg := btnFg
	if !enabled {
		fg = paint.RGB(0.75, 0.77, 0.80)
	}
	w := s.ctx.Painter().MeasureWidthIn("", label, 13)
	c.TextIn("", label, geom.Pt{X: r.Min.X + (r.Dx()-w)/2, Y: r.Min.Y + r.Dy()/2 + 5}, 13, fg)
}

// toolIcon draws a small glyph for each tool inside r, in color col.
func toolIcon(c paint.Canvas, k tool, r geom.Rect, col paint.Color) {
	const lw = 1.8
	bl := geom.Pt{X: r.Min.X, Y: r.Max.Y}
	tr := geom.Pt{X: r.Max.X, Y: r.Min.Y}
	switch k {
	case toolPen:
		c.Line(bl, tr, lw, col) // a pen held nib-down
		nib := bl.Lerp(tr, 0.22)
		c.Line(bl, geom.Pt{X: nib.X, Y: bl.Y}, lw, col)
	case toolRect:
		c.StrokeRRect(r, 3, lw, col)
	case toolDiamond:
		for _, e := range diamondEdges(r) {
			c.Line(e[0], e[1], lw, col)
		}
	case toolEllipse:
		p := paint.NewPath()
		circleInto(p, center(r), r.Dx()/2, r.Dy()/2, 20)
		c.StrokePath(p, lw, col)
	case toolLine:
		c.Line(bl, tr, lw, col)
	case toolArrow:
		c.Line(bl, tr, lw, col)
		dir := norm(bl.Sub(tr))
		c.Line(tr, tr.Add(rot(dir, 0.5).Mul(6)), lw, col)
		c.Line(tr, tr.Add(rot(dir, -0.5).Mul(6)), lw, col)
	case toolEraser:
		block := geom.InsetsSymmetric(1, 4).Inset(r)
		c.StrokeRRect(block, 2, lw, col)
		c.Line(geom.Pt{X: block.Min.X, Y: center(block).Y}, geom.Pt{X: block.Max.X, Y: center(block).Y}, lw, col)
	}
}

func diamondEdges(r geom.Rect) [4][2]geom.Pt {
	p := diamondPts(r)
	return [4][2]geom.Pt{{p[0], p[1]}, {p[1], p[2]}, {p[2], p[3]}, {p[3], p[0]}}
}

// --- Rough (hand-drawn) shape building ------------------------------------
//
// Each builder appends wobbled subpaths to p; the caller strokes the whole
// network once with round caps/joins. Edges are laid down twice with
// independent jitter, and that overlap is what reads as hand-drawn.

// roughLineInto appends a wobbled a→b segment, twice. The segment is split into
// ~len/40 chunks (min 2); interior points are offset perpendicular by a small
// length-scaled jitter and threaded with QuadTo for a smooth waver. Endpoints
// jitter only slightly.
func roughLineInto(p *paint.Path, a, b geom.Pt, rng *rand.Rand) {
	d := b.Sub(a)
	length := vlen(d)
	if length < 0.5 {
		return
	}
	nrm := perp(d.Mul(1 / length))
	n := max(int(length/40), 2)
	amp := clampf(length*0.02, 0.5, 2)
	for range 2 {
		pts := make([]geom.Pt, n+1)
		for i := 0; i <= n; i++ {
			a2 := amp
			if i == 0 || i == n {
				a2 = amp * 0.35 // endpoints stay close to true
			}
			off := (rng.Float32()*2 - 1) * a2
			pts[i] = a.Lerp(b, float32(i)/float32(n)).Add(nrm.Mul(off))
		}
		quadThrough(p, pts)
	}
}

// roughRectInto draws the four edges, each with corners that slightly
// overshoot/undershoot rather than meeting cleanly.
func roughRectInto(p *paint.Path, r geom.Rect, rng *rand.Rand) {
	tl := r.Min
	tr := geom.Pt{X: r.Max.X, Y: r.Min.Y}
	br := r.Max
	bl := geom.Pt{X: r.Min.X, Y: r.Max.Y}
	edge := func(a, b geom.Pt) {
		d := norm(b.Sub(a))
		a2 := a.Sub(d.Mul(rng.Float32() * 3)) // pull the ends past the corners
		b2 := b.Add(d.Mul(rng.Float32() * 3))
		roughLineInto(p, a2, b2, rng)
	}
	edge(tl, tr)
	edge(tr, br)
	edge(br, bl)
	edge(bl, tl)
}

// roughDiamondInto draws the 4 edges between the midpoints of r's sides.
func roughDiamondInto(p *paint.Path, r geom.Rect, rng *rand.Rand) {
	d := diamondPts(r)
	roughLineInto(p, d[0], d[1], rng)
	roughLineInto(p, d[1], d[2], rng)
	roughLineInto(p, d[2], d[3], rng)
	roughLineInto(p, d[3], d[0], rng)
}

// roughEllipseInto samples points around the ellipse, jitters each radially, and
// threads them with QuadTo; it overshoots the seam and draws the loop twice.
func roughEllipseInto(p *paint.Path, r geom.Rect, rng *rand.Rand) {
	cx, cy := center(r).X, center(r).Y
	rx, ry := r.Dx()/2, r.Dy()/2
	if rx < 0.5 || ry < 0.5 {
		return
	}
	const n = 22
	for range 2 {
		pts := make([]geom.Pt, 0, n+3)
		for i := 0; i <= n+2; i++ { // +2 samples overlap the start
			a := 2 * math.Pi * float64(i) / float64(n)
			jit := (rng.Float32()*2 - 1) * 1.6
			pts = append(pts, geom.Pt{
				X: cx + (rx+jit)*float32(math.Cos(a)),
				Y: cy + (ry+jit)*float32(math.Sin(a)),
			})
		}
		quadThrough(p, pts)
	}
}

// roughArrowInto is a rough shaft plus two short rough barbs at b.
func roughArrowInto(p *paint.Path, a, b geom.Pt, rng *rand.Rand) {
	roughLineInto(p, a, b, rng)
	d := b.Sub(a)
	length := vlen(d)
	if length < 1 {
		return
	}
	back := d.Mul(-1 / length)
	const ang = 28 * math.Pi / 180
	head := clampf(length*0.25, 8, 22)
	roughLineInto(p, b, b.Add(rot(back, ang).Mul(head)), rng)
	roughLineInto(p, b, b.Add(rot(back, -ang).Mul(head)), rng)
}

// quadThrough threads an open polyline as a smooth path: midpoint quadratics
// with the sample points as control points.
func quadThrough(p *paint.Path, pts []geom.Pt) {
	switch len(pts) {
	case 0:
		return
	case 1:
		p.MoveTo(pts[0])
		return
	case 2:
		p.MoveTo(pts[0]).LineTo(pts[1])
		return
	}
	p.MoveTo(pts[0])
	for i := 1; i < len(pts)-1; i++ {
		p.QuadTo(pts[i], pts[i].Lerp(pts[i+1], 0.5))
	}
	p.LineTo(pts[len(pts)-1])
}

// --- Freehand pen (perfect-freehand-style filled outline) -----------------

// buildFreehand writes a filled, variable-width tapered outline for the raw pen
// points into p. Each point gets a half-width that ramps up over the first few
// points and down over the last few (and thins a little on fast segments); the
// centerline is offset ±half-width into a left and right rail, joined by rounded
// end and start caps. One point becomes a dot.
func buildFreehand(p *paint.Path, pts []geom.Pt, w float32) {
	base := w * 4 // pen strokes are chunkier than the hairline shapes
	if len(pts) == 0 {
		return
	}
	if len(pts) == 1 {
		circleInto(p, pts[0], base/2, base/2, 14)
		return
	}
	n := len(pts)
	const ramp = 4
	half := make([]float32, n)
	for i := range pts {
		tin := clampf(float32(i)/ramp, 0, 1)
		tout := clampf(float32(n-1-i)/ramp, 0, 1)
		t := tin
		if tout < t {
			t = tout
		}
		ease := t * t * (3 - 2*t) // smoothstep taper toward the ends
		speed := float32(0)
		if i > 0 {
			speed = dist(pts[i], pts[i-1])
		}
		sf := 1 - clampf((speed-6)/60, 0, 0.35) // thin slightly on fast strokes
		half[i] = clampf(base*0.5*ease*sf, 0.35, base)
	}

	left := make([]geom.Pt, n)
	right := make([]geom.Pt, n)
	for i := range pts {
		nrm := perp(tangent(pts, i))
		left[i] = pts[i].Add(nrm.Mul(half[i]))
		right[i] = pts[i].Sub(nrm.Mul(half[i]))
	}

	// Outline: left rail forward, end cap, right rail backward, start cap.
	out := make([]geom.Pt, 0, 2*n+16)
	out = append(out, left...)
	out = append(out, capPts(pts[n-1], tangent(pts, n-1), half[n-1], false)...)
	for i := n - 1; i >= 0; i-- {
		out = append(out, right[i])
	}
	out = append(out, capPts(pts[0], tangent(pts, 0), half[0], true)...)
	closedSmooth(p, out)
}

// tangent is the unit centerline direction at point i.
func tangent(pts []geom.Pt, i int) geom.Pt {
	n := len(pts)
	switch {
	case n < 2:
		return geom.Pt{X: 1}
	case i == 0:
		return norm(pts[1].Sub(pts[0]))
	case i == n-1:
		return norm(pts[i].Sub(pts[i-1]))
	default:
		return norm(pts[i+1].Sub(pts[i-1]))
	}
}

// capPts returns the interior arc points of a rounded cap of radius r at center,
// bulging along +dir for an end cap (start=false) or −dir for a start cap. The
// rail endpoints themselves are omitted (they're already in the outline).
func capPts(center, dir geom.Pt, r float32, start bool) []geom.Pt {
	const steps = 6
	nrm := perp(dir)
	out := make([]geom.Pt, 0, steps-1)
	for k := 1; k < steps; k++ {
		t := math.Pi * float64(k) / steps
		cs := float32(math.Cos(t))
		sn := float32(math.Sin(t))
		if start { // from −nrm through −dir to +nrm
			out = append(out, center.Sub(nrm.Mul(cs*r)).Sub(dir.Mul(sn*r)))
		} else { // from +nrm through +dir to −nrm
			out = append(out, center.Add(nrm.Mul(cs*r)).Add(dir.Mul(sn*r)))
		}
	}
	return out
}

// closedSmooth fills a closed loop through pts with midpoint quadratics.
func closedSmooth(p *paint.Path, pts []geom.Pt) {
	n := len(pts)
	if n < 3 {
		if n > 0 {
			p.MoveTo(pts[0])
		}
		for i := 1; i < n; i++ {
			p.LineTo(pts[i])
		}
		if n > 0 {
			p.Close()
		}
		return
	}
	p.MoveTo(pts[n-1].Lerp(pts[0], 0.5))
	for i := range n {
		p.QuadTo(pts[i], pts[i].Lerp(pts[(i+1)%n], 0.5))
	}
	p.Close()
}

// circleInto writes a smooth closed ellipse of radii rx, ry into p.
func circleInto(p *paint.Path, center geom.Pt, rx, ry float32, n int) {
	pts := make([]geom.Pt, n)
	for i := range n {
		a := 2 * math.Pi * float64(i) / float64(n)
		pts[i] = geom.Pt{X: center.X + rx*float32(math.Cos(a)), Y: center.Y + ry*float32(math.Sin(a))}
	}
	closedSmooth(p, pts)
}

// --- small vector / geometry helpers --------------------------------------

func bounds(a, b geom.Pt) geom.Rect {
	return geom.Rect{
		Min: geom.Pt{X: min(a.X, b.X), Y: min(a.Y, b.Y)},
		Max: geom.Pt{X: max(a.X, b.X), Y: max(a.Y, b.Y)},
	}
}

func center(r geom.Rect) geom.Pt {
	return geom.Pt{X: (r.Min.X + r.Max.X) / 2, Y: (r.Min.Y + r.Max.Y) / 2}
}

func clampToRect(p geom.Pt, r geom.Rect) geom.Pt {
	return geom.Pt{X: clampf(p.X, r.Min.X, r.Max.X), Y: clampf(p.Y, r.Min.Y, r.Max.Y)}
}

func rectPts(r geom.Rect) []geom.Pt {
	return []geom.Pt{r.Min, {X: r.Max.X, Y: r.Min.Y}, r.Max, {X: r.Min.X, Y: r.Max.Y}}
}

func diamondPts(r geom.Rect) []geom.Pt {
	c := center(r)
	return []geom.Pt{{X: c.X, Y: r.Min.Y}, {X: r.Max.X, Y: c.Y}, {X: c.X, Y: r.Max.Y}, {X: r.Min.X, Y: c.Y}}
}

func ellipsePts(r geom.Rect, n int) []geom.Pt {
	c := center(r)
	rx, ry := r.Dx()/2, r.Dy()/2
	pts := make([]geom.Pt, n)
	for i := range n {
		a := 2 * math.Pi * float64(i) / float64(n)
		pts[i] = geom.Pt{X: c.X + rx*float32(math.Cos(a)), Y: c.Y + ry*float32(math.Sin(a))}
	}
	return pts
}

// polyHit reports whether p is within tol of any segment of the polyline (its
// closing edge too when closed).
func polyHit(pts []geom.Pt, p geom.Pt, tol float32, closed bool) bool {
	if len(pts) == 0 {
		return false
	}
	if len(pts) == 1 {
		return dist(pts[0], p) <= tol
	}
	for i := 0; i+1 < len(pts); i++ {
		if segDist(pts[i], pts[i+1], p) <= tol {
			return true
		}
	}
	if closed && len(pts) > 2 && segDist(pts[len(pts)-1], pts[0], p) <= tol {
		return true
	}
	return false
}

// segDist is the distance from p to segment a–b.
func segDist(a, b, p geom.Pt) float32 {
	ab := b.Sub(a)
	l2 := ab.X*ab.X + ab.Y*ab.Y
	if l2 == 0 {
		return dist(a, p)
	}
	t := clampf(((p.X-a.X)*ab.X+(p.Y-a.Y)*ab.Y)/l2, 0, 1)
	return dist(p, a.Add(ab.Mul(t)))
}

func vlen(p geom.Pt) float32 { return float32(math.Hypot(float64(p.X), float64(p.Y))) }

func norm(p geom.Pt) geom.Pt {
	l := vlen(p)
	if l == 0 {
		return geom.Pt{}
	}
	return geom.Pt{X: p.X / l, Y: p.Y / l}
}

func perp(p geom.Pt) geom.Pt { return geom.Pt{X: -p.Y, Y: p.X} }

func rot(v geom.Pt, t float32) geom.Pt {
	cs := float32(math.Cos(float64(t)))
	sn := float32(math.Sin(float64(t)))
	return geom.Pt{X: v.X*cs - v.Y*sn, Y: v.X*sn + v.Y*cs}
}

func clampf(v, lo, hi float32) float32 {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

func dist(a, b geom.Pt) float32 {
	return float32(math.Hypot(float64(a.X-b.X), float64(a.Y-b.Y)))
}

// sampleElements seeds the board with a little rough sketch so a fresh canvas
// (and the gallery thumbnail) isn't blank.
func sampleElements() []*element {
	shape := func(t tool, a, b geom.Pt, col paint.Color, w float32, seed int64) *element {
		return &element{tool: t, a: a, b: b, col: col, w: w, seed: seed}
	}
	pen := func(col paint.Color, w float32, pts ...geom.Pt) *element {
		return &element{tool: toolPen, pts: pts, col: col, w: w}
	}
	squiggle := make([]geom.Pt, 0, 24)
	for i := 0; i <= 22; i++ {
		t := float32(i) / 22
		squiggle = append(squiggle, geom.Pt{
			X: 500 + t*180,
			Y: 300 + 26*float32(math.Sin(float64(t)*3*math.Pi)),
		})
	}
	return []*element{
		shape(toolRect, geom.Pt{X: 70, Y: 110}, geom.Pt{X: 230, Y: 220}, palette[2], widths[1], 1),
		shape(toolDiamond, geom.Pt{X: 270, Y: 110}, geom.Pt{X: 410, Y: 230}, palette[3], widths[1], 2),
		shape(toolEllipse, geom.Pt{X: 450, Y: 110}, geom.Pt{X: 610, Y: 220}, palette[4], widths[1], 3),
		shape(toolArrow, geom.Pt{X: 70, Y: 300}, geom.Pt{X: 240, Y: 360}, palette[1], widths[1], 4),
		shape(toolLine, geom.Pt{X: 270, Y: 360}, geom.Pt{X: 430, Y: 290}, palette[5], widths[0], 5),
		pen(palette[0], widths[1], squiggle...),
	}
}

func main() {
	if err := app.Run(Board{}, app.Config{
		Title:      "Whiteboard",
		Size:       geom.Size{W: 900, H: 560},
		Background: paper,
		Font:       goregular.TTF,
	}); err != nil {
		log.Fatal(err)
	}
}