Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

gemsart.gno

116.32 Kb · 2986 lines
   1// Package gemsartfab: on-chain SVG generator for the "gem in a ring"
   2// collection -- the "fab" iteration of gemsart2, re-lit to read as a
   3// transparent, polished crystal instead of a flat matte cutout. Same
   4// geometry, same traits, same cascade schedule; what changed is only
   5// how a faceted stone is painted (see the gem shapes section). Two hard
   6// constraints shaped every choice there: bytes per token (a token is
   7// re-rendered on every TokenURI/Render read and shipped inline) and
   8// WebKit rendering cost -- this project has twice fixed real Safari
   9// slowdowns by removing SVG <filter>s and SMIL, so the new look uses
  10// only static gradients, paths, one inherited stroke, and CSS opacity
  11// keyframes. Nothing else.
  12//
  13// Originally ported from the Python prototype used to design and
  14// approve the first look. Every function here is pure — no state reads
  15// beyond its own stone/color tables — so a token's full image can be
  16// recomputed on any read (TokenURI/Render) instead of being stored as a
  17// precomputed blob.
  18//
  19// A separate deploy from the nftminter realm that imports it (not one
  20// combined package as originally designed) -- gnomcp's own gno_addpkg
  21// tool hardcodes a 10,000,000 ugnot max-deposit ceiling per deploy call
  22// with no override, and the combined source (~175KB) needs ~17.6M. Each
  23// half fits under that ceiling deployed separately. The public surface
  24// below (ShapeNames/MetalNames/RenderTokenSVG/StoneForChain/
  25// ColorPaletteSize, plus SetStoneForChain/AddStoneColor/StoneNamesCSV/
  26// ColorPaletteCSV/MetalToneHex) exists specifically so nftminter.gno can
  27// still reach everything it needs across that package boundary.
  28//
  29// This is the architecture change requested explicitly, back when this
  30// was still one file: instead of the owner precomputing and storing a
  31// full ~7.6KB image string per token, this package's shared generator
  32// code is stored ONCE, and each token only needs a handful of small
  33// trait selections (see traitPreset in nftminter.gno) — the image is
  34// assembled from those on demand. Storage cost stops scaling with token
  35// count; it's dominated by this package's own one-time deployment cost
  36// instead.
  37package gemsart3d2
  38
  39import (
  40	"crypto/sha256"
  41	"math"
  42	"strings"
  43
  44	"gno.land/p/nt/ufmt/v0"
  45)
  46
  47const (
  48	canvasW, canvasH = 170.0, 170.0
  49	subjX, subjY     = 85.0, 78.0
  50)
  51
  52/* ------------------------------ number/color formatting ------------------------------ */
  53
  54// fnum formats a float to 2 decimal places without depending on ufmt
  55// supporting %f/%g (uncertain in this environment) — plain integer math
  56// and %d instead. Zero-pads the fraction manually rather than via
  57// "%02d": ufmt.Sprintf does NOT implement width/zero-pad flags (confirmed
  58// empirically -- Sprintf("%d.%02d", 1, 9) returns "1.9", not "1.09"), so
  59// relying on "%02d" silently corrupted any value whose hundredths digit
  60// rounded to 1-9 (e.g. 1.09 rendered as "1.9", a ~10x error) throughout
  61// this whole file, since every coordinate/radius/angle goes through this
  62// one function.
  63// itoa writes a non-negative integer's digits. Every number in a token
  64// goes through here: ufmt.Sprintf is general-purpose and, on-chain, most
  65// of a render's gas went to formatting coordinates with it.
  66func itoa(v int64) string {
  67	if v == 0 {
  68		return "0"
  69	}
  70	neg := v < 0
  71	if neg {
  72		v = -v
  73	}
  74	var b [20]byte
  75	i := len(b)
  76	for v > 0 {
  77		i--
  78		b[i] = byte('0' + v%10)
  79		v /= 10
  80	}
  81	if neg {
  82		i--
  83		b[i] = '-'
  84	}
  85	return string(b[i:])
  86}
  87
  88// fnum is the compact form every emitter uses: two decimals at most,
  89// trailing zeros and a leading "0" dropped ("85.00" -> "85", "0.45" ->
  90// ".45"). Valid in SVG attributes and CSS alike; a quarter of a token's
  91// bytes were padding before this.
  92func fnum(v float64) string { return cnum(v) }
  93
  94func fnumPad(v float64) string {
  95	neg := v < 0
  96	if neg {
  97		v = -v
  98	}
  99	scaled := int64(v*100 + 0.5)
 100	whole := scaled / 100
 101	frac := scaled % 100
 102	fracStr := itoa(frac)
 103	if frac < 10 {
 104		fracStr = "0" + fracStr
 105	}
 106	s := itoa(whole) + "." + fracStr
 107	if neg {
 108		return "-" + s
 109	}
 110	return s
 111}
 112
 113// fnum1 is fnum at one decimal -- for keyframe percentages, where the
 114// second decimal is a fraction of a frame and only costs bytes (the
 115// keyframe CSS is the largest slice of every token).
 116// cnum is fnum without padding: trailing zeros and a leading "0" before
 117// the point are dropped ("0.45" -> ".45", "85.00" -> "85"). Used by the
 118// ring and plate markup, which is emitted on every token read.
 119func cnum(v float64) string {
 120	s := fnumPad(v)
 121	if strings.Contains(s, ".") {
 122		s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
 123	}
 124	if strings.HasPrefix(s, "0.") {
 125		s = s[1:]
 126	} else if strings.HasPrefix(s, "-0.") {
 127		s = "-" + s[2:]
 128	}
 129	return s
 130}
 131
 132func fnum1(v float64) string {
 133	neg := v < 0
 134	if neg {
 135		v = -v
 136	}
 137	scaled := int64(v*10 + 0.5)
 138	s := itoa(scaled/10) + "." + itoa(scaled%10)
 139	if neg {
 140		return "-" + s
 141	}
 142	return s
 143}
 144
 145// pctnum formats a float already meant as a percentage (2 decimals, no
 146// clamping — callers clamp first where it matters).
 147func pctnum(v float64) string { return fnum(v) }
 148
 149func hexToRGB(hex string) (int, int, int) {
 150	h := hex
 151	if len(h) > 0 && h[0] == '#' {
 152		h = h[1:]
 153	}
 154	r := hexByte(h[0:2])
 155	g := hexByte(h[2:4])
 156	b := hexByte(h[4:6])
 157	return r, g, b
 158}
 159
 160func hexByte(s string) int {
 161	v := 0
 162	for i := 0; i < len(s); i++ {
 163		c := s[i]
 164		var d int
 165		switch {
 166		case c >= '0' && c <= '9':
 167			d = int(c - '0')
 168		case c >= 'a' && c <= 'f':
 169			d = int(c-'a') + 10
 170		case c >= 'A' && c <= 'F':
 171			d = int(c-'A') + 10
 172		}
 173		v = v*16 + d
 174	}
 175	return v
 176}
 177
 178func rgbToHex(r, g, b int) string {
 179	return "#" + hexDigit2(r) + hexDigit2(g) + hexDigit2(b)
 180}
 181
 182func hexDigit2(v int) string {
 183	if v < 0 {
 184		v = 0
 185	}
 186	if v > 255 {
 187		v = 255
 188	}
 189	const digits = "0123456789abcdef"
 190	return string(digits[v/16]) + string(digits[v%16])
 191}
 192
 193func clampByte(v float64) int {
 194	iv := int(v)
 195	if iv < 0 {
 196		return 0
 197	}
 198	if iv > 255 {
 199		return 255
 200	}
 201	return iv
 202}
 203
 204// lighten blends hex toward white by amt (0-1) — used so a neon glow's
 205// core reads as genuinely brighter than its own base color, not just a
 206// higher-opacity version of the same tone.
 207func lighten(hex string, amt float64) string {
 208	r, g, b := hexToRGB(hex)
 209	nr := clampByte(float64(r) + (255-float64(r))*amt)
 210	ng := clampByte(float64(g) + (255-float64(g))*amt)
 211	nb := clampByte(float64(b) + (255-float64(b))*amt)
 212	return rgbToHex(nr, ng, nb)
 213}
 214
 215// mixHex blends hex toward target by amt (0-1) — used to soften a hard
 216// two-color gradient stop into an in-between tone, e.g. the label bar's
 217// muted echo of the ring's own multi-band highlight/shadow swing.
 218func mixHex(hex, target string, amt float64) string {
 219	r1, g1, b1 := hexToRGB(hex)
 220	r2, g2, b2 := hexToRGB(target)
 221	nr := clampByte(float64(r1) + float64(r2-r1)*amt)
 222	ng := clampByte(float64(g1) + float64(g2-g1)*amt)
 223	nb := clampByte(float64(b1) + float64(b2-b1)*amt)
 224	return rgbToHex(nr, ng, nb)
 225}
 226
 227// hueRotate turns hex's hue by deg degrees -- the exact matrix SVG's
 228// feColorMatrix type="hueRotate" applies, done here once at render so
 229// no live filter is ever needed. Used for colour zoning: a real crystal
 230// is not one hue, it drifts a few degrees from facet to facet.
 231func hueRotate(hex string, deg float64) string {
 232	c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)
 233	r, g, b := hexToRGB(hex)
 234	fr, fg, fb := float64(r), float64(g), float64(b)
 235	nr := (0.213+c*0.787-sn*0.213)*fr + (0.715-c*0.715-sn*0.715)*fg + (0.072-c*0.072+sn*0.928)*fb
 236	ng := (0.213-c*0.213+sn*0.143)*fr + (0.715+c*0.285+sn*0.140)*fg + (0.072-c*0.072-sn*0.283)*fb
 237	nb := (0.213-c*0.213-sn*0.787)*fr + (0.715-c*0.715+sn*0.715)*fg + (0.072+c*0.928+sn*0.072)*fb
 238	return rgbToHex(clampByte(nr), clampByte(ng), clampByte(nb))
 239}
 240
 241// shadeSeries returns n shades of hex scaled between lo and hi brightness
 242// — the palette a faceted gem's individual facets pick from so adjacent
 243// triangles/quads read as catching light differently.
 244func shadeSeries(hex string, n int, lo, hi float64) []string {
 245	r, g, b := hexToRGB(hex)
 246	out := make([]string, n)
 247	for i := 0; i < n; i++ {
 248		t := lo
 249		if n > 1 {
 250			t = lo + (hi-lo)*float64(i)/float64(n-1)
 251		}
 252		out[i] = rgbToHex(clampByte(float64(r)*t), clampByte(float64(g)*t), clampByte(float64(b)*t))
 253	}
 254	return out
 255}
 256
 257/* --------------------------------- tiny deterministic PRNG --------------------------------- */
 258
 259// prng is a small deterministic generator seeded from a string, used only
 260// for per-piece COSMETIC animation timing (glow phase/duration, motion
 261// phases/rotation degree) — never for which trait combination a piece
 262// has (that's traitPreset, randomly assembled by nftminter.gno's own
 263// separate PRNG in randomTraitPreset, not curated and not this one).
 264// Determinism here doesn't weaken the anti-sniping design at all: knowing
 265// in advance that a glow pulses at 2.1s instead of 1.9s isn't a trait
 266// anyone would snipe for.
 267type prng struct {
 268	seed string
 269	n    int
 270}
 271
 272func newPRNG(seed string) *prng { return &prng{seed: seed} }
 273
 274func (p *prng) next() uint64 {
 275	p.n++
 276	material := p.seed + ":" + itoa(int64(p.n)) // same bytes as "%s:%d"
 277	sum := sha256.Sum256([]byte(material))
 278	var v uint64
 279	for i := 0; i < 8; i++ {
 280		v = v<<8 | uint64(sum[i])
 281	}
 282	return v
 283}
 284
 285func (p *prng) floatIn(lo, hi float64) float64 {
 286	frac := float64(p.next()%1000000) / 1000000.0
 287	return lo + frac*(hi-lo)
 288}
 289
 290func (p *prng) intn(n int) int {
 291	if n <= 1 {
 292		return 0
 293	}
 294	return int(p.next() % uint64(n))
 295}
 296
 297/* ------------------------------------ path helpers ------------------------------------ */
 298
 299type pt struct{ x, y float64 }
 300
 301func polyPath(pts []pt) string {
 302	s := "M"
 303	for i, p := range pts {
 304		if i > 0 {
 305			s += " L"
 306		}
 307		s += fnum(p.x) + "," + fnum(p.y)
 308	}
 309	return s + " Z"
 310}
 311
 312func polygonPoints(pts []pt) string {
 313	s := ""
 314	for i, p := range pts {
 315		if i > 0 {
 316			s += " "
 317		}
 318		s += fnum(p.x) + "," + fnum(p.y)
 319	}
 320	return s
 321}
 322
 323/* ------------------------------------ gem shapes ------------------------------------ */
 324// Stones are real 3D cuts, not flat diagrams. Each cut is a small solid
 325// (table, crown break, girdle, pavilion, culet) built from rings of
 326// vertices on the cut's own outline, tilted toward the viewer and
 327// projected. Every facet is shaded from its actual surface normal under
 328// one light, the pavilion's back facets are drawn first and show through
 329// the front ones, and the table is a window onto them -- which is what
 330// finally makes the stone read as transparent: there is now something
 331// behind it to see. Everything is still flat solid fills (one body-glow
 332// gradient per stone): the realism comes from geometry and normals, not
 333// from paint effects, so Safari pays nothing extra for it.
 334//
 335// Bytes are budgeted, this runs on every token read: relative path
 336// coordinates at one decimal, 3-digit hex colours (a facet is one flat
 337// tone, 12 bits is plenty), shared attributes hoisted onto groups, the
 338// girdle as ONE stroke, hidden crown back-faces never emitted, and the
 339// fire capped at ~14 overlays per stone however many facets a cut has.
 340
 341type gstop struct {
 342	pct   float64
 343	color string
 344	op    float64
 345}
 346
 347// gramp is a linear colour ramp in absolute coordinates, sampled by
 348// projection -- used for the spectral fire so each facet flashes the
 349// band of the spectrum it sits in.
 350type gramp struct {
 351	x1, y1, x2, y2 float64
 352	stops          []gstop
 353}
 354
 355func (g gramp) sample(px, py float64) (string, float64) {
 356	dx, dy := g.x2-g.x1, g.y2-g.y1
 357	t := ((px-g.x1)*dx + (py-g.y1)*dy) / (dx*dx + dy*dy) * 100
 358	if t < 0 {
 359		t = 0
 360	}
 361	if t > 100 {
 362		t = 100
 363	}
 364	for i := 1; i < len(g.stops); i++ {
 365		if t <= g.stops[i].pct {
 366			a, b := g.stops[i-1], g.stops[i]
 367			f := (t - a.pct) / (b.pct - a.pct)
 368			return mixHex(a.color, b.color, f), a.op + (b.op-a.op)*f
 369		}
 370	}
 371	last := g.stops[len(g.stops)-1]
 372	return last.color, last.op
 373}
 374
 375func centroid(poly []pt) (float64, float64) {
 376	cx, cy := 0.0, 0.0
 377	for _, p := range poly {
 378		cx += p.x
 379		cy += p.y
 380	}
 381	return cx / float64(len(poly)), cy / float64(len(poly))
 382}
 383
 384// facetStyle is what the fire needs from the stone: each lit-capable
 385// facet's 2D centroid in draw order, the spectral ramp, and the stone's
 386// optical profile.
 387type facetStyle struct {
 388	flash    gramp
 389	ringCent []pt
 390	prof     fireProfile
 391	zone     *prng // per-facet hue drift and shade jitter, seeded per token
 392	stone    string // which crystal habit a Raw Crystal takes
 393	// thumb: a list thumbnail -- skips what does not read at thumbnail
 394	// size (fire flashes, rock relighting, table reflections) to cut
 395	// render gas. The stone, its facets and colours are identical.
 396	thumb bool
 397	// glints: star sparkles applyCascade builds, drawn above everything
 398	// else on the stone (after the table reflections) so nothing dims them.
 399	glints string
 400}
 401
 402// fireProfile is how a stone's optics differ by what it is. Dispersion
 403// is a property of the material: colourless stones throw strong
 404// spectral fire, sapphire much less, topaz less still. shadow is what a
 405// facet darkens toward; bodyMix/neutral rebuild the working base for
 406// stones whose palette hex is near-white (see fireProfileFor).
 407type fireProfile struct {
 408	sat, hit  float64
 409	pinpoints int
 410	shadow    string
 411	bodyMix   float64
 412	neutral   string
 413}
 414
 415func fireProfileFor(stone, colorName string) fireProfile {
 416	if stone == "Diamond" {
 417		// dispersion 0.044, the highest of any stone here: every colour
 418		// throws strong spectral fire, the colourless one most of all.
 419		// Black diamond is opaque: shine only, almost no fire.
 420		switch colorName {
 421		case "Colorless":
 422			// a light body mix: a diamond is a contrast of near-white
 423			// and near-black facets, not a mid-grey (0.5 read as slate)
 424			return fireProfile{1.0, 0.85, 5, "#0d1222", 0.12, "#f4f7fc"}
 425		case "Black":
 426			return fireProfile{0.15, 0.35, 1, "#000000", 0, ""}
 427		}
 428		return fireProfile{0.95, 0.7, 4, "#0b0d18", 0, ""}
 429	}
 430	if stone == "Quartz" {
 431		// dispersion 0.013, the lowest here: faint fire, mostly glassy shine
 432		if colorName == "Rock Crystal" {
 433			return fireProfile{0.4, 0.6, 3, "#141a2c", 0.25, "#e8edf4"}
 434		}
 435		return fireProfile{0.35, 0.5, 3, "#000000", 0, ""}
 436	}
 437	if colorName == "Colorless" {
 438		return fireProfile{1.0, 0.7, 4, "#141a2c", 0.55, "#dfe4ee"}
 439	}
 440	if colorName == "White" {
 441		return fireProfile{0.85, 0.6, 3, "#7d86a0", 0, ""}
 442	}
 443	if colorName == "Crystal" {
 444		return fireProfile{1.0, 0.78, 4, "#141a2c", 0.3, "#e4e9f2"}
 445	}
 446	if stone == "Topaz" {
 447		return fireProfile{0.5, 0.5, 3, "#000000", 0, ""}
 448	}
 449	return fireProfile{0.7, 0.5, 3, "#000000", 0, ""}
 450}
 451
 452const fireZoneDeg = 7.0
 453
 454// rgbI is a colour as three 0-255 ints. The facet shading works on these
 455// instead of "#rrggbb" strings: parsing and re-formatting a hex string at
 456// every mix was a large share of a render's gas. Each op rounds exactly
 457// as its hex-string twin does (clampByte per channel), so the output is
 458// byte-identical.
 459type rgbI struct{ r, g, b int }
 460
 461func toRGBI(hex string) rgbI { r, g, b := hexToRGB(hex); return rgbI{r, g, b} }
 462
 463func (c rgbI) hex() string { return rgbToHex(c.r, c.g, c.b) }
 464
 465func mixI(a, b rgbI, amt float64) rgbI {
 466	return rgbI{
 467		clampByte(float64(a.r) + float64(b.r-a.r)*amt),
 468		clampByte(float64(a.g) + float64(b.g-a.g)*amt),
 469		clampByte(float64(a.b) + float64(b.b-a.b)*amt),
 470	}
 471}
 472
 473func hueRotateI(x rgbI, deg float64) rgbI {
 474	c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)
 475	fr, fg, fb := float64(x.r), float64(x.g), float64(x.b)
 476	nr := (0.213+c*0.787-sn*0.213)*fr + (0.715-c*0.715-sn*0.715)*fg + (0.072-c*0.072+sn*0.928)*fb
 477	ng := (0.213-c*0.213+sn*0.143)*fr + (0.715+c*0.285+sn*0.140)*fg + (0.072-c*0.072-sn*0.283)*fb
 478	nb := (0.213-c*0.213-sn*0.787)*fr + (0.715-c*0.715+sn*0.715)*fg + (0.072+c*0.928+sn*0.072)*fb
 479	return rgbI{clampByte(nr), clampByte(ng), clampByte(nb)}
 480}
 481
 482func hex3I(x rgbI) string {
 483	d := "0123456789abcdef"
 484	q := func(v int) byte { return d[(v+8)*15/255%16] }
 485	return "#" + string([]byte{q(x.r), q(x.g), q(x.b)})
 486}
 487
 488var whiteI = rgbI{255, 255, 255}
 489
 490func darken(hex, shadow string, amt float64) string {
 491	if amt > 0.95 {
 492		amt = 0.95
 493	}
 494	return mixHex(hex, shadow, amt)
 495}
 496
 497const prismFlash = true
 498
 499func flashRamp(ext, sat float64) gramp {
 500	r := gramp{x1: subjX - ext, y1: subjY - ext, x2: subjX + ext, y2: subjY + ext}
 501	if !prismFlash {
 502		r.stops = []gstop{{0, "#ffffff", 1}, {100, "#ffffff", 0.25}}
 503		return r
 504	}
 505	r.stops = []gstop{
 506		{0, "#ffffff", 1}, {14, "#ff4040", 0.95}, {28, "#ff9a1f", 0.95}, {42, "#ffe81f", 0.9},
 507		{56, "#35ff7a", 0.85}, {70, "#22ccff", 0.8}, {84, "#5566ff", 0.75}, {100, "#c04dff", 0.7},
 508	}
 509	for i := range r.stops {
 510		r.stops[i].color = mixHex(r.stops[i].color, "#ffffff", 1-sat)
 511	}
 512	return r
 513}
 514
 515/* ---- tiny vector maths ---- */
 516
 517type v3 struct{ x, y, z float64 }
 518
 519func vsub(a, b v3) v3       { return v3{a.x - b.x, a.y - b.y, a.z - b.z} }
 520func vdot(a, b v3) float64  { return a.x*b.x + a.y*b.y + a.z*b.z }
 521func vcross(a, b v3) v3     { return v3{a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x} }
 522func vscale(a v3, k float64) v3 { return v3{a.x * k, a.y * k, a.z * k} }
 523func vnorm(a v3) v3 {
 524	l := math.Sqrt(vdot(a, a))
 525	if l == 0 {
 526		return a
 527	}
 528	return v3{a.x / l, a.y / l, a.z / l}
 529}
 530
 531func sgnPow(v, e float64) float64 {
 532	if v < 0 {
 533		return -math.Pow(-v, e)
 534	}
 535	return math.Pow(v, e)
 536}
 537
 538/* ---- outlines: a closed curve t in [0,1) -> (x, y) in [-1,1] ---- */
 539
 540type outlineFn func(t float64) (float64, float64)
 541
 542func circleOutline(t float64) (float64, float64) {
 543	a := 2 * math.Pi * t
 544	return math.Cos(a), math.Sin(a)
 545}
 546
 547// superOutline is a superellipse: p=2 is an ellipse, higher p squares
 548// the shoulders -- a cushion.
 549func superOutline(p float64) outlineFn {
 550	return func(t float64) (float64, float64) {
 551		a := 2 * math.Pi * t
 552		return sgnPow(math.Cos(a), 2/p), sgnPow(math.Sin(a), 2/p)
 553	}
 554}
 555
 556// polyOutline walks a polygon by vertex index: t=k/len lands exactly on
 557// vertex k, so step cuts keep their true corners at any ring size.
 558func polyOutline(vs []pt) outlineFn {
 559	return func(t float64) (float64, float64) {
 560		n := len(vs)
 561		f := t * float64(n)
 562		k := int(math.Floor(f))
 563		fr := f - float64(k)
 564		a := vs[((k%n)+n)%n]
 565		b := vs[(((k+1)%n)+n)%n]
 566		return a.x + (b.x-a.x)*fr, a.y + (b.y-a.y)*fr
 567	}
 568}
 569
 570// octagon is a rectangle hw x hh with its corners cut by c.
 571func octagon(hw, hh, c float64) []pt {
 572	return []pt{
 573		{hw - c, hh}, {-hw + c, hh}, {-hw, hh - c}, {-hw, -hh + c},
 574		{-hw + c, -hh}, {hw - c, -hh}, {hw, -hh + c}, {hw, hh - c},
 575	}
 576}
 577
 578func pearOutline(t float64) (float64, float64) {
 579	a := 2 * math.Pi * t
 580	c := math.Cos(a)
 581	return c*0.92 + 0.08, 0.7 * math.Sin(a) * (1 - 0.46*c)
 582}
 583
 584func marquiseOutline(t float64) (float64, float64) {
 585	a := 2 * math.Pi * t
 586	return math.Cos(a), 0.52 * sgnPow(math.Sin(a), 1.35)
 587}
 588
 589// heartOutline is a heart CUT, not a valentine: full rounded lobes, a
 590// shallow notch (~14% of the length; the valentine curve's 24% read as a
 591// drawing), gently convex sides and a clean point, as a heart-cut
 592// diamond is. Notch at t=0 (far side, +y), point at t=.5 (near side),
 593// normalised to [-1,1]; the cut's rx/ry set its width-to-length ratio
 594// (real heart cuts run 0.9-1.1).
 595func heartOutline(t float64) (float64, float64) {
 596	a := 2 * math.Pi * t
 597	sn := math.Sin(a)
 598	y := (13*math.Cos(a) - 4*math.Cos(2*a) - 1.4*math.Cos(3*a) - 0.5*math.Cos(4*a)) / 17
 599	return 0.88*sn*sn*sn + 0.12*sn, (y + 0.1576) / 0.7894
 600}
 601
 602func trillionOutline(t float64) (float64, float64) {
 603	vs := []pt{{0, 1}, {-0.866, -0.5}, {0.866, -0.5}}
 604	f := t * 3
 605	k := int(math.Floor(f))
 606	fr := f - float64(k)
 607	a, b := vs[((k%3)+3)%3], vs[(((k+1)%3)+3)%3]
 608	bulge := 1 + 0.16*math.Sin(math.Pi*fr)
 609	return (a.x + (b.x-a.x)*fr) * bulge, (a.y + (b.y-a.y)*fr) * bulge
 610}
 611
 612/* ---- the cuts ---- */
 613
 614// cutSpec is one cut: its outline, how many table vertices it has, and
 615// whether it is a brilliant (triangular/kite facets radiating from the
 616// table, girdle at twice the table's vertex count) or a step cut
 617// (concentric rows of long rectangular facets).
 618type cutSpec struct {
 619	n         int
 620	brilliant bool
 621	outline   outlineFn
 622	rx, ry    float64
 623}
 624
 625// ShapeNames is the faceted-cut vocabulary, in index order. Heart, Rose
 626// Cut and Raw Crystal were dropped from the first 3D pass and are back
 627// (appended, so the first ten keep their indexes), each only on the
 628// stones that are really sold that way -- see CutsForStone. Raw Crystal
 629// is the stone's own natural crystal, not a cut: an octahedron for
 630// diamond, a hexagonal barrel for sapphire, a prism with a pyramid tip
 631// and a flat cleavage break for topaz (buildRaw).
 632var ShapeNames = []string{
 633	"Round Brilliant", "Oval", "Cushion", "Emerald Cut", "Asscher",
 634	"Radiant", "Princess Cut", "Pear", "Marquise", "Trillion",
 635	"Heart", "Rose Cut", "Raw Crystal",
 636}
 637
 638const (
 639	shapeHeart = 10
 640	shapeRose  = 11
 641	shapeRaw   = 12
 642)
 643
 644// stoneScale enlarges every stone and pearl inside the ring opening:
 645// 1.22 (the first gemsart3d2 pass) crowded the ring; 0.98 was 4/5 of
 646// that, and 0.78 is 4/5 again (2026-09-26, per request). Then 1.02 with
 647// the ring drawn 10% larger, and finally 1.21 with the ring 16% larger
 648// (size "D+" of the size trials, chosen 2026-09-26; see ringScale).
 649const stoneScale = 1.21
 650
 651func cutFor(shapeIdx int) cutSpec {
 652	c := cutForUnscaled(shapeIdx)
 653	c.rx, c.ry = c.rx*stoneScale, c.ry*stoneScale
 654	return c
 655}
 656
 657func cutForUnscaled(shapeIdx int) cutSpec {
 658	switch shapeIdx {
 659	case 1:
 660		return cutSpec{8, true, circleOutline, 30, 21.5}
 661	case 2:
 662		return cutSpec{8, true, superOutline(3.4), 27, 24}
 663	case 3:
 664		// Emerald Cut: 10% smaller than the other cuts' scale (was 30 x 21);
 665		// its long step-cut outline read larger than the rest at 1.21
 666		return cutSpec{8, false, polyOutline(octagon(1, 1, 0.3)), 27, 18.9}
 667	case 4:
 668		return cutSpec{8, false, polyOutline(octagon(1, 1, 0.36)), 25, 25}
 669	case 5:
 670		return cutSpec{8, true, polyOutline(octagon(1, 1, 0.3)), 29, 22.5}
 671	case 6:
 672		return cutSpec{8, true, polyOutline([]pt{{1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}, {1, 0}}), 23.5, 23.5}
 673	case 7:
 674		return cutSpec{8, true, pearOutline, 31, 27}
 675	case 8:
 676		return cutSpec{8, true, marquiseOutline, 33, 30}
 677	case 9:
 678		return cutSpec{9, true, trillionOutline, 27, 27}
 679	case shapeHeart:
 680		return cutSpec{10, true, heartOutline, 29, 28}
 681	case shapeRose:
 682		return cutSpec{6, true, circleOutline, 27, 27}
 683	case shapeRaw:
 684		return cutSpec{0, false, circleOutline, 27, 27} // sized per stone in buildRaw
 685	}
 686	return cutSpec{8, true, circleOutline, 27, 27}
 687}
 688
 689// CutsForStone is which cuts a stone is really sold in -- the cuts the
 690// trade actually produces for it in quantity, not every cut that has
 691// ever been tried. A mint picks shapeIdx from this slice.
 692//   Diamond: every cut here; brilliance cuts (Radiant, Princess, Asscher)
 693//     were invented for it, Rose Cut is its oldest faceted form, and raw
 694//     octahedra are set in jewellery as they come from the mine.
 695//   Sapphire: rough favours ovals and cushions (weight retention), then
 696//     rounds, emerald cuts, pears, hearts, marquises, trillions (side
 697//     stones), princess (calibrated squares), rose cuts, raw barrels.
 698//     Asscher and Radiant are diamond-optimised and rare in sapphire.
 699//   Topaz: crystals are long prisms, so emerald cuts, ovals, pears,
 700//     cushions, marquises, then rounds, trillions, hearts, princess, and
 701//     raw crystals (topaz is one of the classic collector crystals).
 702//   Quartz (every chain without its own stone): cut in every style (it
 703//     is the lapidary's everyday stone), rose-cut amethyst is a classic,
 704//     and the hexagonal quartz point is THE raw crystal.
 705func CutsForStone(stone string) []int {
 706	switch stone {
 707	case "Diamond":
 708		return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}
 709	case "Sapphire":
 710		return []int{1, 2, 0, 3, 7, shapeHeart, 8, 9, 6, shapeRose, shapeRaw}
 711	case "Topaz":
 712		return []int{3, 1, 7, 2, 8, 0, 9, shapeHeart, 6, shapeRaw}
 713	}
 714	return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}
 715}
 716
 717const (
 718	kCrown = iota
 719	kTable
 720	kPav
 721)
 722
 723type face struct {
 724	p    []v3
 725	kind int
 726}
 727
 728func ringPts(c cutSpec, n int, scale, z, off float64) []v3 {
 729	out := make([]v3, n)
 730	for k := 0; k < n; k++ {
 731		x, y := c.outline((float64(k) + off) / float64(n))
 732		out[k] = v3{x * c.rx * scale, y * c.ry * scale, z}
 733	}
 734	return out
 735}
 736
 737// buildCut returns the solid's faces plus its girdle ring (drawn as one
 738// stroke, not as faces). Proportions follow a real stone: table ~56% of
 739// the width, crown ~1/3 as tall as the pavilion is deep.
 740func buildCut(c cutSpec) (faces []face, girdle []v3) {
 741	base := c.rx
 742	if c.ry < base {
 743		base = c.ry
 744	}
 745	hc, hp := 0.34*base, 0.9*base
 746	n := c.n
 747	if c.brilliant {
 748		T := ringPts(c, n, 0.56, hc, 0)
 749		B := ringPts(c, n, 0.8, hc*0.46, 0.5)
 750		G := ringPts(c, 2*n, 1, 0, 0)
 751		culet := v3{0, 0, -hp}
 752		faces = append(faces, face{T, kTable})
 753		for k := 0; k < n; k++ {
 754			k1 := (k + 1) % n
 755			// star, then the kite as one quad down to the girdle
 756			faces = append(faces, face{[]v3{T[k], B[k], T[k1]}, kCrown})
 757			faces = append(faces, face{[]v3{T[k1], B[k], G[(2*k+2)%(2*n)], B[k1]}, kCrown})
 758			// the two upper-girdle facets under this break vertex
 759			faces = append(faces, face{[]v3{B[k], G[2*k], G[2*k+1]}, kCrown})
 760			faces = append(faces, face{[]v3{B[k], G[2*k+1], G[(2*k+2)%(2*n)]}, kCrown})
 761			// pavilion main, spanning two girdle segments
 762			faces = append(faces, face{[]v3{G[2*k], G[2*k+1], G[(2*k+2)%(2*n)], culet}, kPav})
 763		}
 764		return faces, G
 765	}
 766	T := ringPts(c, n, 0.54, hc, 0)
 767	S := ringPts(c, n, 0.78, hc*0.5, 0)
 768	G := ringPts(c, n, 1, 0, 0)
 769	P1 := ringPts(c, n, 0.7, -hp*0.42, 0)
 770	P2 := ringPts(c, n, 0.38, -hp*0.78, 0)
 771	K := ringPts(c, n, 0.1, -hp, 0)
 772	faces = append(faces, face{T, kTable})
 773	rows := [][2][]v3{{T, S}, {S, G}}
 774	for _, r := range rows {
 775		for k := 0; k < n; k++ {
 776			k1 := (k + 1) % n
 777			faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kCrown})
 778		}
 779	}
 780	prow := [][2][]v3{{G, P1}, {P1, P2}, {P2, K}}
 781	for _, r := range prow {
 782		for k := 0; k < n; k++ {
 783			k1 := (k + 1) % n
 784			faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kPav})
 785		}
 786	}
 787	return faces, G
 788}
 789
 790// buildRose is a rose cut: a flat base and a dome of triangular facets
 791// rising to a point -- six at the top, twelve around the edge. No table,
 792// no pavilion; the flat back is the one pavilion face.
 793func buildRose(c cutSpec) (faces []face, girdle []v3) {
 794	n := c.n
 795	base := math.Min(c.rx, c.ry)
 796	h := 0.62 * base
 797	apex := v3{0, 0, h}
 798	R := ringPts(c, n, 0.55, h*0.62, 0.5)
 799	G := ringPts(c, 2*n, 1, 0, 0)
 800	for k := 0; k < n; k++ {
 801		k1 := (k + 1) % n
 802		faces = append(faces, face{[]v3{apex, R[k], R[k1]}, kCrown})
 803		faces = append(faces, face{[]v3{G[2*k], G[2*k+1], R[k]}, kCrown})
 804		faces = append(faces, face{[]v3{G[2*k+1], G[(2*k+2)%(2*n)], R[k]}, kCrown})
 805		faces = append(faces, face{[]v3{R[k], G[(2*k+2)%(2*n)], R[k1]}, kCrown})
 806	}
 807	back := make([]v3, len(G))
 808	for i := range G {
 809		back[len(G)-1-i] = G[i]
 810	}
 811	faces = append(faces, face{back, kPav})
 812	return faces, G
 813}
 814
 815// ringX is a ring of n points around the x axis at position x.
 816func ringX(n int, x, ry, rz, phase float64) []v3 {
 817	out := make([]v3, n)
 818	for k := 0; k < n; k++ {
 819		a := 2*math.Pi*(float64(k)+phase)/float64(n) + math.Pi/2
 820		out[k] = v3{x, ry * math.Cos(a), rz * math.Sin(a)}
 821	}
 822	return out
 823}
 824
 825// bandX joins two rings of equal size with quads.
 826func bandX(a, b []v3) []face {
 827	var fs []face
 828	for k := range a {
 829		k1 := (k + 1) % len(a)
 830		fs = append(fs, face{[]v3{a[k], a[k1], b[k1], b[k]}, kCrown})
 831	}
 832	return fs
 833}
 834
 835// capX closes a ring with one flat face (a pinacoid or a cleavage break).
 836func capX(r []v3) face { return face{r, kCrown} }
 837
 838// turnFaces rotates every vertex about x by ax, then about z by az
 839// (radians): a rough crystal is set at an angle that shows several of its
 840// faces, not square-on to the viewer.
 841func turnFaces(fs []face, ax, az float64) []face {
 842	cx, sx, cz, sz := math.Cos(ax), math.Sin(ax), math.Cos(az), math.Sin(az)
 843	for i := range fs {
 844		// A rough crystal is translucent: lit from inside like a
 845		// pavilion, and its far faces show through the near ones
 846		// (renderGem3D draws back-facing kPav faces).
 847		fs[i].kind = kPav
 848		for j, p := range fs[i].p {
 849			y, z := p.y*cx-p.z*sx, p.y*sx+p.z*cx
 850			fs[i].p[j] = v3{p.x*cz - y*sz, p.x*sz + y*cz, z}
 851		}
 852	}
 853	return fs
 854}
 855
 856// buildRaw is the stone's natural crystal, lying on its side as it would
 857// in a raw-crystal setting. Rough crystals have no table and no girdle,
 858// and none is perfectly regular: each habit is stretched a little on one
 859// axis.
 860func buildRaw(stone string) (faces []face, girdle []v3) {
 861	deg := math.Pi / 180
 862	switch stone {
 863	case "Diamond":
 864		// the octahedron, diamond's commonest habit, tipped so four or five
 865		// of its eight faces show
 866		a, b, h := 25.0*stoneScale, 22.0*stoneScale, 23.0*stoneScale
 867		v := []v3{{a, 0, 0}, {0, b, 0}, {-a, 0, 0}, {0, -b, 0}}
 868		top, bot := v3{0, 0, h}, v3{0, 0, -h}
 869		for k := 0; k < 4; k++ {
 870			k1 := (k + 1) % 4
 871			faces = append(faces, face{[]v3{top, v[k], v[k1]}, kCrown})
 872			faces = append(faces, face{[]v3{bot, v[k1], v[k]}, kCrown})
 873		}
 874		return turnFaces(faces, -38*deg, 28*deg), nil
 875	case "Sapphire":
 876		// corundum's barrel: a hexagonal prism swelling in the middle,
 877		// tapering to flat hexagonal ends
 878		L, R := 26.0*stoneScale, 16.0*stoneScale
 879		r0 := ringX(6, -L, R*0.42, R*0.42, 0)
 880		r1 := ringX(6, -L*0.3, R, R*0.94, 0)
 881		r2 := ringX(6, L*0.3, R, R*0.94, 0)
 882		r3 := ringX(6, L, R*0.42, R*0.42, 0)
 883		faces = append(faces, bandX(r0, r1)...)
 884		faces = append(faces, bandX(r1, r2)...)
 885		faces = append(faces, bandX(r2, r3)...)
 886		faces = append(faces, capX(r3))
 887		rev := make([]v3, 6)
 888		for i := range r0 {
 889			rev[5-i] = r0[i]
 890		}
 891		faces = append(faces, capX(rev))
 892		return turnFaces(faces, -30*deg, -14*deg), nil
 893	}
 894	if stone == "Quartz" {
 895		// the quartz point: a six-sided prism ending in a six-faced
 896		// pyramid, the far end broken off where it grew from the matrix
 897		L, R := 27.0*stoneScale, 12.5*stoneScale
 898		r0 := ringX(6, -L, R*1.04, R, 0)
 899		r1 := ringX(6, L*0.4, R, R*0.96, 0)
 900		point := v3{L, 0, 0}
 901		faces = append(faces, bandX(r0, r1)...)
 902		for k := 0; k < 6; k++ {
 903			faces = append(faces, face{[]v3{r1[k], r1[(k+1)%6], point}, kCrown})
 904		}
 905		rev := make([]v3, 6)
 906		for i := range r0 {
 907			rev[5-i] = r0[i]
 908		}
 909		faces = append(faces, capX(rev))
 910		return turnFaces(faces, -32*deg, 16*deg), nil
 911	}
 912	// topaz: an eight-sided prism (rhombic prism with bevelled edges),
 913	// a four-faced pyramid at one end, and the flat break of topaz's
 914	// perfect basal cleavage at the other
 915	L, ry, rz := 26.0*stoneScale, 16.0*stoneScale, 13.0*stoneScale
 916	r0 := ringX(8, -L, ry, rz, 0.5)
 917	r1 := ringX(8, L*0.5, ry, rz, 0.5)
 918	tip := ringX(8, L*0.82, ry*0.45, rz*0.45, 0.5)
 919	point := v3{L, 0, 0}
 920	faces = append(faces, bandX(r0, r1)...)
 921	faces = append(faces, bandX(r1, tip)...)
 922	for k := 0; k < 8; k++ {
 923		faces = append(faces, face{[]v3{tip[k], tip[(k+1)%8], point}, kCrown})
 924	}
 925	rev := make([]v3, 8)
 926	for i := range r0 {
 927		rev[7-i] = r0[i]
 928	}
 929	faces = append(faces, capX(rev))
 930	return turnFaces(faces, -30*deg, 12*deg), nil
 931}
 932
 933/* ---- view, light, paint ---- */
 934
 935// gemTilt is degrees from a straight-down view: 61 is enough to see under
 936// the girdle (the saved "oblique" view; the "profile" review set is the
 937// same code rendered with 76).
 938const gemTilt = 61.0
 939
 940const (
 941	// Optically centred: the pavilion (the lower half) is dark and melts
 942	// into the ground, so a stone centred by geometry reads high; measured
 943	// on still renders, its visible part sat ~1.5 units above the ring
 944	// centre. Pearls are evenly lit and use subjY as is. (Was subjY-1.)
 945	gemCY   = subjY + 1.5
 946)
 947
 948// n1 formats to one decimal as compactly as SVG allows: no trailing
 949// ".0", no leading "0".
 950func n1(v float64) string {
 951	neg := v < 0
 952	if neg {
 953		v = -v
 954	}
 955	t := int64(v*10 + 0.5)
 956	w, f := t/10, t%10
 957	s := ""
 958	if f == 0 {
 959		s = itoa(w)
 960	} else if w == 0 {
 961		s = "." + string(byte('0'+f))
 962	} else {
 963		s = itoa(w) + "." + string(byte('0'+f))
 964	}
 965	if neg && t != 0 {
 966		return "-" + s
 967	}
 968	return s
 969}
 970
 971// relPath writes a closed polygon with relative coordinates; a negative
 972// number needs no separator before it, which is where the bytes go.
 973func relPath(ps []pt) string {
 974	s := "M" + n1(ps[0].x) + " " + n1(ps[0].y) + "l"
 975	px, py := math.Round(ps[0].x*10)/10, math.Round(ps[0].y*10)/10
 976	for i := 1; i < len(ps); i++ {
 977		qx, qy := math.Round(ps[i].x*10)/10, math.Round(ps[i].y*10)/10
 978		dx, dy := n1(qx-px), n1(qy-py)
 979		if i > 1 && dx[0] != '-' {
 980			s += " "
 981		}
 982		s += dx
 983		if dy[0] != '-' {
 984			s += " "
 985		}
 986		s += dy
 987		px, py = qx, qy
 988	}
 989	return s + "z"
 990}
 991
 992// hex3 quantises #rrggbb to #rgb.
 993func hex3(h string) string {
 994	r, g, b := hexToRGB(h)
 995	d := "0123456789abcdef"
 996	q := func(v int) byte { return d[(v+8)*15/255%16] }
 997	return "#" + string([]byte{q(r), q(g), q(b)})
 998}
 999
