socle

sketch.go

package main

import (
	"fmt"
	"hash/fnv"
	"math"
	"strings"
)

// This file generates the sketch assets: small SVGs of hand-wobbled strokes, boxes,
// circles, and hatching, which the stylesheet uses as backgrounds and border-images.
// Everything is a pure function of (theme seed, purpose), so the bytes are stable,
// snapshot-testable, and safe to cache hard.
//
// SVG served as a CSS background-image cannot see the page's CSS variables or media
// queries reliably, so every asset is generated once per color scheme (light/dark)
// and the stylesheet picks the right one in a media query.

// sketchRand is a tiny owned PRNG: splitmix64 over an fnv1a-hashed string seed.
// Owning the generator (rather than using a stdlib rand) keeps the wobble byte-stable
// across Go releases; snapshots and long-lived caches pin these bytes.
type sketchRand struct{ state uint64 }

func newSketchRand(seed string) *sketchRand {
	h := fnv.New64a()
	h.Write([]byte(seed))
	return &sketchRand{state: h.Sum64()}
}

func (r *sketchRand) next() uint64 {
	r.state += 0x9e3779b97f4a7c15
	z := r.state
	z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
	z = (z ^ (z >> 27)) * 0x94d049bb133111eb
	return z ^ (z >> 31)
}

// rangef returns a uniform float64 in [lo, hi).
func (r *sketchRand) rangef(lo, hi float64) float64 {
	return lo + (hi-lo)*(float64(r.next()>>11)/(1<<53))
}

type pt struct{ x, y float64 }

// wobblePath emits a run of cubic bezier segments through the given anchor points,
// with control points drifting perpendicular to each chord by up to amp.
// Anchors are used exactly as given: callers place (and jitter) them,
// which lets tiling assets pin zero displacement at tile seams.
func wobblePath(sb *strings.Builder, rng *sketchRand, pts []pt, amp float64) {
	fmt.Fprintf(sb, "M%.1f %.1f", pts[0].x, pts[0].y)
	for i := 1; i < len(pts); i++ {
		a, b := pts[i-1], pts[i]
		dx, dy := b.x-a.x, b.y-a.y
		ln := math.Hypot(dx, dy)
		if ln == 0 {
			ln = 1
		}
		px, py := -dy/ln, dx/ln
		j1, j2 := rng.rangef(-amp, amp), rng.rangef(-amp, amp)
		fmt.Fprintf(sb, "C%.1f %.1f %.1f %.1f %.1f %.1f",
			a.x+dx/3+px*j1, a.y+dy/3+py*j1,
			a.x+2*dx/3+px*j2, a.y+2*dy/3+py*j2,
			b.x, b.y)
	}
}

// roughLine emits one hand-drawn stroke: endpoints jittered and overshooting slightly
// past where a ruler would stop (the sketch tell), body wobbling by up to amp.
func roughLine(sb *strings.Builder, rng *sketchRand, a, b pt, amp, over float64) {
	dx, dy := b.x-a.x, b.y-a.y
	ln := math.Hypot(dx, dy)
	if ln == 0 {
		return
	}
	ux, uy := dx/ln, dy/ln
	oa, ob := rng.rangef(0, over), rng.rangef(0, over)
	ej := amp / 2
	a = pt{a.x - ux*oa + rng.rangef(-ej, ej), a.y - uy*oa + rng.rangef(-ej, ej)}
	b = pt{b.x + ux*ob + rng.rangef(-ej, ej), b.y + uy*ob + rng.rangef(-ej, ej)}
	wobblePath(sb, rng, []pt{a, b}, amp)
}

// roughBox emits four rough strokes around a rectangle, overshooting at the corners.
func roughBox(sb *strings.Builder, rng *sketchRand, x, y, w, h, amp, over float64) {
	roughLine(sb, rng, pt{x, y}, pt{x + w, y}, amp, over)
	roughLine(sb, rng, pt{x + w, y}, pt{x + w, y + h}, amp, over)
	roughLine(sb, rng, pt{x + w, y + h}, pt{x, y + h}, amp, over)
	roughLine(sb, rng, pt{x, y + h}, pt{x, y}, amp, over)
}

