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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}