1000func convexHull(ps []pt) []pt {
1001	n := len(ps)
1002	idx := make([]int, n)
1003	for i := range idx {
1004		idx[i] = i
1005	}
1006	// bottom-up merge sort by (x, y): the body hull takes every projected
1007	// vertex of the stone (hundreds), where an insertion sort's n^2
1008	// comparisons were a real share of a render's gas
1009	less := func(a, b int) bool {
1010		return ps[a].x < ps[b].x || (ps[a].x == ps[b].x && ps[a].y < ps[b].y)
1011	}
1012	tmp := make([]int, n)
1013	for w := 1; w < n; w *= 2 {
1014		for lo := 0; lo < n; lo += 2 * w {
1015			mid, hi := lo+w, lo+2*w
1016			if mid > n {
1017				mid = n
1018			}
1019			if hi > n {
1020				hi = n
1021			}
1022			i, j, k := lo, mid, lo
1023			for i < mid && j < hi {
1024				if less(idx[j], idx[i]) {
1025					tmp[k] = idx[j]
1026					j++
1027				} else {
1028					tmp[k] = idx[i]
1029					i++
1030				}
1031				k++
1032			}
1033			for i < mid {
1034				tmp[k] = idx[i]
1035				i++
1036				k++
1037			}
1038			for j < hi {
1039				tmp[k] = idx[j]
1040				j++
1041				k++
1042			}
1043		}
1044		idx, tmp = tmp, idx
1045	}
1046	cr := func(o, a, b pt) float64 { return (a.x-o.x)*(b.y-o.y) - (a.y-o.y)*(b.x-o.x) }
1047	var h []pt
1048	for pass := 0; pass < 2; pass++ {
1049		start := len(h)
1050		for i := 0; i < n; i++ {
1051			p := ps[idx[i]]
1052			if pass == 1 {
1053				p = ps[idx[n-1-i]]
1054			}
1055			for len(h) >= start+2 && cr(h[len(h)-2], h[len(h)-1], p) <= 0 {
1056				h = h[:len(h)-1]
1057			}
1058			h = append(h, p)
1059		}
1060		h = h[:len(h)-1]
1061	}
1062	return h
1063}
1064
1065/* ---- polygon helpers for the realism passes ---- */
1066
1067// polyArea is twice the signed area; sign tells winding.
1068func polyArea(ps []pt) float64 {
1069	a := 0.0
1070	for i := range ps {
1071		j := (i + 1) % len(ps)
1072		a += ps[i].x*ps[j].y - ps[j].x*ps[i].y
1073	}
1074	if a < 0 {
1075		return -a / 2
1076	}
1077	return a / 2
1078}
1079
1080// clipHalf is one Sutherland-Hodgman pass: keeps the part of ps on the
1081// inner side of the line through (ox,oy) with outward normal (nx,ny).
1082func clipHalf(ps []pt, ox, oy, nx, ny float64) []pt {
1083	if len(ps) == 0 {
1084		return ps
1085	}
1086	d := func(q pt) float64 { return (q.x-ox)*nx + (q.y-oy)*ny }
1087	var out []pt
1088	for i := range ps {
1089		a, b := ps[i], ps[(i+1)%len(ps)]
1090		da, db := d(a), d(b)
1091		if da <= 0 {
1092			out = append(out, a)
1093		}
1094		if (da < 0 && db > 0) || (da > 0 && db < 0) {
1095			t := da / (da - db)
1096			out = append(out, pt{a.x + (b.x-a.x)*t, a.y + (b.y-a.y)*t})
1097		}
1098	}
1099	return out
1100}
1101
1102// clipToConvex keeps the part of ps inside the convex window win.
1103func clipToConvex(ps, win []pt) []pt {
1104	// win from convexHull is counter-clockwise in screen coords; the
1105	// outward normal of edge (a,b) is (dy,-dx) rotated to point away.
1106	for i := range win {
1107		a, b := win[i], win[(i+1)%len(win)]
1108		nx, ny := b.y-a.y, a.x-b.x
1109		mid := pt{(a.x + b.x) / 2, (a.y + b.y) / 2}
1110		// make the normal point outward: away from the window centre
1111		cx, cy := centroid(win)
1112		if (mid.x-cx)*nx+(mid.y-cy)*ny < 0 {
1113			nx, ny = -nx, -ny
1114		}
1115		ps = clipHalf(ps, a.x, a.y, nx, ny)
1116		if len(ps) < 3 {
1117			return nil
1118		}
1119	}
1120	return ps
1121}
1122
1123// splitPoly cuts ps in two along the line through its centroid at ang.
1124func splitPoly(ps []pt, ang float64) ([]pt, []pt) {
1125	cx, cy := centroid(ps)
1126	nx, ny := math.Cos(ang), math.Sin(ang)
1127	return clipHalf(ps, cx, cy, nx, ny), clipHalf(ps, cx, cy, -nx, -ny)
1128}
1129
1130// renderGem3D builds the stone. Returns what goes under the facets
1131// (defs, body glow, the pavilion's back faces), the front facets as bare
1132// self-closing paths (applyCascade scans these), and what goes over
1133// (girdle stroke, rim).
1134func renderGem3D(shapeIdx int, base, salt string, fs *facetStyle) (under, facets, over string) {
1135	c := cutFor(shapeIdx)
1136	var faces []face
1137	var girdle []v3
1138	switch shapeIdx {
1139	case shapeRose:
1140		faces, girdle = buildRose(c)
1141	case shapeRaw:
1142		faces, girdle = buildRaw(fs.stone)
1143	default:
1144		faces, girdle = buildCut(c)
1145	}
1146	raw := shapeIdx == shapeRaw
1147
1148	sh := fs.prof.shadow
1149	if fs.prof.neutral != "" {
1150		base = fs.prof.neutral
1151	}
1152	base = mixHex(base, sh, fs.prof.bodyMix)
1153	deep := darken(base, sh, 0.9)
1154	// Highlights stay in the stone's hue; only the specular goes to
1155	// white. Mixing facets most of the way to white is what read pastel.
1156	bright := lighten(base, 0.5)
1157	fs.flash = flashRamp(28, fs.prof.sat)
1158	deepI, baseI, brightI := toRGBI(deep), toRGBI(base), toRGBI(bright)
1159	toneI := func(lum float64) rgbI {
1160		if lum < 0 {
1161			lum = 0
1162		}
1163		if lum > 1 {
1164			lum = 1
1165		}
1166		if lum < 0.5 {
1167			return mixI(deepI, baseI, lum*2)
1168		}
1169		return mixI(baseI, brightI, (lum-0.5)*2)
1170	}
1171	tone := func(lum float64) string { return toneI(lum).hex() }
1172
1173	// view: a small per-token yaw, then the tilt toward the viewer
1174	yaw := fs.zone.floatIn(-13, 13) * math.Pi / 180
1175	th := gemTilt * math.Pi / 180
1176	cy, sy := math.Cos(yaw), math.Sin(yaw)
1177	ct, stt := math.Cos(th), math.Sin(th)
1178	view := func(p v3) v3 {
1179		x1, y1 := p.x*cy-p.y*sy, p.x*sy+p.y*cy
1180		return v3{x1, y1*ct + p.z*stt, p.z*ct - y1*stt}
1181	}
1182	// Centre every stone on its own drawn extent, not on its geometric
1183	// origin: a rose cut is all dome and no pavilion, so centred on its
1184	// girdle it sat high in the ring; raw crystals and pears are lopsided
1185	// the same way. This shifts the drawing only; the rock still pivots
1186	// on the ring centre.
1187	ex0, ey0, ex1, ey1 := 1e9, 1e9, -1e9, -1e9
1188	for _, f := range faces {
1189		for _, p := range f.p {
1190			q := view(p)
1191			ex0, ey0 = math.Min(ex0, q.x), math.Min(ey0, q.y)
1192			ex1, ey1 = math.Max(ex1, q.x), math.Max(ey1, q.y)
1193		}
1194	}
1195	ox, oy := -(ex0+ex1)/2, -(ey0+ey1)/2
1196	scr := func(p v3) pt { return pt{subjX + p.x + ox, gemCY - (p.y + oy)} }
1197	light := vnorm(v3{-0.5, 0.62, 0.6})
1198	inside := v3{0, 0, -0.25 * c.ry}
1199	if raw {
1200		inside = v3{}
1201	} else if shapeIdx == shapeRose {
1202		inside = v3{0, 0, 0.15 * c.ry}
1203	}
1204
1205	type drawn struct {
1206		pts   []pt
1207		depth float64
1208		fill  string
1209		kind  int
1210		lum   float64
1211		// what shadeFront needs to relight this facet for the rock
1212		nv                v3
1213		jit, drift, thick float64
1214		ext               bool
1215	}
1216	// shadeFront is the lighting of a facet seen from outside, for a
1217	// given light direction. Kept as one function so the rock states
1218	// (see lightStates below) are lit by exactly the same model.
1219	shadeFront := func(kind int, nv, L v3, jit, drift, thick float64, ext bool) (string, float64) {
1220		H := vnorm(v3{L.x, L.y, L.z + 1})
1221		// A real stone is a contrast pattern, not an evenly lit solid:
1222		// lambert with a gamma so mid facets stay deep and saturated,
1223		// plus the odd "extinction" facet that returns no light at all.
1224		lam := math.Max(0, vdot(nv, L))
1225		lum := 0.03 + 0.7*math.Pow(lam, 2.1) + jit
1226		if ext {
1227			lum *= 0.3
1228		}
1229		if kind == kTable {
1230			lum = 0.36 + 0.26*lam
1231		}
1232		if kind == kPav {
1233			// A pavilion facet seen from outside faces away from the
1234			// lamp, but it is where light that entered through the
1235			// crown LEAVES the stone: lit from its inner side.
1236			lum = 0.22 + 0.5*math.Max(0, vdot(vscale(nv, -1), L)) + jit
1237		}
1238		if raw {
1239			// A natural crystal face is not polished, but topaz, quartz,
1240			// sapphire and diamond rough all have a glassy (vitreous to
1241			// adamantine) lustre: faces toward the lamp shine from
1242			// outside as well as glowing from inside. Lit only from
1243			// inside, the crystals read as dull plastic.
1244			lum = 0.3 + 0.42*math.Pow(lam, 1.4) + 0.22*math.Max(0, vdot(vscale(nv, -1), L)) + jit*0.6
1245		}
1246		col := mixI(toneI(lum), deepI, 0.34*thick*thick)
1247		if sp := math.Pow(math.Max(0, vdot(nv, H)), 28); sp > 0.3 {
1248			col = mixI(col, whiteI, sp*0.85)
1249		}
1250		// Dispersion on the bright facets: white light split by the stone
1251		// leaves each facet in a slightly different spectral colour, set by
1252		// which way the facet faces (its azimuth), so neighbours differ
1253		// the way real fire does instead of every bright facet being one
1254		// lighter shade of the body. Scaled by the stone's own dispersion
1255		// (prof.sat: diamond most, topaz least) and only above mid
1256		// brightness; dark facets keep the body colour.
1257		if lum > 0.5 && kind != kTable {
1258			amt := math.Min(1, (lum-0.5)/0.4) * dispersionMix * fs.prof.sat
1259			t := math.Atan2(nv.y, nv.x)/math.Pi + drift/40
1260			col = mixI(col, spectrumAtI(t), amt)
1261		}
1262		return hex3I(hueRotateI(col, drift)), lum
1263	}
1264	var back, front, pav []drawn
1265	var all []pt
1266	for _, f := range faces {
1267		vp := make([]v3, len(f.p))
1268		ctr := v3{}
1269		for i, p := range f.p {
1270			vp[i] = view(p)
1271			ctr = v3{ctr.x + p.x, ctr.y + p.y, ctr.z + p.z}
1272		}
1273		ctr = vscale(ctr, 1/float64(len(f.p)))
1274		// Newell's normal, not a cross product of the first three points:
1275		// Princess Cut's table has three collinear corners there, which
1276		// gave a zero normal and dropped its table face entirely.
1277		nm := v3{}
1278		for i := range f.p {
1279			a, b := f.p[i], f.p[(i+1)%len(f.p)]
1280			nm = v3{nm.x + (a.y-b.y)*(a.z+b.z), nm.y + (a.z-b.z)*(a.x+b.x), nm.z + (a.x-b.x)*(a.y+b.y)}
1281		}
1282		nm = vnorm(nm)
1283		if vdot(nm, vsub(ctr, inside)) < 0 {
1284			nm = vscale(nm, -1)
1285		}
1286		nv := vnorm(view(nm))
1287		d := drawn{kind: f.kind}
1288		for _, p := range vp {
1289			d.pts = append(d.pts, scr(p))
1290			d.depth += p.z
1291			all = append(all, scr(p))
1292		}
1293		d.depth /= float64(len(vp))
1294		// How much stone the light crosses under this facet: the solid is
1295		// thickest over the axis and thins to nothing at the girdle, so a
1296		// facet near the centre shows more of the body colour (Beer-Lambert,
1297		// the reason a deep stone reads darker and more saturated than a
1298		// shallow one). Costs no extra markup -- it only shifts the fill.
1299		rN := math.Sqrt((ctr.x/c.rx)*(ctr.x/c.rx) + (ctr.y/c.ry)*(ctr.y/c.ry))
1300		if rN > 1 {
1301			rN = 1
1302		}
1303		thick := 1 - rN
1304		jit := fs.zone.floatIn(-0.13, 0.13)
1305		if nv.z > 0.02 {
1306			ext := f.kind == kCrown && fs.zone.floatIn(0, 1) < 0.28
1307			drift := fs.zone.floatIn(-fireZoneDeg, fireZoneDeg)
1308			d.nv, d.jit, d.drift, d.thick, d.ext = nv, jit, drift, thick, ext
1309			d.fill, d.lum = shadeFront(f.kind, nv, light, jit, drift, thick, ext)
1310			front = append(front, d)
1311			if f.kind == kPav {
1312				pav = append(pav, d)
1313			}
1314		} else if f.kind == kPav {
1315			// seen from inside through the crown: lit on its inner side
1316			inner := vscale(nv, -1)
1317			lum := 0.2 + 0.6*math.Max(0, vdot(inner, light)) + jit
1318			d.fill = hex3(mixHex(tone(lum), deep, 0.34*thick*thick))
1319			d.lum = lum
1320			back = append(back, d)
1321			pav = append(pav, d)
1322		}
1323	}
1324	sortDrawn := func(ds []drawn) {
1325		for i := 1; i < len(ds); i++ {
1326			for j := i; j > 0 && ds[j].depth < ds[j-1].depth; j-- {
1327				ds[j], ds[j-1] = ds[j-1], ds[j]
1328			}
1329		}
1330	}
1331	sortDrawn(back)
1332	sortDrawn(front)
1333
1334	hull := convexHull(all)
1335	if shapeIdx == shapeHeart {
1336		// the notch: a hull would fill it; the girdle ring is the outline
1337		hull = nil
1338		for _, g := range girdle {
1339			hull = append(hull, scr(view(g)))
1340		}
1341	}
1342	// strings.Builder, not +=: appending to a growing string re-copies it
1343	// every time, and on-chain that copying was the bulk of a render's gas
1344	var underB, facetsB strings.Builder
1345	underB.WriteString(`<defs><radialGradient id="bd` + salt + `" cx=".4" cy=".35" r=".75">` +
1346		`<stop offset="0" stop-color="` + hex3(base) + `"/><stop offset=".5" stop-color="` + hex3(darken(base, sh, 0.25)) + `"/><stop offset="1" stop-color="` + hex3(darken(base, sh, 0.85)) + `"/></radialGradient></defs>` +
1347		`<path d="` + relPath(hull) + `" fill="url(#bd` + salt + `)" opacity=".7"/><g fill-opacity=".6">`)
1348	for _, d := range back {
1349		underB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)
1350	}
1351	underB.WriteString(`</g>`)
1352
1353	nCrown := 0
1354	for _, d := range front {
1355		if d.kind == kCrown {
1356			nCrown++
1357		}
1358	}
1359	candP := 1.0
1360	if nCrown > fireMaxFacets {
1361		candP = float64(fireMaxFacets) / float64(nCrown) * 1.25
1362	}
1363	nCand := 0
1364	nSplit := 0
1365	if raw || fs.thumb {
1366		// rough crystal: no fire flashes, no split facets -- a natural
1367		// face is one frosted plane. A thumbnail: no flashes either.
1368		candP = 0
1369	}
1370	for _, d := range front {
1371		if d.kind == kTable && shapeIdx == shapeHeart {
1372			// a non-convex window: skip the reflected mosaic, which is
1373			// clipped to a convex outline and would spill into the notch
1374			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)
1375			continue
1376		}
1377		if d.kind == kTable {
1378			// The window. Through it you do not see the pavilion's own
1379			// faces straight on -- you see them mirrored back up at you off
1380			// the pavilion walls, small and inverted. That mosaic is what a
1381			// real table shows, so it is drawn here: each pavilion face mapped
1382			// into the table, flipped, and cut to the table's outline (a
1383			// Sutherland-Hodgman clip, no <clipPath>).
1384			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)
1385			win := convexHull(d.pts)
1386			tx, ty := centroid(win)
1387			if len(pav) > 0 && !fs.thumb {
1388				bx0, by0, bx1, by1 := 1e9, 1e9, -1e9, -1e9
1389				for _, b := range pav {
1390					for _, q := range b.pts {
1391						bx0, by0 = math.Min(bx0, q.x), math.Min(by0, q.y)
1392						bx1, by1 = math.Max(bx1, q.x), math.Max(by1, q.y)
1393					}
1394				}
1395				wx0, wy0, wx1, wy1 := 1e9, 1e9, -1e9, -1e9
1396				for _, q := range win {
1397					wx0, wy0 = math.Min(wx0, q.x), math.Min(wy0, q.y)
1398					wx1, wy1 = math.Max(wx1, q.x), math.Max(wy1, q.y)
1399				}
1400				k := 0.92 * math.Min((wx1-wx0)/math.Max(1, bx1-bx0), (wy1-wy0)/math.Max(1, by1-by0))
1401				bx, by := (bx0+bx1)/2, (by0+by1)/2
1402				var mosB strings.Builder
1403				nRefl := 0
1404				for _, b := range pav {
1405					if nRefl >= 8 {
1406						break
1407					}
1408					mp := make([]pt, len(b.pts))
1409					for i, q := range b.pts {
1410						mp[i] = pt{tx + (q.x-bx)*k, ty - (q.y-by)*k}
1411					}
1412					cl := clipToConvex(mp, win)
1413					if len(cl) < 3 || polyArea(cl) < 4 {
1414						continue
1415					}
1416					nRefl++
1417					mosB.WriteString(`<path d="` + relPath(cl) + `" fill="` + hex3(tone(b.lum*0.95)) + `"/>`)
1418				}
1419				if mosB.Len() > 0 {
1420					facetsB.WriteString(`<g fill-opacity=".55">` + mosB.String() + `</g>`)
1421				}
1422			}
1423			continue
1424		}
1425		// Only some crown facets can flash (fireMaxFacets): every one that
1426		// can costs an overlay path, so the set is bounded per stone.
1427		if d.kind == kCrown && nCand < fireMaxFacets && fs.zone.floatIn(0, 1) < candP {
1428			nCand++
1429			cx, cyy := centroid(d.pts)
1430			fs.ringCent = append(fs.ringCent, pt{cx, cyy})
1431			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" class="f"/>`)
1432			continue
1433		}
1434		// Two-tone facets: a real facet rarely reflects one thing. Part of
1435		// it catches the lamp, the rest catches the dark room, and the
1436		// boundary is a straight line across the facet. Split a bounded
1437		// number of the larger ones (each split costs one extra path).
1438		if !raw && nSplit < 6 && polyArea(d.pts) > 26 && fs.zone.floatIn(0, 1) < 0.5 {
1439			a, b := splitPoly(d.pts, fs.zone.floatIn(0, math.Pi))
1440			if len(a) >= 3 && len(b) >= 3 {
1441				nSplit++
1442				lo := hex3(mixHex(tone(math.Max(0, d.lum*0.6-0.04)), deep, 0.18))
1443				hi := hex3(tone(math.Min(1, d.lum*1.35+0.06)))
1444				facetsB.WriteString(`<path d="` + relPath(a) + `" fill="` + hi + `"/><path d="` + relPath(b) + `" fill="` + lo + `"/>`)
1445				continue
1446			}
1447		}
1448		facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)
1449	}
1450
1451	// Lighting that follows the rock. The stone swings to -rot and +rot
1452	// (motionPhases); relative to the stone that turns the lamp the other
1453	// way, so every facet meets the light at a new angle and they all
1454	// change together. For each swing the facets that change most get a
1455	// copy in their new shade, grouped, and the group fades in with the
1456	// swing (gt-la / gt-lb in styleBlock, timed off the rock's own
1457	// duration and delay, which the group inherits from .gt-r). Only
1458	// group opacity animates -- no per-facet colour animation, which is
1459	// what keeps it smooth in Safari. The lamp turns lightSwing times the
1460	// rock angle: a real stone flashes hard on a tiny movement, and at 1x
1461	// a 4-8 degree rock barely moves the shading.
1462	_, _, _, _, rotDeg := motionPhases(salt)
1463	type cand struct {
1464		i     int
1465		fill  string
1466		delta float64
1467	}
1468	for si, sgn := range []float64{1, -1} {
1469		if fs.thumb {
1470			break
1471		}
1472		// rotate(-rot) in SVG (y down) is a visual counter-clockwise turn,
1473		// which in this y-up view space turns the stone +rot, i.e. the
1474		// lamp -rot relative to it
1475		a := -sgn * rotDeg * lightSwing * math.Pi / 180
1476		ca, sa := math.Cos(a), math.Sin(a)
1477		L := vnorm(v3{light.x*ca - light.y*sa, light.x*sa + light.y*ca, light.z})
1478		var cs []cand
1479		for i, d := range front {
1480			if d.kind == kTable {
1481				continue
1482			}
1483			f2, _ := shadeFront(d.kind, d.nv, L, d.jit, d.drift, d.thick, d.ext)
1484			if dl := lumaDelta(f2, d.fill); dl > 0.06 && polyArea(d.pts) > 3 {
1485				cs = append(cs, cand{i, f2, dl})
1486			}
1487		}
1488		// strongest changes first; insertion sort, the list is short
1489		for i := 1; i < len(cs); i++ {
1490			for j := i; j > 0 && cs[j].delta > cs[j-1].delta; j-- {
1491				cs[j], cs[j-1] = cs[j-1], cs[j]
1492			}
1493		}
1494		if len(cs) > lightMaxFacets {
1495			cs = cs[:lightMaxFacets]
1496		}
1497		if len(cs) == 0 {
1498			continue
1499		}
1500		cls := "gt-la"
1501		if si == 1 {
1502			cls = "gt-lb"
1503		}
1504		facetsB.WriteString(`<g class="` + cls + `" opacity="0">`)
1505		for _, c := range cs {
1506			facetsB.WriteString(`<path d="` + relPath(front[c.i].pts) + `" fill="` + c.fill + `"/>`)
1507		}
1508		facetsB.WriteString(`</g>`)
1509	}
1510
1511	// girdle: the near half of the ring as one frosted stroke
1512	gd, lastG := "", ""
1513	for i := range girdle {
1514		a, b := view(girdle[i]), view(girdle[(i+1)%len(girdle)])
1515		mid := vscale(v3{girdle[i].x + girdle[(i+1)%len(girdle)].x, girdle[i].y + girdle[(i+1)%len(girdle)].y, 0}, 0.5)
1516		if view(vnorm(mid)).z-0 > -0.05 && view(mid).z >= view(v3{}).z-0.01 {
1517			pa, pb := scr(a), scr(b)
1518			// consecutive segments share an end point: continue the
1519			// polyline instead of a fresh M...L pair per segment
1520			sa := n1(pa.x) + " " + n1(pa.y)
1521			if sa != lastG {
1522				gd += "M" + sa
1523			}
1524			lastG = n1(pb.x) + " " + n1(pb.y)
1525			gd += " " + lastG
1526		}
1527	}
1528	// The girdle is a faint line in the stone's own tone, not a light
1529	// edge, and there is no outline stroke: a drawn rim reads as a
1530	// sticker border, real stones end where their facets end.
1531	over = `<path d="` + gd + `" fill="none" stroke="` + hex3(lighten(base, 0.18)) + `" stroke-width=".7" stroke-linecap="round" opacity=".4"/>`
1532	return underB.String(), facetsB.String(), over
1533}
1534
1535// dispersionMix is the most a bright facet is tinted toward its spectral
1536// colour (for a stone with prof.sat 1, i.e. diamond).
1537const dispersionMix = 0.32
1538
1539var spectrum = []string{"#ff4040", "#ff9a1f", "#ffe81f", "#35ff7a", "#22ccff", "#5566ff", "#c04dff"}
1540
1541var spectrumI = func() []rgbI {
1542	out := make([]rgbI, len(spectrum))
1543	for i, h := range spectrum {
1544		out[i] = toRGBI(h)
1545	}
1546	return out
1547}()
1548
1549func spectrumAtI(t float64) rgbI {
1550	t = t - math.Floor(t)
1551	f := t * float64(len(spectrumI))
1552	i := int(f)
1553	return mixI(spectrumI[i%len(spectrumI)], spectrumI[(i+1)%len(spectrumI)], f-float64(i))
1554}
1555
1556// spectrumAt returns the spectral colour at t, wrapping every 1.0 (so
1557// facets facing opposite ways get the same hue, as a symmetric cut does).
1558func spectrumAt(t float64) string {
1559	t = t - math.Floor(t)
1560	f := t * float64(len(spectrum))
1561	i := int(f)
1562	return mixHex(spectrum[i%len(spectrum)], spectrum[(i+1)%len(spectrum)], f-float64(i))
1563}
1564
1565// lightSwing is how far the lamp turns per degree of rock, and
1566// lightMaxFacets how many facets per swing get a relit copy (each costs
1567// one path, ~60 bytes).
1568const (
1569	lightSwing     = 2.2
1570	lightMaxFacets = 12
1571)
1572
1573// lumaDelta is the perceived brightness difference of two hex colours,
1574// 0..1.
1575func lumaDelta(a, b string) float64 {
1576	// facet fills are #rgb (hex3); widen to #rrggbb for hexToRGB
1577	wide := func(h string) string {
1578		if len(h) == 4 {
1579			return "#" + h[1:2] + h[1:2] + h[2:3] + h[2:3] + h[3:4] + h[3:4]
1580		}
1581		return h
1582	}
1583	ar, ag, ab := hexToRGB(wide(a))
1584	br, bg, bb := hexToRGB(wide(b))
1585	d := (0.2126*float64(ar-br) + 0.7152*float64(ag-bg) + 0.0722*float64(ab-bb)) / 255
1586	if d < 0 {
1587		return -d
1588	}
1589	return d
1590}
1591
1592// fireMaxFacets bounds how many facets of a stone can flash.
1593const fireMaxFacets = 12
1594
1595// starfield toggles the background stars (see renderStars).
1596const starfield = true
1597
1598// renderStars scatters a sparse starfield behind everything. The field
1599// itself is still: every star keeps its own fixed brightness (four levels,
1600// sizes to match), the way a night sky looks. Only a few stars twinkle,
1601// and each twinkler is its own element with its own period and phase,
1602// drawn per token, so no two ever blink together -- the old field had one
1603// whole group of stars fading in and out in unison. The ring band is kept
1604// clear. Seeded per token.
1605func renderStars(salt string) string {
1606	if !starfield {
1607		return ""
1608	}
1609	rnd := newPRNG("stars" + salt)
1610	type star struct{ x, y float64 }
1611	var byLevel [4][]star
1612	for i := 0; i < 36; i++ {
1613		x, y := rnd.floatIn(3, canvasW-3), rnd.floatIn(3, 127)
1614		k := rnd.intn(20)
1615		dx, dy := x-subjX, y-subjY
1616		if r := math.Sqrt(dx*dx + dy*dy); r > 43 && r < 58 {
1617			continue
1618		}
1619		lvl := 0 // most stars are faint; a few are bright
1620		switch {
1621		case k >= 18:
1622			lvl = 3
1623		case k >= 14:
1624			lvl = 2
1625		case k >= 8:
1626			lvl = 1
1627		}
1628		byLevel[lvl] = append(byLevel[lvl], star{x, y})
1629	}
1630	widths := [4]string{".45", ".6", ".85", "1.15"}
1631	ops := [4]string{".22", ".38", ".58", ".85"}
1632	var b strings.Builder
1633	b.WriteString(`<g stroke="#fff" stroke-linecap="round" fill="none">`)
1634	var tw []star
1635	for l := 0; l < 4; l++ {
1636		if len(byLevel[l]) == 0 {
1637			continue
1638		}
1639		d := ""
1640		for i, st := range byLevel[l] {
1641			// the twinklers come from the middle levels (visible, not the
1642			// brightest), at most three per token
1643			if l >= 1 && l <= 2 && len(tw) < 3 && i%3 == 0 {
1644				tw = append(tw, st)
1645				continue
1646			}
1647			d += "M" + n1(st.x) + " " + n1(st.y) + "h.01"
1648		}
1649		if d != "" {
1650			b.WriteString(`<path d="` + d + `" stroke-width="` + widths[l] + `" opacity="` + ops[l] + `"/>`)
1651		}
1652	}
1653	for _, st := range tw {
1654		// 5-12 s periods (irrational-ish ratios between them) and random
1655		// phases: each twinkles alone, briefly, now and then
1656		dur := rnd.floatIn(5, 12)
1657		b.WriteString(`<path d="M` + n1(st.x) + " " + n1(st.y) + `h.01" stroke-width=".9" opacity=".4" class="gt-tw" style="--d:` + fnum1(dur) + `s;--dl:-` + fnum1(rnd.floatIn(0, dur)) + `s"/>`)
1658	}
1659	b.WriteString(`</g>`)
1660	return b.String()
1661}
1662
1663/* ------------------------------------ pearl shapes ------------------------------------ */
1664// Pearls are organic gems -- they grow into a form, they are never cut or
1665// faceted the way a crystal is. So unlike the cut vocabulary above
1666// (ShapeNames/renderGem), everything here draws a smooth, continuous
1667// surface: one radial luster gradient plus a soft diagonal highlight band
1668// (a pearl's real "orient" sheen), no straight-edged facet triangles.
1669
1670// pearlLusterGradient returns the defs for a pearl's nacre, as four
1671// gradients the body shape is filled with in turn (pearlLayers):
1672//   gid    body: a sphere lit from the upper left -- lifted where the light
1673//          lands, the body colour through the middle, a real terminator
1674//          (the shadowed limb, lower right), and a thin bounce-lit rim.
1675//          The old version only lightened a white body, which cannot get
1676//          brighter, so white pearls read as flat discs.
1677//   gid+o  overtone: nacre's resting iridescence, rose over the body and
1678//          green toward the rim, the two interference colours pearls show.
1679//   gid+s  specular: the lamp itself, a small hot core with a soft skirt.
1680//          Nacre is glossy; the core is what says "pearl" at 200 px.
1681//   gid+w  window: the second, dimmer reflection of the room on the far
1682//          side, which is what makes a sphere read as a mirror, not paint.
1683// Shadow tones are warm for light bodies and neutral for dark ones: a
1684// white pearl shades toward grey-taupe, never toward black.
1685func pearlLusterGradient(gid, base, metal string) string {
1686	r, g, b := hexToRGB(base)
1687	lum := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 255
1688	shadow := "#4a4038"
1689	if lum < 0.4 {
1690		shadow = "#000000"
1691	}
1692	// The lit side is the body colour itself and everything else is
1693	// shaded down from it, so the specular (pure white) is the only thing
1694	// that brightest -- that contrast is what reads as gloss. Full 6-digit
1695	// hex here: #rgb quantising flattened the subtle pearl tones.
1696	hi := base
1697	// highlight strengths; a dark (Tahitian) pearl gets a smaller, softer
1698	// highlight: at full strength a white hot spot on a near-black body
1699	// read as a glare, not as nacre
1700	// A soft, wide sheen rather than a hot spot: a real pearl's lustre is
1701	// a broad glow with a gentle falloff (nacre scatters light), not a
1702	// pinpoint. Peak kept well below full white, spread over a larger
1703	// radius with an extra mid stop so there is no hard edge.
1704	sc, ss, sw := ".6", ".32", ".16"
1705	mo := ".3"
1706	if lum < 0.4 {
1707		mo = ".22"
1708		hi = mixHex(base, "#c9d2d8", 0.28)
1709		sc, ss, sw = ".34", ".16", ".07"
1710	}
1711	return `<radialGradient id="` + gid + `" cx=".4" cy=".36" r=".7" fx=".34" fy=".28">` +
1712		`<stop offset="0" stop-color="` + hi + `"/>` +
1713		`<stop offset=".4" stop-color="` + mixHex(base, shadow, 0.13) + `"/>` +
1714		`<stop offset=".76" stop-color="` + mixHex(base, shadow, 0.36) + `"/>` +
1715		`<stop offset=".93" stop-color="` + mixHex(base, shadow, 0.5) + `"/>` +
1716		`<stop offset="1" stop-color="` + mixHex(base, shadow, 0.3) + `"/></radialGradient>` +
1717		`<radialGradient id="` + gid + `o" cx=".45" cy=".44" r=".62">` +
1718		`<stop offset=".3" stop-color="#fff" stop-opacity="0"/>` +
1719		`<stop offset=".62" stop-color="#ffb3cf" stop-opacity=".2"/>` +
1720		`<stop offset=".88" stop-color="#b3ffd6" stop-opacity=".2"/>` +
1721		`<stop offset="1" stop-color="#c3d3ff" stop-opacity=".12"/></radialGradient>` +
1722		`<radialGradient id="` + gid + `s" cx=".36" cy=".3" r=".4">` +
1723		`<stop offset="0" stop-color="#fff" stop-opacity="` + sc + `"/>` +
1724		`<stop offset=".35" stop-color="#fff" stop-opacity="` + ss + `"/>` +
1725		`<stop offset=".7" stop-color="#fff" stop-opacity=".06"/>` +
1726		`<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>` +
1727		// gid+m: the setting mirrored in the nacre -- a thin crescent of
1728		// the ring's metal along the lower limb, where a sphere reflects
1729		// what is beside and below it. A pearl in gold shows a warm edge,
1730		// in platinum a cool one: that is what makes it read as a mirror.
1731		`<radialGradient id="` + gid + `m" cx=".42" cy=".36" r=".68">` +
1732		`<stop offset=".78" stop-color="` + metal + `" stop-opacity="0"/>` +
1733		`<stop offset=".93" stop-color="` + metal + `" stop-opacity="` + mo + `"/>` +
1734		`<stop offset="1" stop-color="` + metal + `" stop-opacity="0"/></radialGradient>` +
1735		`<radialGradient id="` + gid + `w" cx=".66" cy=".73" r=".2">` +
1736		`<stop offset="0" stop-color="#fff" stop-opacity="` + sw + `"/>` +
1737		`<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>`
1738}
1739
1740// pearlLayers re-emits a pearl's body shape once per lustre layer (body,
1741// overtone, metal reflection, window, specular) (see
1742// pearlLusterGradient). shape is the full self-closing element with its
1743// fill attribute; only the fill is swapped. The rings of a Circled pearl
1744// go between the overtone and the highlights, so the highlight sits on
1745// top of the grooves as it would on a real surface.
1746func pearlLayers(shape, gid, between string) string {
1747	i := strings.Index(shape, ` fill="`)
1748	if i < 0 {
1749		return shape
1750	}
1751	cp := func(suffix string) string { return shape[:i] + ` fill="url(#` + gid + suffix + `)"/>` }
1752	return shape + cp("o") + cp("m") + between + cp("w") + cp("s")
1753}
1754
1755// pearlSweepColorIntensity picks the sweep gradient's opacity multiplier
1756// off the base color's own luminance: a dark body (e.g. Black) already
1757// reads a screen-blended color band far more strongly than a light body
1758// does at the same opacity, so light bodies get a bigger boost off the
1759// shared baseline than dark ones do. Threshold/multipliers tuned by eye
1760// against the full 8-color palette, not derived from anything physical.
1761func pearlSweepColorIntensity(base string) float64 {
1762	r, g, b := hexToRGB(base)
1763	lum := 0.2126*float64(r)/255 + 0.7152*float64(g)/255 + 0.0722*float64(b)/255
1764	if lum < 0.4 {
1765		// was 1.2: on a black pearl the colour band read as a bright
1766		// stripe; nacre's orient on a dark body is subtle
1767		return 0.55
1768	}
1769	return 2.0
1770}
1771
1772// pearlSweepGradient is the traveling band's own color: transparent at
1773// both ends, a soft pink/blue/green/gold spectrum in the middle -- an
1774// approximation of thin-film interference (the real source of nacre's
1775// iridescence) rather than a single flat highlight tone.
1776func pearlSweepGradient(gid, base string) string {
1777	mult := pearlSweepColorIntensity(base)
1778	o1, o2, o3 := fnum(0.3*mult), fnum(0.35*mult), fnum(0.3*mult)
1779	return `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="0%">` +
1780		`<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>` +
1781		`<stop offset="18%" stop-color="#ffd2e6" stop-opacity="` + o1 + `"/>` +
1782		`<stop offset="38%" stop-color="#cfe8ff" stop-opacity="` + o2 + `"/>` +
1783		`<stop offset="58%" stop-color="#d4ffe4" stop-opacity="` + o3 + `"/>` +
1784		`<stop offset="80%" stop-color="#fff0c2" stop-opacity="` + o3 + `"/>` +
1785		`<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>` +
1786		`</linearGradient>`
1787}
1788
1789// pearlSweepOverlay replaces the old fixed pearlOrientSheen ellipse: a
1790// handful of slow, wide, translucent color bands that each cross the
1791// pearl's own silhouette once from a random angle, then wait, then cross
1792// again from a new random angle, on repeat. shapeGeom is that pearl's own
1793// outline (no fill needed -- only used inside a clipPath) so the color
1794// never spills past the gem's edge, the same way applyCascade's facet
1795// flashes are seeded per token off tidStr rather than being identical
1796// across every piece.
1797func pearlSweepOverlay(salt, base, shapeGeom string, w, h float64) string {
1798	rnd := newPRNG(salt + "-sweep")
1799	const passes = 3
1800	total := 15.0 + rnd.floatIn(0, 9)
1801
1802	durs := make([]float64, passes)
1803	sumDur := 0.0
1804	for i := 0; i < passes; i++ {
1805		durs[i] = 2.4 + rnd.floatIn(0, 2.2)
1806		sumDur += durs[i]
1807	}
1808	gapTotal := total - sumDur
1809	if gapTotal < float64(passes)*0.5 {
1810		gapTotal = float64(passes) * 0.5
1811	}
1812	weights := make([]float64, passes)
1813	sumW := 0.0
1814	for i := 0; i < passes; i++ {
1815		weights[i] = 0.4 + rnd.floatIn(0, 1)
1816		sumW += weights[i]
1817	}
1818	gaps := make([]float64, passes)
1819	for i := 0; i < passes; i++ {
1820		gaps[i] = weights[i] / sumW * gapTotal
1821	}
1822	starts := make([]float64, passes)
1823	t := gaps[0]
1824	for i := 0; i < passes; i++ {
1825		starts[i] = t
1826		t += durs[i]
1827		if i < passes-1 {
1828			t += gaps[i+1]
1829		}
1830	}
1831
1832	travel := w * 1.05
1833	clipID := "clp" + salt
1834	gradID := "swp" + salt
1835	off, on1, on2, out := fnum(-travel), fnum(-travel*0.5), fnum(travel*0.5), fnum(travel)
1836
1837	var defs strings.Builder
1838	defs.WriteString(`<defs><clipPath id="` + clipID + `">` + shapeGeom + `</clipPath>`)
1839	defs.WriteString(pearlSweepGradient(gradID, base))
1840
1841	var marks strings.Builder
1842	marks.WriteString(`<g clip-path="url(#` + clipID + `)">`)
1843	for i := 0; i < passes; i++ {
1844		angle := fnum(rnd.floatIn(0, 360))
1845		startPct := starts[i] / total * 100
1846		durPct := durs[i] / total * 100
1847		rampPct := durPct * 0.32
1848		if rampPct > 6 {
1849			rampPct = 6
1850		}
1851		p0, p1, p2, p3 := startPct, startPct+rampPct, startPct+durPct-rampPct, startPct+durPct
1852		kf := "swp" + salt + "_" + ufmt.Sprintf("%d", i)
1853
1854		closing := ""
1855		if p3 < 99.999 {
1856			closing = `100%{transform:translate(` + off + `px,0);opacity:0}`
1857		}
1858		defs.WriteString(`<style>@keyframes ` + kf + `{` +
1859			`0%{transform:translate(` + off + `px,0);opacity:0}` +
1860			fnum(p0) + `%{transform:translate(` + off + `px,0);opacity:0}` +
1861			fnum(p1) + `%{transform:translate(` + on1 + `px,0);opacity:1}` +
1862			fnum(p2) + `%{transform:translate(` + on2 + `px,0);opacity:1}` +
1863			fnum(p3) + `%{transform:translate(` + out + `px,0);opacity:0}` +
1864			closing +
1865			`}</style>`)
1866
1867		marks.WriteString(`<g transform="rotate(` + angle + ` ` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +
1868			`<rect x="` + fnum(subjX-w*1.3) + `" y="` + fnum(subjY-h*0.95) + `" width="` + fnum(w*2.6) + `" height="` + fnum(h*1.9) + `" fill="url(#` + gradID + `)" opacity="0" style="mix-blend-mode:screen;animation:` + kf + ` ` + fnum(total) + `s linear infinite"/>` +
1869			`</g>`)
1870	}
1871	marks.WriteString(`</g>`)
1872	defs.WriteString(`</defs>`)
1873	return defs.String() + marks.String()
1874}
1875
1876func pearlRound(base, metal, salt string) string {
1877	gid := "prl" + salt
1878	r := 24.0 * stoneScale
1879	body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
1880	clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
1881	sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
1882	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1883}
1884
1885func pearlOval(base, metal, salt string) string {
1886	gid := "prl" + salt
1887	rx, ry := 18.0*stoneScale, 25.0*stoneScale
1888	body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
1889	clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
1890	sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
1891	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1892}
1893
1894// pearlButton is flattened rather than round -- wider than tall, the
1895// real "button pearl" silhouette (as if gently pressed), popular in
1896// jewelry specifically because the flat back sits well against skin.
1897func pearlButton(base, metal, salt string) string {
1898	gid := "prl" + salt
1899	rx, ry := 25.0*stoneScale, 18.0*stoneScale
1900	body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
1901	clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
1902	sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
1903	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1904}
1905
1906// pearlDrop is a smooth teardrop built from two Bezier arcs -- unlike
1907// gemPear's faceted polygon, a real drop pearl tapers continuously, with
1908// no straight edges anywhere on its surface.
1909func pearlDrop(base, metal, salt string) string {
1910	gid := "prl" + salt
1911	// wider and taller than the round's footprint suggests: a drop only
1912	// fills half its bounding box, and at 20x30 it read as a seed
1913	w, h := 32.0*stoneScale, 42.0*stoneScale
1914	path := `M ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) +
1915		` C ` + fnum(subjX+w*0.55) + ` ` + fnum(subjY-h*0.15) + ` ` + fnum(subjX+w*0.42) + ` ` + fnum(subjY+h*0.42) + ` ` + fnum(subjX) + ` ` + fnum(subjY+h*0.48) +
1916		` C ` + fnum(subjX-w*0.42) + ` ` + fnum(subjY+h*0.42) + ` ` + fnum(subjX-w*0.55) + ` ` + fnum(subjY-h*0.15) + ` ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) + ` Z`
1917	body := `<path d="` + path + `" fill="url(#` + gid + `)"/>`
1918	clipGeom := `<path d="` + path + `"/>`
1919	sweep := pearlSweepOverlay(salt, base, clipGeom, w*1.6, h*0.9)
1920	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1921}
1922
1923// pearlBaroqueOutline draws an irregular, asymmetric silhouette -- a real
1924// baroque pearl has no symmetry at all, which is exactly what makes it a
1925// baroque pearl rather than a round one.
1926func pearlBaroqueOutline(seed string) []pt {
1927	rnd := newPRNG(seed)
1928	n := 10
1929	pts := make([]pt, n)
1930	for k := 0; k < n; k++ {
1931		a := 2 * math.Pi * float64(k) / float64(n)
1932		rr := 21.0 * stoneScale * (0.75 + 0.4*rnd.floatIn(0, 1))
1933		pts[k] = pt{subjX + rr*math.Cos(a)*1.1, subjY + rr*math.Sin(a)*0.9}
1934	}
1935	return pts
1936}
1937
1938// pearlBaroqueSmoothPath threads a quadratic Bezier through the midpoint
1939// of every outline segment, so the irregular outline still reads as one
1940// continuous organic surface -- a straight-edged polygon here would read
1941// as a cut facet, which a baroque pearl's surface never has.
1942func pearlBaroqueSmoothPath(pts []pt) string {
1943	n := len(pts)
1944	mid := func(a, b pt) pt { return pt{(a.x + b.x) / 2, (a.y + b.y) / 2} }
1945	start := mid(pts[n-1], pts[0])
1946	path := "M " + fnum(start.x) + " " + fnum(start.y)
1947	for k := 0; k < n; k++ {
1948		next := pts[(k+1)%n]
1949		m := mid(pts[k], next)
1950		path += " Q " + fnum(pts[k].x) + " " + fnum(pts[k].y) + " " + fnum(m.x) + " " + fnum(m.y)
1951	}
1952	return path + " Z"
1953}
1954
1955func pearlBaroque(base, metal, salt string) string {
1956	gid := "prl" + salt
1957	pathD := pearlBaroqueSmoothPath(pearlBaroqueOutline("baroque" + salt))
1958	body := `<path d="` + pathD + `" fill="url(#` + gid + `)"/>`
1959	clipGeom := `<path d="` + pathD + `"/>`
1960	sweep := pearlSweepOverlay(salt, base, clipGeom, 40*stoneScale, 30*stoneScale)
1961	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1962}
1963
1964// pearlCircled draws thin concentric growth rings over a round body --
1965// the defining feature of a real "circled" (or "ringed") pearl, visible
1966// grooves left by its own growth, not a decorative pattern.
1967func pearlCircled(base, metal, salt string) string {
1968	gid := "prl" + salt
1969	r := 24.0 * stoneScale
1970	body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
1971	ringTone := mixHex(base, "#000000", 0.12)
1972	rings := ""
1973	for _, f := range []float64{0.42, 0.58, 0.74, 0.9} {
1974		rings += `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r*f) + `" fill="none" stroke="` + ringTone + `" stroke-width="0.9" opacity="0.35"/>`
1975	}
1976	clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
1977	sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
1978	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, rings) + sweep
1979}
1980
1981// PearlShapeNames lists the real jewelry-trade pearl shape categories --
1982// deliberately NOT the faceted-cut vocabulary above (ShapeNames). Round
1983// is the rarest/most prized (perfectly spherical); Baroque is the least
1984// regular (freeform, no symmetry); Circled pearls show visible
1985// concentric growth grooves.
1986var PearlShapeNames = []string{"Round", "Oval", "Button", "Drop", "Baroque", "Circled"}
1987
1988// renderPearlShape is PearlShapeNames' own counterpart to renderGem.
1989func renderPearlShape(shapeIdx int, base, metal, salt string) string {
1990	switch shapeIdx {
1991	case 0:
1992		return pearlRound(base, metal, salt)
1993	case 1:
1994		return pearlOval(base, metal, salt)
1995	case 2:
1996		return pearlButton(base, metal, salt)
1997	case 3:
1998		return pearlDrop(base, metal, salt)
1999	case 4:
2000		return pearlBaroque(base, metal, salt)
2001	default:
2002		return pearlCircled(base, metal, salt)
2003	}
2004}
2005
2006/* -------------------------------------- ring -------------------------------------- */
2007
2008// renderRing draws the metal band (with bevel) plus the neon energy-field
2009// glow — fixed outer edge (never animates position), two static-geometry
2010// layers crossfaded via opacity only. No blur filter: an SVG <filter>
2011// wrapping an opacity-animated child is expensive to composite in
2012// WebKit specifically (confirmed via a live Safari test against a mock
2013// page with it removed — Chrome never showed the problem, masking it).
2014// The gradient's own multi-stop falloff carries the glow instead.
2015func renderRing(metalHi, metalLo, salt string) string {
2016	// Light-from-top-left tilt: instead of one smooth diagonal fade, the
2017	// gradient alternates hi/lo twice along the same diagonal axis, so
2018	// the band reads as catching more than one reflected highlight as it
2019	// curves — a real polished ring's "multi-band Fresnel" look, chosen
2020	// over a plain 2-stop fade after a live side-by-side comparison of
2021	// six alternative ring treatments. All three strokes (outer/main/
2022	// inner) reference this one gradient so the light direction is
2023	// consistent across the whole band, not just its center.
2024	gid := "ring" + salt
2025	// Wider tonal swing than the metal table's own hi/lo pair: the bright
2026	// bands lift toward white and the dark bands sink toward black, so
2027	// the band reads as polished metal turning through the light instead
2028	// of a flat painted hoop (the old hi/lo-only version read as matte).
2029	bright := lighten(metalHi, 0.45)
2030	deep := mixHex(metalLo, "#000000", 0.45)
2031	// Compact markup (same picture as the long form): stop offsets as
2032	// fractions, circles in one translated group so cx/cy/fill aren't
2033	// repeated -- this runs on every token read, bytes matter.
2034	st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }
2035	defs := `<linearGradient id="` + gid + `" x2="1" y2="1">` +
2036		st("0", bright) + st(".12", metalHi) + st(".26", deep) + st(".4", metalLo) + st(".52", bright) +
2037		st(".64", metalHi) + st(".78", deep) + st(".9", metalLo) + st("1", bright) + `</linearGradient>`
2038
2039	// Drop shadow under the band (grounds it), the band, then bevel edges:
2040	// a bright inner-top edge and a dark outer edge, the way a real
2041	// rounded band catches light on the side facing the lamp and falls
2042	// into shadow on the far side. No drawn specular arcs: they read as
2043	// cartoon highlights; the band gradient carries the light on its own.
2044	// All plain strokes, no filters.
2045	outer := `<g fill="none" transform="translate(` + cnum(subjX) + ` ` + cnum(subjY) + `)">` +
2046		`<circle r="52.6" stroke="#000" stroke-width="1.6" opacity=".45"/>` +
2047		`<circle r="50" stroke="url(#` + gid + `)" stroke-width="4.4"/>` +
2048		`<circle r="52.3" stroke="` + deep + `" stroke-width=".7" opacity=".8"/>` +
2049		`<circle r="47.7" stroke="` + deep + `" stroke-width=".6" opacity=".7"/>` +
2050		`<circle r="48.5" stroke="` + lighten(metalHi, 0.35) + `" stroke-width=".5" opacity=".45"/></g>`
2051
2052	const rOut = 46.0
2053	const coreHalf = 0.6
2054	const outwardBloom = 0.9
2055	glowR := rOut + outwardBloom + 3
2056	pct := func(v float64) float64 {
2057		p := v / glowR * 100
2058		if p < 0 {
2059			p = 0
2060		}
2061		if p > 100 {
2062			p = 100
2063		}
2064		return p
2065	}
2066	// Core tinted in the metal's own tone rather than near-white, and the
2067	// animated layer peaks lower and breathes slower (see dur): next to a
2068	// realistic stone the old white neon pulse was the most stylised thing
2069	// on the canvas.
2070	coreColor := lighten(metalHi, 0.25)
2071	donutStops := func(inwardBloom, peakOp float64, gid string) string {
2072		bloomOp := peakOp * 0.35
2073		midOp := peakOp * 0.7
2074		return `<radialGradient id="` + gid + `" cx="50%" cy="50%" r="50%">` +
2075			`<stop offset="0%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
2076			`<stop offset="` + pctnum(pct(rOut-inwardBloom-1.5)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
2077			`<stop offset="` + pctnum(pct(rOut-inwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
2078			`<stop offset="` + pctnum(pct(rOut-coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
2079			`<stop offset="` + pctnum(pct(rOut)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(peakOp) + `"/>` +
2080			`<stop offset="` + pctnum(pct(rOut+coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
2081			`<stop offset="` + pctnum(pct(rOut+outwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
2082			`<stop offset="` + pctnum(pct(rOut+outwardBloom+1)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
2083			`<stop offset="100%" stop-color="` + metalHi + `" stop-opacity="0"/></radialGradient>`
2084	}
2085
2086	rnd := newPRNG("energy" + salt)
2087	gidA, gidB := "glowA"+salt, "glowB"+salt
2088	// 2.13-3.2 s: 75% of the original energy pulse's speed (gems.gno and
2089	// gemsart2 ran 1.6-2.4 s; gemsartfab had slowed it to 3.5-5.5 s)
2090	dur := rnd.floatIn(2.13, 3.2)
2091	phase := rnd.floatIn(0, dur)
2092
2093	stopsA := donutStops(2.5*2.0/3.0*0.5, 0.4, gidA)
2094	stopsB := donutStops(8.0*2.0/3.0*0.5, 0.6, gidB)
2095
2096	energy := `<defs>` + stopsA + stopsB + `</defs>` +
2097		`<g>` +
2098		`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidA + `)"/>` +
2099		`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidB + `)" opacity="0" class="gt-glow" style="--glow-dur:` + fnum(dur) + `s;--glow-delay:-` + fnum(phase) + `s"/>` +
2100		`</g>`
2101
2102	return `<defs>` + defs + `</defs>` + outer + energy
2103}
2104
2105/* ------------------------------------- motion ------------------------------------- */
2106
2107const (
2108	motionVDur, motionHDur, motionRDur, motionSDur = 3.4, 4.66, 5.81, 6.56
2109	motionRotMin, motionRotMax                     = 4.0, 8.0
2110	motionScale                                    = 1.09
2111)
2112
2113// applyMotion wraps content in the floating-holder animation (vertical
2114// float + sideways drift + left/right rock + scale pulse), each axis on
2115// its own independently-phased period so pieces never move in sync.
2116// CSS @keyframes (see styleBlock), not SMIL -- WebKit's SMIL engine
2117// scales badly with many concurrently-animated elements on one page,
2118// confirmed via a live cross-browser test. transform-origin:view-box
2119// on gt-r/gt-s replaces SMIL's manual translate-to-pivot/scale/
2120// translate-back trick, so this nests in fewer elements than before,
2121// not more.
2122// motionPhases draws a token's four motion phases and its rock angle.
2123// Shared with applyCascade so the fire can lock to the rock: a flash
2124// should happen when the stone tilts, not on a timer of its own.
2125func motionPhases(salt string) (vphase, hphase, rphase, sphase, rotDeg float64) {
2126	rnd := newPRNG("motion" + salt)
2127	vphase = rnd.floatIn(0, motionVDur)
2128	hphase = rnd.floatIn(0, motionHDur)
2129	rphase = rnd.floatIn(0, motionRDur)
2130	sphase = rnd.floatIn(0, motionSDur)
2131	rotDeg = rnd.floatIn(motionRotMin, motionRotMax)
2132	return
2133}
2134
2135func applyMotion(content, salt string) string {
2136	vphase, hphase, rphase, sphase, rotDeg := motionPhases(salt)
2137
2138	// The rock and the pulse pivot on the ring centre by geometry, not by
2139	// transform-box/transform-origin: the motion groups sit inside a group
2140	// already translated to (subjX, subjY), so their own origin IS the ring
2141	// centre, and the content is shifted back inside them. Safari has
2142	// mishandled transform-box inside <img> SVGs, which swings a stone
2143	// around the image corner instead of its centre.
2144	return `<g transform="translate(` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +
2145		`<g class="gt-v" style="--v-dur:` + fnum(motionVDur) + `s;--v-delay:-` + fnum(vphase) + `s">` +
2146		`<g class="gt-h" style="--h-dur:` + fnum(motionHDur) + `s;--h-delay:-` + fnum(hphase) + `s">` +
2147		`<g class="gt-r" style="--r-dur:` + fnum(motionRDur) + `s;--r-delay:-` + fnum(rphase) + `s;--rot-deg:` + fnum(rotDeg) + `deg">` +
2148		`<g class="gt-s" style="--s-dur:` + fnum(motionSDur) + `s;--s-delay:-` + fnum(sphase) + `s">` +
2149		`<g transform="translate(-` + fnum(subjX) + ` -` + fnum(subjY) + `)">` +
2150		content + `</g></g></g></g></g></g>`
2151}
2152
2153/* ------------------------------- ring and stone size ------------------------------- */
2154// Chosen 2026-09-26 from side-by-sides of several sizes (100 pieces each,
2155// all animated): the ring is drawn 16% larger and the stone at scale 1.21
2156// (was 0.78), so a bigger stone still keeps clear of the band. Both sit
2157// sizeLift units higher so the larger ring clears the chain plate (top at
2158// y 134), and the status dot moves out so it does not touch the band. The
2159// ring is scaled as one group around its own centre: its strokes, bevel
2160// and glow keep their proportions. Plain group transforms, no filters.
2161const (
2162	ringScale = 1.16
2163	sizeLift  = 6.0
2164)
2165
2166func setRing(els string) string {
2167	return `<g transform="translate(` + fnum(subjX) + ` ` + fnum(subjY-sizeLift) + `) scale(` + fnum(ringScale) + `) translate(-` + fnum(subjX) + ` -` + fnum(subjY) + `)">` + els + `</g>`
2168}
2169func setStone(els string) string { return `<g transform="translate(0 -` + fnum(sizeLift) + `)">` + els + `</g>` }
2170func setBadge(els string) string { return `<g transform="translate(5 2)">` + els + `</g>` }
2171
2172/* ---------------------------------- status badge ---------------------------------- */
2173
2174const dotX, dotY = subjX + 50, subjY + 42
2175
2176func renderStatusBadge(active bool) string {
2177	if active {
2178		return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#7fd88f"/>` +
2179			// r starts at 4 (the solid dot's own radius, so the ring
2180			// begins flush with its edge) and scales up via transform --
2181			// see styleBlock's gt-kf-badge-pulse comment for why not r.
2182			// pulse ring drawn at the origin of a translated group, so its
2183			// scale grows from the dot's own centre in every browser
2184			`<g transform="translate(` + fnum(dotX) + ` ` + fnum(dotY) + `)"><circle r="4" fill="none" stroke="#7fd88f" stroke-width="1.2" class="gt-badge-pulse" opacity="0.8"/></g>`
2185	}
2186	return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#5a5f6b"/>`
2187}
2188
2189/* ------------------------------------ facet fire ------------------------------------ */
2190// The stone's sparkle, modelled on what a cut diamond does under a
2191// moving light rather than on a decorative shimmer:
2192//   - scintillation: a facet flashes on sharply and decays, in a third
2193//     of a second, not a slow symmetric fade;
2194//   - fire: the flash is white at its peak and takes on that facet's
2195//     spectral colour as it dies -- colour follows brilliance;
2196//   - sweep: a flash passes ACROSS the stone in one direction (facets
2197//     ordered by their projection along a random sweep axis), and only
2198//     about half of them light on any pass, so it stays sparse;
2199//   - motion-locked: the sweeps sit on the MID-swings of the rock
2200//     animation (see motionPhases and applyCascade's delay), where the
2201//     stone turns fastest and a real reflection moves fastest, and each
2202//     sweep travels the way the stone is turning at that moment -- one
2203//     way on the clockwise swing, the reverse on the way back -- along
2204//     the axis a fixed top-left light traces across a turning stone. So
2205//     the sparkle reads as caused by the motion. A few lone pinpoint
2206//     flashes at random times are the exception.
2207// Cost is unchanged from the old cascade: one solid-filled overlay path
2208// per lit facet, animated on opacity and fill only.
2209
2210// facetSpan is one self-closing facet element found in renderGem's own
2211// markup, in the same left-to-right order it was drawn -- which is also
2212// each facet's real adjacency order around its ring, since every gem_*
2213// function draws its ring in one continuous angular sweep. ringMember
2214// distinguishes a real facet (class="f", set by facetStyle.facet) from
2215// the one non-ring table/center/backing element each shape draws
2216// without it -- that one stays static, outside the cascade.
2217type facetSpan struct {
2218	start, end int
2219	ringMember bool
2220}
2221
2222// scanFacetSpans walks renderGem's own markup once, left to right, with
2223// plain string scanning rather than regexp -- this runs on every
2224// TokenURI/Render call, and CollectionSummary/WalletSummary's own
2225// pagination fix already established that anything unbounded or
2226// unnecessarily expensive in a per-token read path becomes a real "out
2227// of gas" failure at real collection sizes, not a theoretical concern.
2228func scanFacetSpans(markup string) []facetSpan {
2229	var spans []facetSpan
2230	i := 0
2231	for {
2232		rel := strings.Index(markup[i:], "<")
2233		if rel < 0 {
2234			break
2235		}
2236		start := i + rel
2237		relEnd := strings.Index(markup[start:], "/>")
2238		if relEnd < 0 {
2239			break
2240		}
2241		end := start + relEnd + 2
2242		tag := markup[start:end]
2243		i = end
2244		isFacetTag := strings.HasPrefix(tag, "<path") || strings.HasPrefix(tag, "<circle") || strings.HasPrefix(tag, "<polygon")
2245		if !isFacetTag {
2246			continue
2247		}
2248		spans = append(spans, facetSpan{start: start, end: end, ringMember: strings.Contains(tag, `class="f"`)})
2249	}
2250	return spans
2251}
2252
2253
2254// applyCascade is the stone's fire. Every flash-capable facet (class
2255// "f", chosen in renderGem3D) gets a duplicate of its path stacked above
2256// the facets, and ALL of them share two keyframe rules per stone instead
2257// of owning one each -- the per-facet keyframe blocks were several
2258// kilobytes of every token. What differs per facet rides on the element
2259// itself: --c its spectral colour, --o its peak strength, and an
2260// animation-delay that places it in the sweep.
2261//
2262// The flash shape is unchanged from the hand-scheduled version: white at
2263// the peak, the facet's own spectral colour on the decay, under a second.
2264// Rule A fires on the rock animation's clockwise mid-swing and sweeps
2265// along fireSweepAxis; rule B fires on the return swing and sweeps the
2266// other way. Facets are split between the two, so a stone sparkles
2267// forth and back in time with its own motion (period = two rock cycles,
2268// delays offset from the token's rock phase, see motionPhases).
2269const (
2270	fireSweepAxis = -35.0 * math.Pi / 180
2271	fireSweepSpan = 1.6 // seconds a sweep takes to cross the stone
2272)
2273
2274func applyCascade(markup, tidStr string, fs *facetStyle) (overlay string, css string) {
2275	if fs == nil || len(fs.ringCent) == 0 {
2276		return "", ""
2277	}
2278	spans := scanFacetSpans(markup)
2279	rnd := newPRNG("fire" + tidStr)
2280	_, _, rphase, _, _ := motionPhases(tidStr)
2281	period := 2 * motionRDur
2282	axis := fireSweepAxis + rnd.floatIn(-0.3, 0.3)
2283	dx, dy := math.Cos(axis), math.Sin(axis)
2284
2285	// flash: 0 -> white peak -> coloured decay -> 0, in the first ~8% of
2286	// the period (~0.9s); B is the same shape half a period later.
2287	ka, kb := "fa"+tidStr, "fb"+tidStr
2288	shape := func(at float64) string {
2289		return fnum1(at) + `%{opacity:0}` + fnum1(at+2) + `%{opacity:var(--o);fill:#fff}` +
2290			fnum1(at+4.5) + `%{opacity:.4;fill:var(--c)}` + fnum1(at+8) + `%{opacity:0}`
2291	}
2292	per := fnum(period)
2293	// opacity="0" rides on each overlay as an attribute, not only in this
2294	// CSS: a thumbnailer that ignores CSS animation (or CSS) must see the
2295	// resting state, not an unfilled (black) copy of every flash facet.
2296	css = `.` + ka + `{animation:` + ka + ` ` + per + `s linear infinite}.` + kb + `{animation:` + kb + ` ` + per + `s linear infinite}` +
2297		`@keyframes ` + ka + `{` + shape(12.5) + `100%{opacity:0}}` +
2298		`@keyframes ` + kb + `{` + shape(62.5) + `100%{opacity:0}}`
2299
2300	var out strings.Builder
2301	type glintAt struct {
2302		x, y, delay float64
2303		rule        string
2304	}
2305	var cands []glintAt
2306	ringIdx := -1
2307	for _, span := range spans {
2308		if !span.ringMember {
2309			continue
2310		}
2311		ringIdx++
2312		if ringIdx >= len(fs.ringCent) {
2313			break
2314		}
2315		c := fs.ringCent[ringIdx]
2316		color, colorOp := fs.flash.sample(c.x, c.y)
2317		// position along the sweep axis, -1..1 across the stone
2318		pos := ((c.x-subjX)*dx + (c.y-gemCY)*dy) / (30 * stoneScale)
2319		name := ka
2320		if rnd.floatIn(0, 1) < 0.5 {
2321			name, pos = kb, -pos
2322		}
2323		// the rock runs at delay -rphase; the flash windows sit at 12.5%
2324		// and 62.5% of two rock cycles = the rock's two mid-swings
2325		delay := rphase - 0.25*motionRDur + period - pos*fireSweepSpan/2
2326		tag := markup[span.start:span.end]
2327		geomEnd := strings.Index(tag, `" fill=`)
2328		if geomEnd < 0 {
2329			continue
2330		}
2331		cands = append(cands, glintAt{c.x, c.y, delay, name})
2332		out.WriteString(tag[:geomEnd] + `" opacity="0" class="` + name + `" style="--o:` + fnum(math.Round(math.Min(1, rnd.floatIn(0.85, 1.05)*colorOp)*10)/10) + `;--c:` + hex3(color) +
2333			`;animation-delay:-` + fnum1(delay) + `s"/>`)
2334	}
2335	// Star glints: the brightest moment of a real diamond is a pinpoint that
2336	// throws four rays. Three facets (one on the forward sweep, two spread
2337	// out) each get a four-point star that pops at that facet's white peak
2338	// -- same delay as its flash, so it lands exactly as the facet goes
2339	// white -- scales up with a small twist and collapses. Plain paths on
2340	// transform + opacity only; opacity="0" rests them for thumbnailers.
2341	if len(cands) > 0 && fs.prof.sat >= 0.3 {
2342		n := 3
2343		if fs.prof.sat < 0.6 {
2344			n = 2 // quartz, topaz: less fire, fewer stars
2345		}
2346		ga, gb := "ga"+tidStr, "gb"+tidStr
2347		star := func(at float64) string {
2348			return fnum1(at+0.6) + `%{opacity:0;transform:scale(.2) rotate(0)}` +
2349				fnum1(at+2.2) + `%{opacity:1;transform:scale(1) rotate(25deg)}` +
2350				fnum1(at+4.4) + `%{opacity:0;transform:scale(.35) rotate(55deg)}`
2351		}
2352		css += `.` + ga + `,.` + gb + `{opacity:0}` +
2353			`.` + ga + `{animation:` + ga + ` ` + per + `s linear infinite}.` + gb + `{animation:` + gb + ` ` + per + `s linear infinite}` +
2354			`@keyframes ` + ga + `{0%{opacity:0}` + star(12.5) + `100%{opacity:0}}` +
2355			`@keyframes ` + gb + `{0%{opacity:0}` + star(62.5) + `100%{opacity:0}}`
2356		r := 7.5 * stoneScale
2357		w := 0.9 * stoneScale
2358		ray := `M0 -` + fnum1(r) + `L` + fnum1(w) + ` -` + fnum1(w) + `L` + fnum1(r) + ` 0L` + fnum1(w) + ` ` + fnum1(w) +
2359			`L0 ` + fnum1(r) + `L-` + fnum1(w) + ` ` + fnum1(w) + `L-` + fnum1(r) + ` 0L-` + fnum1(w) + ` -` + fnum1(w) + `Z`
2360		var g strings.Builder
2361		used := map[int]bool{}
2362		for k := 0; k < n && k < len(cands); k++ {
2363			i := rnd.intn(len(cands))
2364			for tries := 0; used[i] && tries < len(cands); tries++ {
2365				i = (i + 1) % len(cands)
2366			}
2367			used[i] = true
2368			c := cands[i]
2369			cls := "ga" + tidStr
2370			if c.rule == kb {
2371				cls = "gb" + tidStr
2372			}
2373			g.WriteString(`<g transform="translate(` + fnum1(c.x) + ` ` + fnum1(c.y) + `)"><g class="` + cls + `" opacity="0" style="animation-delay:-` + fnum1(c.delay) + `s">` +
2374				`<path d="` + ray + `" fill="#fff"/><circle r="` + fnum1(1.1*stoneScale) + `" fill="#fff"/></g></g>`)
2375		}
2376		fs.glints = g.String()
2377	}
2378	return out.String(), css
2379}
2380
2381/* ---------------------------------- label + serial ---------------------------------- */
2382
2383func renderMetalLabelRect(metalHi, metalLo string, x, y, w, h, rx float64, salt string) string {
2384	// Echoes the ring's multi-band tilt but heavily muted — a soft
2385	// two-step blend instead of the ring's hard alternating stops, so the
2386	// bar doesn't visually fight the chain-name text sitting on top of it.
2387	gid := "lg" + salt
2388	// A brushed metal plate rather than a flat bar: a horizontal sweep
2389	// with a bright band, a dark outline that seats it on the canvas, a
2390	// light top edge and dark bottom edge (bevel), and a rivet in each
2391	// corner. The chain text is drawn on top by RenderTokenSVG with an
2392	// engraved shadow (see there). Plain shapes, one gradient.
2393	bright := lighten(metalHi, 0.4)
2394	deep := mixHex(metalLo, "#000000", 0.5)
2395	st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }
2396	defs := `<linearGradient id="` + gid + `">` +
2397		st("0", metalLo) + st(".22", metalHi) + st(".42", bright) + st(".62", metalHi) + st("1", metalLo) + `</linearGradient>`
2398	rect := `<rect x="` + cnum(x) + `" y="` + cnum(y) + `" width="` + cnum(w) + `" height="` + cnum(h) + `" rx="` + cnum(rx) + `" fill="url(#` + gid + `)" stroke="` + deep + `" stroke-width=".8"/>`
2399	// Bevel edges follow the plate's own rounded outline, inset a hair:
2400	// a thin soft highlight along the top chamfer, a thin shade along the
2401	// bottom one -- low contrast, the way a real machined edge reads.
2402	const in_ = 0.9
2403	arc := ` a` + cnum(rx-in_) + ` ` + cnum(rx-in_) + ` 0 0 `
2404	d := cnum(rx - in_)
2405	edges := `<g fill="none" stroke-linecap="round">` +
2406		`<path d="M` + cnum(x+in_) + ` ` + cnum(y+rx) + arc + `1 ` + d + ` -` + d + `H` + cnum(x+w-rx) + arc + `1 ` + d + ` ` + d + `" stroke="` + lighten(metalHi, 0.45) + `" stroke-width=".45" opacity=".4"/>` +
2407		`<path d="M` + cnum(x+in_) + ` ` + cnum(y+h-rx) + arc + `0 ` + d + ` ` + d + `H` + cnum(x+w-rx) + arc + `0 ` + d + ` -` + d + `" stroke="` + deep + `" stroke-width=".5" opacity=".5"/></g>`
2408	// Slotted screws: a shadowed countersink, a head a shade off the plate
2409	// tone with a soft sheen up-left, and a slot at a different angle per
2410	// screw (and per token). The head is defined once and placed with
2411	// <use>; all four slots are one path -- bytes matter on every read.
2412	sid := "sh" + salt
2413	defs += `<g id="` + sid + `"><circle cx=".15" cy=".2" r="1.6" fill="` + deep + `" opacity=".55"/>` +
2414		`<circle r="1.3" fill="` + mixHex(metalLo, metalHi, 0.4) + `" stroke="` + deep + `" stroke-width=".25"/>` +
2415		`<circle cx="-.4" cy="-.4" r=".75" fill="` + lighten(metalHi, 0.3) + `" opacity=".35"/></g>`
2416	seed := 0
2417	for k := 0; k < len(salt); k++ {
2418		seed += int(salt[k])
2419	}
2420	heads, slots := "", ""
2421	n := 0
2422	for _, px := range []float64{x + 4.2, x + w - 4.2} {
2423		for _, py := range []float64{y + 3.7, y + h - 3.7} {
2424			heads += `<use href="#` + sid + `" x="` + cnum(px) + `" y="` + cnum(py) + `"/>`
2425			ang := float64((seed*37+n*71)%180) * math.Pi / 180
2426			dx, dy := 0.9*math.Cos(ang), 0.9*math.Sin(ang)
2427			slots += `M` + cnum(px-dx) + ` ` + cnum(py-dy) + `l` + cnum(2*dx) + ` ` + cnum(2*dy)
2428			n++
2429		}
2430	}
2431	rivets := heads + `<path d="` + slots + `" stroke="` + deep + `" stroke-width=".45" stroke-linecap="round"/>`
2432	return `<defs>` + defs + `</defs>` + rect + edges + rivets
2433}
2434
2435// Plate lettering. SVGs shown as <img> can't load web fonts, so this is a
2436// system-font stack: DIN-style engineering capitals where the OS has one
2437// (DIN Alternate on Apple, Bahnschrift on Windows, Roboto Condensed on
2438// Android), tracked out like stamped nameplate type. Engraved rather than
2439// embossed: the letters sit in the plate and their lower lip catches the
2440// light, so the highlight copy goes a hair BELOW the fill.
2441const (
2442	plateFont    = `'DIN Alternate',Bahnschrift,'Roboto Condensed',sans-serif`
2443	plateSize    = 8.6
2444	plateSpacing = 1.1
2445	plateMaxW    = 100.0
2446)
2447
2448func renderPlateText(label, fill, metalHi string) string {
2449	g := `<g font-size="` + cnum(plateSize) + `" letter-spacing="` + cnum(plateSpacing) + `" text-anchor="middle" font-family="` + plateFont + `" font-weight="700"`
2450	// Rough width estimate (condensed caps ~0.52em plus tracking); only a
2451	// label that would run into the screws gets squeezed to fit.
2452	if est := float64(len(label))*(plateSize*0.52+plateSpacing); est > plateMaxW {
2453		g += ` textLength="` + cnum(plateMaxW) + `" lengthAdjust="spacingAndGlyphs"`
2454	}
2455	x := cnum(subjX)
2456	return g + `><text x="` + x + `" y="144.9" fill="` + lighten(metalHi, 0.5) + `" opacity=".45">` + label + `</text>` +
2457		`<text x="` + x + `" y="144.4" fill="` + fill + `">` + label + `</text></g>`
2458}
2459
2460// renderSerialText uses the token's REAL assigned ID (already the exact
2461// Crockford-base32 seqid format gno.land itself produces) — no lookup
2462// table or reimplemented encoding needed, unlike the Python prototype
2463// which had to hardcode a verified sample of real IDs since it couldn't
2464// call the real package directly.
2465func renderSerialText(tidStr, color string) string {
2466	return `<text x="` + fnum(subjX) + `" y="159.5" font-size="6.5" text-anchor="middle" fill="` + color + `" font-family="ui-monospace,monospace" opacity="0.85">#` + tidStr + `</text>`
2467}
2468
2469/* ------------------------------- stone / color / metal tables ------------------------------- */
2470
2471type namedColor struct{ hex, name string }
2472
2473// stoneByChain, stonePalettes, and stoneNames are mutable, not const --
2474// a deliberate departure from this file's "pure functions, no state"
2475// design (see the package doc comment). Originally hardcoded switch
2476// statements covering only Topaz-1/Sapphire-1, seeded here with the
2477// same real gemology research, but now editable via SetStoneForChain/
2478// AddStoneColor (nftminter.gno) so a FUTURE gno.land testnet generation
2479// that gets a real gem codename can be recognized without a contract
2480// redeploy — the whole point of putting this data in state instead of
2481// source.
2482var stoneByChain = map[string]string{
2483	"TOPAZ-1":    "Topaz",
2484	"SAPPHIRE-1": "Sapphire",
2485	"PEARL-1":    "Pearl",
2486	// Mainnet. Diamond: the hardest, most-dispersive stone for the chain
2487	// that is final, and the one gem every collector reads at a glance.
2488	"GNOLAND-1": "Diamond",
2489}
2490
2491// All hex values here (except Red, see "Not Set" below) are the original
2492// palette run through the exact CSS/SVG feColorMatrix type="saturate"
2493// formula at s=1.5 (150%) -- confirmed against a live side-by-side
2494// comparison (three saturation levels, same six real minted pieces)
2495// before choosing this one, "the enhanced saturation" over both the
2496// original 100% and a more extreme 200% option. Not a hand-picked
2497// repaint -- every value is the deterministic output of that one matrix,
2498// so hues stay true, only intensity increases.
2499//
2500// Baked directly into these constants rather than applied via a live
2501// <filter> (or an equivalent Go function called on every render): a
2502// live filter under several CSS-animated ancestors, times potentially
2503// dozens of gems rendered on one page at once, was a real, confirmed
2504// Safari motion-jitter source in this project (same category of fix
2505// already applied once before, to the rings' glow effect). Precomputing
2506// the target color once, here, and shipping it as an ordinary static
2507// fill value removes that cost entirely -- nothing left for any browser
2508// to re-rasterize. The matrix itself, if a future palette addition ever
2509// needs to reproduce it by hand:
2510//   a=.213+.787s  b=.715-.715s  c=.072-.072s
2511//   d=.213-.213s  e=.715+.285s  f=.072-.072s
2512//   g=.213-.213s  h=.715-.715s  i=.072+.928s
2513//   R'=a*R+b*G+c*B  G'=d*R+e*G+f*B  B'=g*R+h*G+i*B  (s=1.5 here)
2514var stonePalettes = map[string][]namedColor{
2515	// Accuracy pass (2026-09-25), five hand-set exceptions to the matrix
2516	// rule, each because the boosted value named the wrong stone: blue
2517	// sapphire is royal/cornflower blue, not cyan (#00b6ff read as
2518	// aquamarine); green sapphire is teal-green, not emerald green;
2519	// padparadscha is by definition pinkish-orange (lotus), not orange-
2520	// red; natural blue topaz is pale sky blue (saturated blue topaz is
2521	// irradiated); sherry topaz is yellowish/reddish brown, not orange.
2522	"Sapphire": {
2523		{"#2a5fe0", "Blue"}, {"#ff6ea4", "Pink"}, {"#ff8501", "Orange"}, {"#ffd500", "Yellow"},
2524		{"#1aa88a", "Green"}, {"#a658ff", "Violet"}, {"#e6e9f8", "White"}, {"#ff8f72", "Padparadscha"},
2525	},
2526	"Topaz": {
2527		{"#f5edd1", "Colorless"}, {"#ffa300", "Imperial"}, {"#ffd100", "Golden"},
2528		{"#c96a28", "Sherry Brown"}, {"#86cdf2", "Sky Blue"}, {"#ff96ba", "Pink"},
2529	},
2530	// Not Set was originally a muted, deliberately non-gem palette (dull
2531	// grays), reasoning that a chain with no assigned stone shouldn't get
2532	// gem-like colors. Replaced per request -- these read as dull/boring
2533	// in practice, and this stone accounts for the large majority of
2534	// chains (every testnet except Topaz-1/Sapphire-1), so it now gets
2535	// the full generic gem-color spectrum instead: every hue already used
2536	// by Sapphire/Topaz (this is intentional reuse, not new hex values --
2537	// a "Not Set" piece can land on any color a real gem might), plus Red,
2538	// which neither Sapphire nor Topaz can use (Sapphire excludes it
2539	// because red corundum is classified as ruby, a separate gem name).
2540	// Red is the one deliberate exception to this file's "every hex is
2541	// the s=1.5 matrix output" rule above -- the matrix-derived #ff4b4b
2542	// read too saturated in practice, so it's hand-tuned ~12.5% less
2543	// saturated (#f45656) instead, per request.
2544	// Crystal (appended last, so every earlier index keeps its meaning)
2545	// is the clearest stone in the pool. Not called Transparent: in
2546	// gemology transparency is diaphaneity, a separate axis from colour,
2547	// and this is a colour list. See fireProfileFor for how it is
2548	// painted -- the hex here is only what the metadata reports and
2549	// what the rim stroke lightens from.
2550	// Quartz (2026-09-25) replaces "Not Set" for every chain without its
2551	// own codename (TEST-1..13, BETANET, any future one): a generic
2552	// "Red Asscher" was not a real gem, and quartz is the one gem family
2553	// whose natural varieties cover the range. Hand-set real colours:
2554	// Rock Crystal (colourless), Amethyst (purple), Citrine (yellow to
2555	// orange-yellow), Rose Quartz (pale, slightly milky pink), Smoky
2556	// Quartz (grey-brown), Prasiolite (pale leek green; mostly heated
2557	// amethyst, rare in nature). Weighted in colorWeights.
2558	"Quartz": {
2559		{"#eef2f7", "Rock Crystal"}, {"#9b59d6", "Amethyst"}, {"#f2b01e", "Citrine"},
2560		{"#f5b9ca", "Rose Quartz"}, {"#7d6250", "Smoky Quartz"}, {"#a9d9a2", "Prasiolite"},
2561	},
2562	// Pearl (PEARL-1, added for the Pearl testnet generation) is the one
2563	// deliberate exception to this file's "every hex is the s=1.5 matrix
2564	// output" rule above, for a real gemological reason rather than a
2565	// stylistic one: pearls are organic gems with a soft nacre sheen, not
2566	// faceted crystals -- the same saturation boost that makes Sapphire/
2567	// Topaz read as vivid gemstones would misrepresent what a pearl
2568	// actually looks like. These 8 are real naturally-occurring pearl
2569	// body colors (not dyed), sourced from gemological references, hand-
2570	// tuned to a muted/pearly range instead of the boosted-saturation one:
2571	// white (Akoya), cream, black (Tahitian -- reads as a very dark grey/
2572	// green, never true black), silver, gold (South Sea), pink and peach
2573	// (freshwater), and lavender (freshwater, one of the rarer natural
2574	// overtones).
2575	// Diamond (GNOLAND-1) is hand-picked like Pearl, not the s=1.5 matrix:
2576	// real diamond colour grades. Colorless first (D-F, the classic stone),
2577	// then the natural "fancy" colours in the trade's own names, including
2578	// the browns sold as Champagne and Cognac, and Black (opaque, a metallic
2579	// shine and no fire -- fireProfileFor paints it separately).
2580	"Diamond": {
2581		{"#f3f6fc", "Colorless"}, {"#ffc400", "Fancy Yellow"}, {"#ff4f9a", "Fancy Pink"},
2582		{"#1f8fff", "Fancy Blue"}, {"#14c46e", "Fancy Green"}, {"#d9a560", "Champagne"},
2583		{"#b4561a", "Cognac"}, {"#2b2d34", "Black"},
2584		// appended 2026-09-25, so every earlier index keeps its meaning;
2585		// red diamonds are purplish-red in life, purple ones are mostly
2586		// violet-purple
2587		{"#e0243a", "Red"}, {"#ff7a1a", "Orange"}, {"#9b4fd6", "Purple"},
2588	},
2589	"Pearl": {
2590		{"#f8f6f0", "White"}, {"#f0e4c8", "Cream"}, {"#3a3a3c", "Black"}, {"#c8cdd4", "Silver"},
2591		{"#d4af6a", "Gold"}, {"#f0d4dc", "Pink"}, {"#f2c9a8", "Peach"}, {"#d8cbe0", "Lavender"},
2592	},
2593}
2594
2595// colorWeights is how often each palette entry is drawn at mint, in
2596// parts per 100 by palette index. Diamond and Quartz are weighted, in the order
2597// of real-world rarity: colourless and the yellows and browns are what
2598// the mines mostly give, pink/blue/green are rare, red is the rarest
2599// diamond there is. Every other stone draws its colours evenly. A colour
2600// appended later (AddStoneColor) with no weight here gets
2601// defaultColorWeight.
2602var colorWeights = map[string][]int{
2603	//            Colorless Yellow Pink Blue Green Champagne Cognac Black Red Orange Purple
2604	"Diamond": {30, 18, 5, 4, 3, 14, 12, 8, 1, 3, 2},
2605	// Rock Crystal and Amethyst are the commonest quartz, Citrine and Smoky
2606	// next, Rose Quartz is rarely clear enough to facet, Prasiolite rare.
2607	"Quartz": {25, 25, 18, 9, 18, 5},
2608}
2609
2610const defaultColorWeight = 5
2611
2612func colorWeight(stone string, idx int) int {
2613	w, ok := colorWeights[stone]
2614	if !ok {
2615		return 1
2616	}
2617	if idx < len(w) {
2618		return w[idx]
2619	}
2620	return defaultColorWeight
2621}
2622
2623// PickColorIndex maps a uniform random roll to a palette index for stone,
2624// honouring colorWeights. The mint realm calls this instead of an even
2625// intn(ColorPaletteSize(stone)).
2626func PickColorIndex(stone string, roll uint64) int {
2627	n := ColorPaletteSize(stone)
2628	total := 0
2629	for i := 0; i < n; i++ {
2630		total += colorWeight(stone, i)
2631	}
2632	r := int(roll % uint64(total))
2633	for i := 0; i < n; i++ {
2634		r -= colorWeight(stone, i)
2635		if r < 0 {
2636			return i
2637		}
2638	}
2639	return n - 1
2640}
2641
2642// ColorProbability is the chance (0..1) that a mint of stone draws colour
2643// idx -- the same weights PickColorIndex uses. The mint realm multiplies
2644// it into a piece's Rarity tier.
2645func ColorProbability(stone string, idx int) float64 {
2646	n := ColorPaletteSize(stone)
2647	total := 0
2648	for i := 0; i < n; i++ {
2649		total += colorWeight(stone, i)
2650	}
2651	return float64(colorWeight(stone, idx)) / float64(total)
2652}
2653
2654// ColorRarityCSV lists "name:percent" for every colour of stone, e.g. for
2655// a mint page's odds table. Percent has one decimal.
2656func ColorRarityCSV(stone string) string {
2657	n := ColorPaletteSize(stone)
2658	total := 0
2659	for i := 0; i < n; i++ {
2660		total += colorWeight(stone, i)
2661	}
2662	out := make([]string, n)
2663	for i := 0; i < n; i++ {
2664		pm := colorWeight(stone, i) * 1000 / total
2665		out[i] = colorForStone(stone, i).name + ":" + ufmt.Sprintf("%d.%d", pm/10, pm%10)
2666	}
2667	return strings.Join(out, ";")
2668}
2669
2670// ChainAlwaysActive: mainnet never sunsets, so a GNOLAND-1 piece always
2671// shows the green active light. RenderTokenSVG forces it; the mint realm
2672// should record Active=true for it too so the metadata agrees.
2673func ChainAlwaysActive(chain string) bool { return chain == "GNOLAND-1" }
2674
2675// stoneNames tracks insertion order for deterministic listing (Go/Gno
2676// map iteration order is randomized) -- kept in sync with stonePalettes
2677// by registerStoneName, called from both AddStoneColor and
2678// SetStoneForChain (nftminter.gno).
2679var stoneNames = []string{"Sapphire", "Topaz", "Pearl", "Diamond", "Quartz"}
2680
2681func registerStoneName(stone string) {
2682	for _, s := range stoneNames {
2683		if s == stone {
2684			return
2685		}
2686	}
2687	stoneNames = append(stoneNames, stone)
2688}
2689
2690// SetStoneForChain and AddStoneColor live here (not in nftminter.gno's own
2691// wrapper functions of the same purpose) because they mutate this
2692// package's own private maps -- stoneByChain/stonePalettes/stoneNames
2693// aren't visible outside this package once gemsart is its own separate
2694// deploy, unlike when this was one combined realm. nftminter.gno's
2695// SetStoneForChain/AddStoneColor now just assertOwner() then delegate here.
2696func SetStoneForChain(chainName, stone string) {
2697	stoneByChain[chainName] = stone
2698	registerStoneName(stone)
2699}
2700
2701func AddStoneColor(stone, hex, name string) {
2702	stonePalettes[stone] = append(stonePalettes[stone], namedColor{hex: hex, name: name})
2703	registerStoneName(stone)
2704}
2705
2706// StoneNamesCSV lists every registered stone name, semicolon-separated,
2707// in the order each was first introduced.
2708func StoneNamesCSV() string {
2709	return strings.Join(stoneNames, ";")
2710}
2711
2712// ColorPaletteCSV lists stone's full color palette as "hex:name;...", in
2713// colorIdx order. Implemented here rather than in nftminter.gno since it
2714// needs namedColor's own unexported hex/name fields.
2715func ColorPaletteCSV(stone string) string {
2716	palette := stonePalettes[stone]
2717	pairs := make([]string, len(palette))
2718	for i, c := range palette {
2719		pairs[i] = c.hex + ":" + c.name
2720	}
2721	return strings.Join(pairs, ";")
2722}
2723
2724// MetalToneHex reports metalName's hi/lo gradient tones as "hi;lo", or ""
2725// if unrecognized. Implemented here for the same reason as
2726// ColorPaletteCSV -- needs metalTone's own unexported hi/lo fields.
2727func MetalToneHex(metalName string) string {
2728	t, ok := metals[metalName]
2729	if !ok {
2730		return ""
2731	}
2732	return t.hi + ";" + t.lo
2733}
2734
2735// StoneForChain: only gno.land testnet generations that were actually
2736// given a gemstone codename (Topaz-1, Sapphire-1 at launch) get one —
2737// every other/future chain is "Not Set" rather than a guessed mineral
2738// name, unless the owner explicitly assigns one via SetStoneForChain.
2739func StoneForChain(chain string) string {
2740	if s, ok := stoneByChain[chain]; ok {
2741		return s
2742	}
2743	return "Quartz"
2744}
2745
2746// colorForStone looks up colorIdx in the palette matching stone,
2747// clamping out-of-range indices instead of panicking — AddTraitPreset
2748// validates at write time, this is just defense in depth for read time.
2749// A stone with no palette yet (registered via SetStoneForChain but
2750// never given a color via AddStoneColor) falls back to "Not Set"'s
2751// palette rather than indexing into an empty slice.
2752func colorForStone(stone string, colorIdx int) namedColor {
2753	palette := stonePalettes[stone]
2754	if len(palette) == 0 {
2755		palette = stonePalettes["Quartz"]
2756	}
2757	if colorIdx < 0 {
2758		colorIdx = 0
2759	}
2760	if colorIdx >= len(palette) {
2761		colorIdx = len(palette) - 1
2762	}
2763	return palette[colorIdx]
2764}
2765
2766// ColorPaletteSize reports how many colors are valid for stone, so
2767// AddTraitPreset can validate a caller-supplied colorIdx. Same
2768// empty-palette fallback as colorForStone, for the same reason.
2769func ColorPaletteSize(stone string) int {
2770	if n := len(stonePalettes[stone]); n > 0 {
2771		return n
2772	}
2773	return len(stonePalettes["Quartz"])
2774}
2775
2776// FindShapeIndex reverse-maps a rendered "Shape" trait value (e.g.
2777// "Oval") back to its index into ShapeNames. A source realm for a
2778// migration only ever exposes the rendered trait STRING (via
2779// CollectionSummary), never the raw index a migrated token would need
2780// to regenerate its own art instead of storing a full copy of it — this
2781// closes that gap for the migration tooling (see
2782// AddMigrationSnapshotEntry). ok is false if name doesn't match any
2783// current shape (not expected for anything this realm's own gems.gno
2784// produced, but a caller should treat that as "unrecognized," not guess).
2785// FindShapeIndex takes stone (from StoneForChain) since Pearl draws
2786// shapeIdx from a completely different list (PearlShapeNames, not
2787// ShapeNames) -- a rendered "Baroque" only resolves against the right
2788// one.
2789func FindShapeIndex(stone, name string) (idx int, ok bool) {
2790	names := ShapeNames
2791	if stone == "Pearl" {
2792		names = PearlShapeNames
2793	}
2794	for i, s := range names {
2795		if s == name {
2796			return i, true
2797		}
2798	}
2799	return -1, false
2800}
2801
2802// FindColorIndex reverse-maps a rendered "Color" trait value back to
2803// its index in chain's own stone palette — colorIdx is only meaningful
2804// relative to a specific stone (derived from chain via StoneForChain),
2805// not a single global color list, so this needs the chain too. Same
2806// migration-tooling purpose as FindShapeIndex.
2807func FindColorIndex(chain, colorName string) (idx int, ok bool) {
2808	stone := StoneForChain(chain)
2809	palette := stonePalettes[stone]
2810	if len(palette) == 0 {
2811		palette = stonePalettes["Quartz"]
2812	}
2813	for i, c := range palette {
2814		if c.name == colorName {
2815			return i, true
2816		}
2817	}
2818	return -1, false
2819}
2820
2821type metalTone struct{ hi, lo string }
2822
2823// metals holds each metal's ring/label gradient tones. A var rather than
2824// a const purely because Gno doesn't allow map literals as consts, not
2825// because it's meant to be mutable -- no exported function reassigns it
2826// (nftminter.gno's MetalToneHex is read-only), so a minted piece's ring
2827// color is fixed the moment it's revealed, same as every other trait.
2828// Gold/Silver/Bronze are two darkening
2829// passes down from their original brighter values; Platinum stays at
2830// ONE pass (not two) and Obsidian is unchanged. Verified byte-for-byte
2831// against all 100 pieces of the confirmed "Latest iteration, all
2832// hundred (mask applied)" reference batch -- every non-Platinum ring
2833// matched the two-pass tone exactly, but every Platinum ring in that
2834// same confirmed batch used the one-pass tone, not two; a later commit
2835// had mistakenly carried Platinum through the second darkening pass
2836// too. Reverted just that one entry to match the confirmed reference.
2837var metals = map[string]metalTone{
2838	"Gold":     {"#a48b3f", "#6a5117"},
2839	"Silver":   {"#95989c", "#5e646c"},
2840	"Bronze":   {"#865b34", "#52391d"},
2841	"Platinum": {"#c3c6c8", "#87909a"},
2842	"Obsidian": {"#3a3d47", "#0d0e12"},
2843}
2844
2845// MetalNames is an ordered counterpart to metals -- Go/Gno map iteration
2846// order is randomized, so on-the-fly random trait assembly (MintAssembled,
2847// nftminter.gno) needs an indexable slice to pick a random metal from.
2848var MetalNames = []string{"Gold", "Silver", "Bronze", "Platinum", "Obsidian"}
2849
2850type metalLabelTone struct{ bg, text string }
2851
2852var metalLabel = map[string]metalLabelTone{
2853	"Gold":     {"#f4d060", "#04121a"},
2854	"Silver":   {"#dfe4ea", "#04121a"},
2855	"Bronze":   {"#c9884f", "#04121a"},
2856	"Platinum": {"#eef2f5", "#04121a"},
2857	"Obsidian": {"#5a5f6b", "#e8e9f0"},
2858}
2859
2860/* --------------------------------- full assembly --------------------------------- */
2861
2862// RenderTokenSVG assembles one token's complete SVG from its small trait
2863// selection, mirroring badge() in the Python prototype exactly (same
2864// element order, same layout constants). `salt` seeds every per-piece
2865// animation timing and every gradient id — the token's own real ID
2866// (tidStr), so nothing here is computed before mint assigns one.
2867// RenderTokenThumbSVG is RenderTokenSVG for a list thumbnail (a gnoweb
2868// collection table row): the same piece, minus the fire flashes, the rock
2869// relighting and the table reflections, which do not read at thumbnail
2870// size, for roughly a third less gas, so a page fits more rows under a
2871// query's gas ceiling.
2872func RenderTokenThumbSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) string {
2873	svg, _, _, _ := renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, true)
2874	return svg
2875}
2876
2877func RenderTokenSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) (svg string, stone string, colorHex string, colorName string) {
2878	return renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, false)
2879}
2880
2881func renderToken(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string, thumb bool) (svg string, stone string, colorHex string, colorName string) {
2882	var cascadeCSS string
2883	stone = StoneForChain(chain)
2884	if ChainAlwaysActive(chain) {
2885		active = true
2886	}
2887	nc := colorForStone(stone, colorIdx)
2888	colorHex, colorName = nc.hex, nc.name
2889	mt := metals[metal]
2890
2891	ringEls := renderRing(mt.hi, mt.lo, tidStr)
2892	// colorHex is already the boosted value -- stonePalettes' own hex
2893	// constants ARE the s=1.5 saturate output (see stonePalettes' comment
2894	// for the formula, and for which entry is deliberately excluded: Red).
2895	//
2896	// Pearl draws from renderPearlShape (smooth luster gradient, no
2897	// facets) instead of the faceted pipeline -- a real pearl is never
2898	// cut. applyCascade still runs on it rather than being skipped:
2899	// scanFacetSpans finds no class="f" facets in pearl markup and
2900	// returns nothing, so it stays a no-op there without its own branch.
2901	//
2902	// Faceted stones stack, bottom to top: body glow (silhouette), the
2903	// facets themselves (translucent solid fills at varying opacity, so
2904	// the glow shows through some more than others), the cascade's flash
2905	// overlays, then one faint rim stroke. A caustic pool under the stone
2906	// and a pavilion silhouette behind it were tried and dropped: on a
2907	// flat dark ground they read as a second layer, not as depth. All static gradients and paths -- no <filter>, no mask.
2908	// An earlier iteration also drew hairline facet edges and two white
2909	// specular hot spots; both read as cartoonish and were dropped.
2910	var gemMarkup string
2911	if stone == "Pearl" {
2912		pearl := renderPearlShape(shapeIdx, colorHex, mt.hi, tidStr)
2913		overlay, css := applyCascade(pearl, tidStr, nil)
2914		gemMarkup, cascadeCSS = pearl+overlay, css
2915	} else {
2916		fs := &facetStyle{prof: fireProfileFor(stone, colorName), zone: newPRNG("zone" + tidStr), stone: stone, thumb: thumb}
2917		under, facets, over := renderGem3D(shapeIdx, colorHex, tidStr, fs)
2918		overlay, css := applyCascade(facets, tidStr, fs)
2919		cascadeCSS = css
2920		gemMarkup = under + `<g fill-opacity=".9">` + facets + `</g>` + overlay + over + fs.glints
2921	}
2922	floatingGem := applyMotion(gemMarkup, tidStr)
2923	status := renderStatusBadge(active)
2924	serial := renderSerialText(tidStr, mt.hi)
2925	labelText := metalLabel[metal].text
2926	labelRect := renderMetalLabelRect(mt.hi, mt.lo, 24, 134, 122, 15, 3, tidStr)
2927
2928	inner := `<rect width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" fill="#0a0b10"/>` +
2929		renderStars(tidStr) + setRing(ringEls) + setStone(floatingGem) + setBadge(status) + serial + labelRect +
2930		renderPlateText(chain+" CHAIN", labelText, mt.hi)
2931
2932	// width/height on the root <svg>, not just viewBox -- without them the
2933	// image has no definite intrinsic size as an <img> resource, and
2934	// gnoweb's own CSS (anchors wrapping embedded thumbnails are
2935	// `display: inline-block; text-wrap: balance`) collapses an
2936	// ambiguous-size image down to 2x2px. Confirmed live against a real
2937	// deployed collection (gno-tools' nft-minter tool hit the exact same
2938	// bug); see ~/gno-land-dev-notes.md.
2939	// cascadeCSS is a second, separate <style> block rather than folded
2940	// into styleBlock() -- every rule in it (keyframe names, timings) is
2941	// unique to THIS token, unlike styleBlock()'s shared, fixed rules
2942	// reused across every token that's ever rendered.
2943	svg = `<svg xmlns="http://www.w3.org/2000/svg" width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" viewBox="0 0 ` + fnum(canvasW) + ` ` + fnum(canvasH) + `">` + styleBlock() +
2944		`<style>` + cascadeCSS + `</style>` + inner + `</svg>`
2945	return svg, stone, colorHex, colorName
2946}
2947
2948// styleBlock is embedded once per token's own SVG (a real NFT image is a
2949// fully self-contained document -- this can't live in a page-level
2950// stylesheet). Drives every animated piece via CSS @keyframes instead
2951// of SMIL <animate>/<animateTransform>: each token's own randomized
2952// timing/angles are set as custom properties in a small inline style=""
2953// per element, against these shared, fixed keyframe rules.
2954func styleBlock() string {
2955	const easeOut = "cubic-bezier(0,0,.3,1)"
2956	// Compact form (gemsart3d2): shared selectors, animation shorthand with
2957	// the delay inline, ease-in-out in place of the near-identical
2958	// cubic-bezier(.45,0,.55,1). Same motion, ~40% fewer bytes. The
2959	// will-change hint stays: it mattered for Safari.
2960	return `<style>.gt-v,.gt-h,.gt-r,.gt-s{will-change:transform}` +
2961
2962		`.gt-v{animation:gt-v var(--v-dur) ease-in-out var(--v-delay) infinite}` +
2963		`.gt-h{animation:gt-h var(--h-dur) ease-in-out var(--h-delay) infinite}` +
2964		`.gt-r{animation:gt-r var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2965		`.gt-s{animation:gt-s var(--s-dur) ease-in-out var(--s-delay) infinite}` +
2966		`.gt-glow{animation:gt-glow var(--glow-dur) ease-in-out var(--glow-delay) infinite}` +
2967		// rock-synced lighting: same duration, delay and easing as .gt-r,
2968		// peaking at its two extremes (25% and 75%)
2969		`.gt-la{animation:gt-la var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2970		`.gt-lb{animation:gt-lb var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2971		`@keyframes gt-la{0%,50%,100%{opacity:0}25%{opacity:1}}` +
2972		`@keyframes gt-lb{0%,50%,100%{opacity:0}75%{opacity:1}}` +
2973		`.gt-badge-pulse{animation:gt-bp 1.8s ` + easeOut + ` infinite}` +
2974		// the float is balanced around the ring centre (+2.5 to -2.5); it
2975		// used to rise 0 to -5 only, which sat every stone 2.5 high on average
2976		`@keyframes gt-v{0%,100%{transform:translateY(2.5px)}50%{transform:translateY(-2.5px)}}` +
2977		`@keyframes gt-h{50%{transform:translateX(1.8px)}}` +
2978		`@keyframes gt-r{25%{transform:rotate(calc(-1*var(--rot-deg)))}75%{transform:rotate(var(--rot-deg))}}` +
2979		`@keyframes gt-s{50%{transform:scale(` + fnum(motionScale) + `)}}` +
2980		// a twinkle is a brief flare from the star's resting brightness,
2981		// once per its own period (see renderStars)
2982		`.gt-tw{animation:gt-tw var(--d) ease-in-out var(--dl) infinite}@keyframes gt-tw{0%,84%,100%{opacity:.4}90%{opacity:1}94%{opacity:.2}}` +
2983		`@keyframes gt-glow{50%{opacity:1}}` +
2984		`@keyframes gt-bp{from{transform:scale(1);opacity:.8}to{transform:scale(2.25);opacity:0}}` +
2985		`</style>`
2986}