// roughCircle emits a hand-drawn circle: four quarter arcs between anchors at jittered
// angles and radii, closing with a small miss at the seam, like a pen lifting.
func roughCircle(sb *strings.Builder, rng *sketchRand, c pt, r, amp float64) {
	const k = 0.5523 // the standard cubic-arc handle ratio for a quarter circle
	start := rng.rangef(0, 2*math.Pi)
	var anchors [5]pt
	var angles [5]float64
	var radii [5]float64
	for i := 0; i < 4; i++ {
		angles[i] = start + float64(i)*math.Pi/2 + rng.rangef(-0.09, 0.09)
		radii[i] = r + rng.rangef(-amp, amp)
		anchors[i] = pt{c.x + radii[i]*math.Cos(angles[i]), c.y + radii[i]*math.Sin(angles[i])}
	}
	angles[4], radii[4] = angles[0], radii[0]
	anchors[4] = pt{anchors[0].x + rng.rangef(-amp, amp), anchors[0].y + rng.rangef(-amp, amp)}
	fmt.Fprintf(sb, "M%.1f %.1f", anchors[0].x, anchors[0].y)
	for i := 0; i < 4; i++ {
		h1 := k * radii[i] * rng.rangef(0.9, 1.1)
		h2 := k * radii[i+1] * rng.rangef(0.9, 1.1)
		fmt.Fprintf(sb, "C%.1f %.1f %.1f %.1f %.1f %.1f",
			anchors[i].x-h1*math.Sin(angles[i]), anchors[i].y+h1*math.Cos(angles[i]),
			anchors[i+1].x+h2*math.Sin(angles[i+1]), anchors[i+1].y-h2*math.Cos(angles[i+1]),
			anchors[i+1].x, anchors[i+1].y)
	}
}

// ---- the assets ----

// sketchPalette is the per-scheme ink color; each purpose picks its own opacity,
// following choosing-colors: structure gets tints of the ink, never a new hue.
type sketchPalette struct {
	scheme string // "light" or "dark", the asset filename suffix
	ink    string
}

var sketchPalettes = []sketchPalette{
	{"light", "#24292f"},
	{"dark", "#e6edf3"},
}

// sketchAssets builds every sketch SVG for one theme seed, keyed by filename
// (e.g. "frame-light.svg").  Each asset seeds its own PRNG with seed+purpose,
// so assets wobble independently and adding one never reshuffles the others.
func sketchAssets(seed string) map[string][]byte {
	m := map[string][]byte{}
	for _, p := range sketchPalettes {
		m["frame-"+p.scheme+".svg"] = frameSVG(seed, p)
		m["rule-"+p.scheme+".svg"] = ruleSVG(seed, p)
		m["flank-"+p.scheme+".svg"] = flankSVG(seed, p)
		m["ground-"+p.scheme+".svg"] = groundSVG(seed, p)
		for _, v := range []string{"a", "b", "c"} {
			m["circle-"+v+"-"+p.scheme+".svg"] = circleSVG(seed+"/"+v, p)
		}
	}
	return m
}

// svgOpen writes the opening tag, with explicit width/height: an SVG without intrinsic
// dimensions misrenders as a border-image (the slice offsets have no size to resolve against).
func svgOpen(sb *strings.Builder, w, h int) {
	fmt.Fprintf(sb, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, w, h, w, h)
	sb.WriteString("\n")
}

// svgPath writes one stroked path element holding all the segments accumulated in d.
func svgPath(sb *strings.Builder, d string, ink string, width, opacity float64) {
	fmt.Fprintf(sb, `<path d="%s" fill="none" stroke="%s" stroke-width="%.1f" stroke-opacity="%.2f" stroke-linecap="round"/>`, d, ink, width, opacity)
	sb.WriteString("\n")
}

// frameSVG is the border-image tile for rough-ruled frames: a 96x96 tile, sliced at 16,
// with the lines drawn at inset 8 -- so a 13px border renders the slice close to 1:1
// and the stroke survives at full weight.  Displacement is pinned to zero where the
// edge lines cross the slice boundaries, so border-image-repeat: round tiles the
// middles without visible seams; the corner regions wobble freely and the lines
// overshoot past each crossing.
func frameSVG(seed string, p sketchPalette) []byte {
	rng := newSketchRand(seed + "/frame/" + p.scheme)
	var sb strings.Builder
	svgOpen(&sb, 96, 96)
	edge := func(d *strings.Builder, amp float64) {
		j := func() float64 { return rng.rangef(-amp, amp) }
		// Each edge runs corner to corner at inset 8, crossing the perpendicular
		// edges near the corners; anchors at 16 and 80 sit exactly on the line.
		wobblePath(d, rng, []pt{{3.5 + j(), 8 + j()}, {16, 8}, {48 + j(), 8 + j()}, {80, 8}, {92.5 + j(), 8 + j()}}, amp)
		wobblePath(d, rng, []pt{{88 + j(), 3.5 + j()}, {88, 16}, {88 + j(), 48 + j()}, {88, 80}, {88 + j(), 92.5 + j()}}, amp)
		wobblePath(d, rng, []pt{{92.5 + j(), 88 + j()}, {80, 88}, {48 + j(), 88 + j()}, {16, 88}, {3.5 + j(), 88 + j()}}, amp)
		wobblePath(d, rng, []pt{{8 + j(), 92.5 + j()}, {8, 80}, {8 + j(), 48 + j()}, {8, 16}, {8 + j(), 3.5 + j()}}, amp)
	}
	var d1, d2 strings.Builder
	edge(&d1, 1.3)
	edge(&d2, 1.8)
	svgPath(&sb, d1.String(), p.ink, 1.8, 0.75)
	svgPath(&sb, d2.String(), p.ink, 1.4, 0.28) // the ghost pass: a second, fainter hand
	sb.WriteString("</svg>\n")
	return []byte(sb.String())
}

// ruleSVG is a wide, thin tile holding one hand-ruled horizontal line,
// for border-images on nav and heading underlines.  Displacement is zero at both
// tile ends so repeat tiles cleanly.
func ruleSVG(seed string, p sketchPalette) []byte {
	rng := newSketchRand(seed + "/rule/" + p.scheme)
	var sb strings.Builder
	svgOpen(&sb, 480, 12)
	anchorRow := func(amp float64) []pt {
		pts := []pt{{0, 6}}
		for x := 96.0; x < 480; x += 96 {
			pts = append(pts, pt{x + rng.rangef(-8, 8), 6 + rng.rangef(-amp, amp)})
		}
		return append(pts, pt{480, 6})
	}
	var d1, d2 strings.Builder
	wobblePath(&d1, rng, anchorRow(1.1), 1.1)
	wobblePath(&d2, rng, anchorRow(1.5), 1.5)
	svgPath(&sb, d1.String(), p.ink, 1.8, 0.7)
	svgPath(&sb, d2.String(), p.ink, 1.3, 0.25)
	sb.WriteString("</svg>\n")
	return []byte(sb.String())
}

// circleSVG is one round-button circle: a 96x96 tile, drawn twice (a crisp pass and a
// fainter ghost) so buttons look sketched rather than stamped.  Three variants exist
// (seed carries the variant) so hover and active states can swap drawings without JS.
func circleSVG(seed string, p sketchPalette) []byte {
	rng := newSketchRand(seed + "/circle/" + p.scheme)
	var sb strings.Builder
	svgOpen(&sb, 96, 96)
	var d1, d2 strings.Builder
	roughCircle(&d1, rng, pt{48, 48}, 42, 2.0)
	roughCircle(&d2, rng, pt{48, 48}, 42, 2.8)
	svgPath(&sb, d1.String(), p.ink, 2.4, 0.85)
	svgPath(&sb, d2.String(), p.ink, 1.6, 0.3)
	sb.WriteString("</svg>\n")
	return []byte(sb.String())
}

// flankSVG is the margin cross-hatching: a 240x720 tile for background-repeat: repeat-y,
// densest at x=0 (the viewport edge), fading to nothing before the content column.
// Strokes near the tile's top or bottom are drawn again one tile-height away,
// so the vertical repeat has no visible seam.
func flankSVG(seed string, p sketchPalette) []byte {
	const W, H = 240.0, 720.0
	rng := newSketchRand(seed + "/flank/" + p.scheme)
	var sb strings.Builder
	svgOpen(&sb, int(W), int(H))
	var d strings.Builder
	hatch := func(n int, angle, spread float64, maxX float64) {
		for i := 0; i < n; i++ {
			// Cube-root sampling gives density falling off quadratically toward maxX.
			u := rng.rangef(0, 1)
			cx := maxX * (1 - math.Cbrt(u))
			cy := rng.rangef(0, H)
			ln := rng.rangef(9, 17) // half-length
			a := angle + rng.rangef(-spread, spread)
			dx, dy := ln*math.Cos(a), ln*math.Sin(a)
			stroke := func(y float64) {
				roughLine(&d, rng, pt{cx - dx, y - dy}, pt{cx + dx, y + dy}, 0.9, 1.5)
			}
			stroke(cy)
			if cy < 40 {
				stroke(cy + H)
			}
			if cy > H-40 {
				stroke(cy - H)
			}
		}
	}
	hatch(150, -0.96, 0.14, W)   // the main pencil direction, steep backslash
	hatch(70, 1.05, 0.14, W*0.6) // the crossing direction, only in the denser band
	svgPath(&sb, d.String(), p.ink, 1.0, 0.3)
	sb.WriteString("</svg>\n")
	return []byte(sb.String())
}

// groundSVG is the footer floor: hatching gathering downward into a hand-ruled baseline,
// with a small pedestal glyph standing on it at center.  Positioned bottom-center as a
// single non-repeating background; density converging is the arrival signal.
func groundSVG(seed string, p sketchPalette) []byte {
	const W, H = 1800.0, 160.0
	const baseY = 120.0
	rng := newSketchRand(seed + "/ground/" + p.scheme)
	var sb strings.Builder
	svgOpen(&sb, int(W), int(H))
	// The floor hatching: strokes gathering into the baseline.  Height above the line
	// is cubed-uniform, so density rises sharply on approach; strokes also flatten and
	// shorten as they near the line, and the near band gets its own darker pass so the
	// convergence is tonal as well as spatial.  A soft horizontal envelope fades the ends.
	var dNear, dFar strings.Builder
	for i := 0; i < 420; i++ {
		cx := rng.rangef(30, W-30)
		env := math.Sin(math.Pi * cx / W)
		if rng.rangef(0, 1) > env*env {
			continue
		}
		u := rng.rangef(0, 1)
		cy := baseY - 2 - 95*u*u*u
		ln := rng.rangef(7, 12+16*u)
		a := -(0.05 + 0.5*u) + rng.rangef(-0.08, 0.08)
		dx, dy := ln*math.Cos(a), ln*math.Sin(a)
		d := &dNear
		if baseY-cy > 20 {
			d = &dFar
		}
		roughLine(d, rng, pt{cx - dx, cy - dy}, pt{cx + dx, cy + dy}, 0.9, 1.5)
	}
	svgPath(&sb, dNear.String(), p.ink, 1.0, 0.42)
	svgPath(&sb, dFar.String(), p.ink, 1.0, 0.22)
	// The baseline: one long rule, drawn twice.
	var db strings.Builder
	baseRow := func(amp float64) []pt {
		pts := []pt{{30 + rng.rangef(-6, 6), baseY + rng.rangef(-amp, amp)}}
		for x := 300.0; x < W-200; x += 300 {
			pts = append(pts, pt{x + rng.rangef(-30, 30), baseY + rng.rangef(-amp, amp)})
		}
		return append(pts, pt{W - 30 + rng.rangef(-6, 6), baseY + rng.rangef(-amp, amp)})
	}
	wobblePath(&db, rng, baseRow(1.6), 1.6)
	wobblePath(&db, rng, baseRow(2.2), 2.2)
	svgPath(&sb, db.String(), p.ink, 1.8, 0.65)
	// The pedestal: cap, die, plinth, standing centered on the baseline.
	var dp strings.Builder
	roughBox(&dp, rng, 858, 58, 84, 13, 1.0, 2.5)
	roughBox(&dp, rng, 870, 71, 60, 34, 1.0, 2.5)
	roughBox(&dp, rng, 850, 105, 100, 15, 1.0, 2.5)
	svgPath(&sb, dp.String(), p.ink, 1.6, 0.75)
	sb.WriteString("</svg>\n")
	return []byte(sb.String())
}

sketch.go · 12.6 KiB · 68bc19f54a