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.

art.gno

61.41 Kb · 2096 lines
   1package art3d
   2
   3import (
   4	"crypto/sha256"
   5	"math"
   6	"strings"
   7
   8	"gno.land/p/nt/ufmt/v0"
   9)
  10
  11const (
  12	canvasW, canvasH = 170.0, 170.0
  13	subjX, subjY     = 85.0, 78.0
  14)
  15
  16func itoa(v int64) string {
  17	if v == 0 {
  18		return "0"
  19	}
  20	neg := v < 0
  21	if neg {
  22		v = -v
  23	}
  24	var b [20]byte
  25	i := len(b)
  26	for v > 0 {
  27		i--
  28		b[i] = byte('0' + v%10)
  29		v /= 10
  30	}
  31	if neg {
  32		i--
  33		b[i] = '-'
  34	}
  35	return string(b[i:])
  36}
  37
  38func fnum(v float64) string { return cnum(v) }
  39
  40func fnumPad(v float64) string {
  41	neg := v < 0
  42	if neg {
  43		v = -v
  44	}
  45	scaled := int64(v*100 + 0.5)
  46	whole := scaled / 100
  47	frac := scaled % 100
  48	fracStr := itoa(frac)
  49	if frac < 10 {
  50		fracStr = "0" + fracStr
  51	}
  52	s := itoa(whole) + "." + fracStr
  53	if neg {
  54		return "-" + s
  55	}
  56	return s
  57}
  58
  59func cnum(v float64) string {
  60	s := fnumPad(v)
  61	if strings.Contains(s, ".") {
  62		s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
  63	}
  64	if strings.HasPrefix(s, "0.") {
  65		s = s[1:]
  66	} else if strings.HasPrefix(s, "-0.") {
  67		s = "-" + s[2:]
  68	}
  69	return s
  70}
  71
  72func fnum1(v float64) string {
  73	neg := v < 0
  74	if neg {
  75		v = -v
  76	}
  77	scaled := int64(v*10 + 0.5)
  78	s := itoa(scaled/10) + "." + itoa(scaled%10)
  79	if neg {
  80		return "-" + s
  81	}
  82	return s
  83}
  84
  85func pctnum(v float64) string { return fnum(v) }
  86
  87func hexToRGB(hex string) (int, int, int) {
  88	h := hex
  89	if len(h) > 0 && h[0] == '#' {
  90		h = h[1:]
  91	}
  92	r := hexByte(h[0:2])
  93	g := hexByte(h[2:4])
  94	b := hexByte(h[4:6])
  95	return r, g, b
  96}
  97
  98func hexByte(s string) int {
  99	v := 0
 100	for i := 0; i < len(s); i++ {
 101		c := s[i]
 102		var d int
 103		switch {
 104		case c >= '0' && c <= '9':
 105			d = int(c - '0')
 106		case c >= 'a' && c <= 'f':
 107			d = int(c-'a') + 10
 108		case c >= 'A' && c <= 'F':
 109			d = int(c-'A') + 10
 110		}
 111		v = v*16 + d
 112	}
 113	return v
 114}
 115
 116func rgbToHex(r, g, b int) string {
 117	return "#" + hexDigit2(r) + hexDigit2(g) + hexDigit2(b)
 118}
 119
 120func hexDigit2(v int) string {
 121	if v < 0 {
 122		v = 0
 123	}
 124	if v > 255 {
 125		v = 255
 126	}
 127	const digits = "0123456789abcdef"
 128	return string(digits[v/16]) + string(digits[v%16])
 129}
 130
 131func clampByte(v float64) int {
 132	iv := int(v)
 133	if iv < 0 {
 134		return 0
 135	}
 136	if iv > 255 {
 137		return 255
 138	}
 139	return iv
 140}
 141
 142func lighten(hex string, amt float64) string {
 143	r, g, b := hexToRGB(hex)
 144	nr := clampByte(float64(r) + (255-float64(r))*amt)
 145	ng := clampByte(float64(g) + (255-float64(g))*amt)
 146	nb := clampByte(float64(b) + (255-float64(b))*amt)
 147	return rgbToHex(nr, ng, nb)
 148}
 149
 150func mixHex(hex, target string, amt float64) string {
 151	r1, g1, b1 := hexToRGB(hex)
 152	r2, g2, b2 := hexToRGB(target)
 153	nr := clampByte(float64(r1) + float64(r2-r1)*amt)
 154	ng := clampByte(float64(g1) + float64(g2-g1)*amt)
 155	nb := clampByte(float64(b1) + float64(b2-b1)*amt)
 156	return rgbToHex(nr, ng, nb)
 157}
 158
 159func hueRotate(hex string, deg float64) string {
 160	c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)
 161	r, g, b := hexToRGB(hex)
 162	fr, fg, fb := float64(r), float64(g), float64(b)
 163	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
 164	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
 165	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
 166	return rgbToHex(clampByte(nr), clampByte(ng), clampByte(nb))
 167}
 168
 169func shadeSeries(hex string, n int, lo, hi float64) []string {
 170	r, g, b := hexToRGB(hex)
 171	out := make([]string, n)
 172	for i := 0; i < n; i++ {
 173		t := lo
 174		if n > 1 {
 175			t = lo + (hi-lo)*float64(i)/float64(n-1)
 176		}
 177		out[i] = rgbToHex(clampByte(float64(r)*t), clampByte(float64(g)*t), clampByte(float64(b)*t))
 178	}
 179	return out
 180}
 181
 182type prng struct {
 183	seed string
 184	n    int
 185}
 186
 187func newPRNG(seed string) *prng { return &prng{seed: seed} }
 188
 189func (p *prng) next() uint64 {
 190	p.n++
 191	material := p.seed + ":" + itoa(int64(p.n)) // same bytes as "%s:%d"
 192	sum := sha256.Sum256([]byte(material))
 193	var v uint64
 194	for i := 0; i < 8; i++ {
 195		v = v<<8 | uint64(sum[i])
 196	}
 197	return v
 198}
 199
 200func (p *prng) floatIn(lo, hi float64) float64 {
 201	frac := float64(p.next()%1000000) / 1000000.0
 202	return lo + frac*(hi-lo)
 203}
 204
 205func (p *prng) intn(n int) int {
 206	if n <= 1 {
 207		return 0
 208	}
 209	return int(p.next() % uint64(n))
 210}
 211
 212type pt struct{ x, y float64 }
 213
 214func polyPath(pts []pt) string {
 215	s := "M"
 216	for i, p := range pts {
 217		if i > 0 {
 218			s += " L"
 219		}
 220		s += fnum(p.x) + "," + fnum(p.y)
 221	}
 222	return s + " Z"
 223}
 224
 225func polygonPoints(pts []pt) string {
 226	s := ""
 227	for i, p := range pts {
 228		if i > 0 {
 229			s += " "
 230		}
 231		s += fnum(p.x) + "," + fnum(p.y)
 232	}
 233	return s
 234}
 235
 236type gstop struct {
 237	pct   float64
 238	color string
 239	op    float64
 240}
 241
 242type gramp struct {
 243	x1, y1, x2, y2 float64
 244	stops          []gstop
 245}
 246
 247func (g gramp) sample(px, py float64) (string, float64) {
 248	dx, dy := g.x2-g.x1, g.y2-g.y1
 249	t := ((px-g.x1)*dx + (py-g.y1)*dy) / (dx*dx + dy*dy) * 100
 250	if t < 0 {
 251		t = 0
 252	}
 253	if t > 100 {
 254		t = 100
 255	}
 256	for i := 1; i < len(g.stops); i++ {
 257		if t <= g.stops[i].pct {
 258			a, b := g.stops[i-1], g.stops[i]
 259			f := (t - a.pct) / (b.pct - a.pct)
 260			return mixHex(a.color, b.color, f), a.op + (b.op-a.op)*f
 261		}
 262	}
 263	last := g.stops[len(g.stops)-1]
 264	return last.color, last.op
 265}
 266
 267func centroid(poly []pt) (float64, float64) {
 268	cx, cy := 0.0, 0.0
 269	for _, p := range poly {
 270		cx += p.x
 271		cy += p.y
 272	}
 273	return cx / float64(len(poly)), cy / float64(len(poly))
 274}
 275
 276type facetStyle struct {
 277	flash    gramp
 278	ringCent []pt
 279	prof     fireProfile
 280	zone     *prng // per-facet hue drift and shade jitter, seeded per token
 281	stone    string // which crystal habit a Raw Crystal takes
 282	thumb bool
 283}
 284
 285type fireProfile struct {
 286	sat, hit  float64
 287	pinpoints int
 288	shadow    string
 289	bodyMix   float64
 290	neutral   string
 291}
 292
 293func fireProfileFor(stone, colorName string) fireProfile {
 294	if stone == "Diamond" {
 295		switch colorName {
 296		case "Colorless":
 297			return fireProfile{1.0, 0.85, 5, "#0d1222", 0.12, "#f4f7fc"}
 298		case "Black":
 299			return fireProfile{0.15, 0.35, 1, "#000000", 0, ""}
 300		}
 301		return fireProfile{0.95, 0.7, 4, "#0b0d18", 0, ""}
 302	}
 303	if stone == "Quartz" {
 304		if colorName == "Rock Crystal" {
 305			return fireProfile{0.4, 0.6, 3, "#141a2c", 0.25, "#e8edf4"}
 306		}
 307		return fireProfile{0.35, 0.5, 3, "#000000", 0, ""}
 308	}
 309	if colorName == "Colorless" {
 310		return fireProfile{1.0, 0.7, 4, "#141a2c", 0.55, "#dfe4ee"}
 311	}
 312	if colorName == "White" {
 313		return fireProfile{0.85, 0.6, 3, "#7d86a0", 0, ""}
 314	}
 315	if colorName == "Crystal" {
 316		return fireProfile{1.0, 0.78, 4, "#141a2c", 0.3, "#e4e9f2"}
 317	}
 318	if stone == "Topaz" {
 319		return fireProfile{0.5, 0.5, 3, "#000000", 0, ""}
 320	}
 321	return fireProfile{0.7, 0.5, 3, "#000000", 0, ""}
 322}
 323
 324const fireZoneDeg = 7.0
 325
 326type rgbI struct{ r, g, b int }
 327
 328func toRGBI(hex string) rgbI { r, g, b := hexToRGB(hex); return rgbI{r, g, b} }
 329
 330func (c rgbI) hex() string { return rgbToHex(c.r, c.g, c.b) }
 331
 332func mixI(a, b rgbI, amt float64) rgbI {
 333	return rgbI{
 334		clampByte(float64(a.r) + float64(b.r-a.r)*amt),
 335		clampByte(float64(a.g) + float64(b.g-a.g)*amt),
 336		clampByte(float64(a.b) + float64(b.b-a.b)*amt),
 337	}
 338}
 339
 340func hueRotateI(x rgbI, deg float64) rgbI {
 341	c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)
 342	fr, fg, fb := float64(x.r), float64(x.g), float64(x.b)
 343	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
 344	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
 345	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
 346	return rgbI{clampByte(nr), clampByte(ng), clampByte(nb)}
 347}
 348
 349func hex3I(x rgbI) string {
 350	d := "0123456789abcdef"
 351	q := func(v int) byte { return d[(v+8)*15/255%16] }
 352	return "#" + string([]byte{q(x.r), q(x.g), q(x.b)})
 353}
 354
 355var whiteI = rgbI{255, 255, 255}
 356
 357func darken(hex, shadow string, amt float64) string {
 358	if amt > 0.95 {
 359		amt = 0.95
 360	}
 361	return mixHex(hex, shadow, amt)
 362}
 363
 364const prismFlash = true
 365
 366func flashRamp(ext, sat float64) gramp {
 367	r := gramp{x1: subjX - ext, y1: subjY - ext, x2: subjX + ext, y2: subjY + ext}
 368	if !prismFlash {
 369		r.stops = []gstop{{0, "#ffffff", 1}, {100, "#ffffff", 0.25}}
 370		return r
 371	}
 372	r.stops = []gstop{
 373		{0, "#ffffff", 1}, {14, "#ff4040", 0.95}, {28, "#ff9a1f", 0.95}, {42, "#ffe81f", 0.9},
 374		{56, "#35ff7a", 0.85}, {70, "#22ccff", 0.8}, {84, "#5566ff", 0.75}, {100, "#c04dff", 0.7},
 375	}
 376	for i := range r.stops {
 377		r.stops[i].color = mixHex(r.stops[i].color, "#ffffff", 1-sat)
 378	}
 379	return r
 380}
 381
 382type v3 struct{ x, y, z float64 }
 383
 384func vsub(a, b v3) v3       { return v3{a.x - b.x, a.y - b.y, a.z - b.z} }
 385func vdot(a, b v3) float64  { return a.x*b.x + a.y*b.y + a.z*b.z }
 386func 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} }
 387func vscale(a v3, k float64) v3 { return v3{a.x * k, a.y * k, a.z * k} }
 388func vnorm(a v3) v3 {
 389	l := math.Sqrt(vdot(a, a))
 390	if l == 0 {
 391		return a
 392	}
 393	return v3{a.x / l, a.y / l, a.z / l}
 394}
 395
 396func sgnPow(v, e float64) float64 {
 397	if v < 0 {
 398		return -math.Pow(-v, e)
 399	}
 400	return math.Pow(v, e)
 401}
 402
 403type outlineFn func(t float64) (float64, float64)
 404
 405func circleOutline(t float64) (float64, float64) {
 406	a := 2 * math.Pi * t
 407	return math.Cos(a), math.Sin(a)
 408}
 409
 410func superOutline(p float64) outlineFn {
 411	return func(t float64) (float64, float64) {
 412		a := 2 * math.Pi * t
 413		return sgnPow(math.Cos(a), 2/p), sgnPow(math.Sin(a), 2/p)
 414	}
 415}
 416
 417func polyOutline(vs []pt) outlineFn {
 418	return func(t float64) (float64, float64) {
 419		n := len(vs)
 420		f := t * float64(n)
 421		k := int(math.Floor(f))
 422		fr := f - float64(k)
 423		a := vs[((k%n)+n)%n]
 424		b := vs[(((k+1)%n)+n)%n]
 425		return a.x + (b.x-a.x)*fr, a.y + (b.y-a.y)*fr
 426	}
 427}
 428
 429func octagon(hw, hh, c float64) []pt {
 430	return []pt{
 431		{hw - c, hh}, {-hw + c, hh}, {-hw, hh - c}, {-hw, -hh + c},
 432		{-hw + c, -hh}, {hw - c, -hh}, {hw, -hh + c}, {hw, hh - c},
 433	}
 434}
 435
 436func pearOutline(t float64) (float64, float64) {
 437	a := 2 * math.Pi * t
 438	c := math.Cos(a)
 439	return c*0.92 + 0.08, 0.7 * math.Sin(a) * (1 - 0.46*c)
 440}
 441
 442func marquiseOutline(t float64) (float64, float64) {
 443	a := 2 * math.Pi * t
 444	return math.Cos(a), 0.52 * sgnPow(math.Sin(a), 1.35)
 445}
 446
 447func heartOutline(t float64) (float64, float64) {
 448	a := 2 * math.Pi * t
 449	sn := math.Sin(a)
 450	y := (13*math.Cos(a) - 4*math.Cos(2*a) - 1.4*math.Cos(3*a) - 0.5*math.Cos(4*a)) / 17
 451	return 0.88*sn*sn*sn + 0.12*sn, (y + 0.1576) / 0.7894
 452}
 453
 454func trillionOutline(t float64) (float64, float64) {
 455	vs := []pt{{0, 1}, {-0.866, -0.5}, {0.866, -0.5}}
 456	f := t * 3
 457	k := int(math.Floor(f))
 458	fr := f - float64(k)
 459	a, b := vs[((k%3)+3)%3], vs[(((k+1)%3)+3)%3]
 460	bulge := 1 + 0.16*math.Sin(math.Pi*fr)
 461	return (a.x + (b.x-a.x)*fr) * bulge, (a.y + (b.y-a.y)*fr) * bulge
 462}
 463
 464type cutSpec struct {
 465	n         int
 466	brilliant bool
 467	outline   outlineFn
 468	rx, ry    float64
 469}
 470
 471var ShapeNames = []string{
 472	"Round Brilliant", "Oval", "Cushion", "Emerald Cut", "Asscher",
 473	"Radiant", "Princess Cut", "Pear", "Marquise", "Trillion",
 474	"Heart", "Rose Cut", "Raw Crystal",
 475}
 476
 477const (
 478	shapeHeart = 10
 479	shapeRose  = 11
 480	shapeRaw   = 12
 481)
 482
 483const stoneScale = 0.78
 484
 485func cutFor(shapeIdx int) cutSpec {
 486	c := cutForUnscaled(shapeIdx)
 487	c.rx, c.ry = c.rx*stoneScale, c.ry*stoneScale
 488	return c
 489}
 490
 491func cutForUnscaled(shapeIdx int) cutSpec {
 492	switch shapeIdx {
 493	case 1:
 494		return cutSpec{8, true, circleOutline, 30, 21.5}
 495	case 2:
 496		return cutSpec{8, true, superOutline(3.4), 27, 24}
 497	case 3:
 498		return cutSpec{8, false, polyOutline(octagon(1, 1, 0.3)), 30, 21}
 499	case 4:
 500		return cutSpec{8, false, polyOutline(octagon(1, 1, 0.36)), 25, 25}
 501	case 5:
 502		return cutSpec{8, true, polyOutline(octagon(1, 1, 0.3)), 29, 22.5}
 503	case 6:
 504		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}
 505	case 7:
 506		return cutSpec{8, true, pearOutline, 31, 27}
 507	case 8:
 508		return cutSpec{8, true, marquiseOutline, 33, 30}
 509	case 9:
 510		return cutSpec{9, true, trillionOutline, 27, 27}
 511	case shapeHeart:
 512		return cutSpec{10, true, heartOutline, 29, 28}
 513	case shapeRose:
 514		return cutSpec{6, true, circleOutline, 27, 27}
 515	case shapeRaw:
 516		return cutSpec{0, false, circleOutline, 27, 27} // sized per stone in buildRaw
 517	}
 518	return cutSpec{8, true, circleOutline, 27, 27}
 519}
 520
 521func CutsForStone(stone string) []int {
 522	switch stone {
 523	case "Diamond":
 524		return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}
 525	case "Sapphire":
 526		return []int{1, 2, 0, 3, 7, shapeHeart, 8, 9, 6, shapeRose, shapeRaw}
 527	case "Topaz":
 528		return []int{3, 1, 7, 2, 8, 0, 9, shapeHeart, 6, shapeRaw}
 529	}
 530	return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}
 531}
 532
 533const (
 534	kCrown = iota
 535	kTable
 536	kPav
 537)
 538
 539type face struct {
 540	p    []v3
 541	kind int
 542}
 543
 544func ringPts(c cutSpec, n int, scale, z, off float64) []v3 {
 545	out := make([]v3, n)
 546	for k := 0; k < n; k++ {
 547		x, y := c.outline((float64(k) + off) / float64(n))
 548		out[k] = v3{x * c.rx * scale, y * c.ry * scale, z}
 549	}
 550	return out
 551}
 552
 553func buildCut(c cutSpec) (faces []face, girdle []v3) {
 554	base := c.rx
 555	if c.ry < base {
 556		base = c.ry
 557	}
 558	hc, hp := 0.34*base, 0.9*base
 559	n := c.n
 560	if c.brilliant {
 561		T := ringPts(c, n, 0.56, hc, 0)
 562		B := ringPts(c, n, 0.8, hc*0.46, 0.5)
 563		G := ringPts(c, 2*n, 1, 0, 0)
 564		culet := v3{0, 0, -hp}
 565		faces = append(faces, face{T, kTable})
 566		for k := 0; k < n; k++ {
 567			k1 := (k + 1) % n
 568			faces = append(faces, face{[]v3{T[k], B[k], T[k1]}, kCrown})
 569			faces = append(faces, face{[]v3{T[k1], B[k], G[(2*k+2)%(2*n)], B[k1]}, kCrown})
 570			faces = append(faces, face{[]v3{B[k], G[2*k], G[2*k+1]}, kCrown})
 571			faces = append(faces, face{[]v3{B[k], G[2*k+1], G[(2*k+2)%(2*n)]}, kCrown})
 572			faces = append(faces, face{[]v3{G[2*k], G[2*k+1], G[(2*k+2)%(2*n)], culet}, kPav})
 573		}
 574		return faces, G
 575	}
 576	T := ringPts(c, n, 0.54, hc, 0)
 577	S := ringPts(c, n, 0.78, hc*0.5, 0)
 578	G := ringPts(c, n, 1, 0, 0)
 579	P1 := ringPts(c, n, 0.7, -hp*0.42, 0)
 580	P2 := ringPts(c, n, 0.38, -hp*0.78, 0)
 581	K := ringPts(c, n, 0.1, -hp, 0)
 582	faces = append(faces, face{T, kTable})
 583	rows := [][2][]v3{{T, S}, {S, G}}
 584	for _, r := range rows {
 585		for k := 0; k < n; k++ {
 586			k1 := (k + 1) % n
 587			faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kCrown})
 588		}
 589	}
 590	prow := [][2][]v3{{G, P1}, {P1, P2}, {P2, K}}
 591	for _, r := range prow {
 592		for k := 0; k < n; k++ {
 593			k1 := (k + 1) % n
 594			faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kPav})
 595		}
 596	}
 597	return faces, G
 598}
 599
 600func buildRose(c cutSpec) (faces []face, girdle []v3) {
 601	n := c.n
 602	base := math.Min(c.rx, c.ry)
 603	h := 0.62 * base
 604	apex := v3{0, 0, h}
 605	R := ringPts(c, n, 0.55, h*0.62, 0.5)
 606	G := ringPts(c, 2*n, 1, 0, 0)
 607	for k := 0; k < n; k++ {
 608		k1 := (k + 1) % n
 609		faces = append(faces, face{[]v3{apex, R[k], R[k1]}, kCrown})
 610		faces = append(faces, face{[]v3{G[2*k], G[2*k+1], R[k]}, kCrown})
 611		faces = append(faces, face{[]v3{G[2*k+1], G[(2*k+2)%(2*n)], R[k]}, kCrown})
 612		faces = append(faces, face{[]v3{R[k], G[(2*k+2)%(2*n)], R[k1]}, kCrown})
 613	}
 614	back := make([]v3, len(G))
 615	for i := range G {
 616		back[len(G)-1-i] = G[i]
 617	}
 618	faces = append(faces, face{back, kPav})
 619	return faces, G
 620}
 621
 622func ringX(n int, x, ry, rz, phase float64) []v3 {
 623	out := make([]v3, n)
 624	for k := 0; k < n; k++ {
 625		a := 2*math.Pi*(float64(k)+phase)/float64(n) + math.Pi/2
 626		out[k] = v3{x, ry * math.Cos(a), rz * math.Sin(a)}
 627	}
 628	return out
 629}
 630
 631func bandX(a, b []v3) []face {
 632	var fs []face
 633	for k := range a {
 634		k1 := (k + 1) % len(a)
 635		fs = append(fs, face{[]v3{a[k], a[k1], b[k1], b[k]}, kCrown})
 636	}
 637	return fs
 638}
 639
 640func capX(r []v3) face { return face{r, kCrown} }
 641
 642func turnFaces(fs []face, ax, az float64) []face {
 643	cx, sx, cz, sz := math.Cos(ax), math.Sin(ax), math.Cos(az), math.Sin(az)
 644	for i := range fs {
 645		fs[i].kind = kPav
 646		for j, p := range fs[i].p {
 647			y, z := p.y*cx-p.z*sx, p.y*sx+p.z*cx
 648			fs[i].p[j] = v3{p.x*cz - y*sz, p.x*sz + y*cz, z}
 649		}
 650	}
 651	return fs
 652}
 653
 654func buildRaw(stone string) (faces []face, girdle []v3) {
 655	deg := math.Pi / 180
 656	switch stone {
 657	case "Diamond":
 658		a, b, h := 25.0*stoneScale, 22.0*stoneScale, 23.0*stoneScale
 659		v := []v3{{a, 0, 0}, {0, b, 0}, {-a, 0, 0}, {0, -b, 0}}
 660		top, bot := v3{0, 0, h}, v3{0, 0, -h}
 661		for k := 0; k < 4; k++ {
 662			k1 := (k + 1) % 4
 663			faces = append(faces, face{[]v3{top, v[k], v[k1]}, kCrown})
 664			faces = append(faces, face{[]v3{bot, v[k1], v[k]}, kCrown})
 665		}
 666		return turnFaces(faces, -38*deg, 28*deg), nil
 667	case "Sapphire":
 668		L, R := 26.0*stoneScale, 16.0*stoneScale
 669		r0 := ringX(6, -L, R*0.42, R*0.42, 0)
 670		r1 := ringX(6, -L*0.3, R, R*0.94, 0)
 671		r2 := ringX(6, L*0.3, R, R*0.94, 0)
 672		r3 := ringX(6, L, R*0.42, R*0.42, 0)
 673		faces = append(faces, bandX(r0, r1)...)
 674		faces = append(faces, bandX(r1, r2)...)
 675		faces = append(faces, bandX(r2, r3)...)
 676		faces = append(faces, capX(r3))
 677		rev := make([]v3, 6)
 678		for i := range r0 {
 679			rev[5-i] = r0[i]
 680		}
 681		faces = append(faces, capX(rev))
 682		return turnFaces(faces, -30*deg, -14*deg), nil
 683	}
 684	if stone == "Quartz" {
 685		L, R := 27.0*stoneScale, 12.5*stoneScale
 686		r0 := ringX(6, -L, R*1.04, R, 0)
 687		r1 := ringX(6, L*0.4, R, R*0.96, 0)
 688		point := v3{L, 0, 0}
 689		faces = append(faces, bandX(r0, r1)...)
 690		for k := 0; k < 6; k++ {
 691			faces = append(faces, face{[]v3{r1[k], r1[(k+1)%6], point}, kCrown})
 692		}
 693		rev := make([]v3, 6)
 694		for i := range r0 {
 695			rev[5-i] = r0[i]
 696		}
 697		faces = append(faces, capX(rev))
 698		return turnFaces(faces, -32*deg, 16*deg), nil
 699	}
 700	L, ry, rz := 26.0*stoneScale, 16.0*stoneScale, 13.0*stoneScale
 701	r0 := ringX(8, -L, ry, rz, 0.5)
 702	r1 := ringX(8, L*0.5, ry, rz, 0.5)
 703	tip := ringX(8, L*0.82, ry*0.45, rz*0.45, 0.5)
 704	point := v3{L, 0, 0}
 705	faces = append(faces, bandX(r0, r1)...)
 706	faces = append(faces, bandX(r1, tip)...)
 707	for k := 0; k < 8; k++ {
 708		faces = append(faces, face{[]v3{tip[k], tip[(k+1)%8], point}, kCrown})
 709	}
 710	rev := make([]v3, 8)
 711	for i := range r0 {
 712		rev[7-i] = r0[i]
 713	}
 714	faces = append(faces, capX(rev))
 715	return turnFaces(faces, -30*deg, 12*deg), nil
 716}
 717
 718const viewTilt = 61.0
 719
 720const (
 721	pieceCY   = subjY + 1.5
 722)
 723
 724func n1(v float64) string {
 725	neg := v < 0
 726	if neg {
 727		v = -v
 728	}
 729	t := int64(v*10 + 0.5)
 730	w, f := t/10, t%10
 731	s := ""
 732	if f == 0 {
 733		s = itoa(w)
 734	} else if w == 0 {
 735		s = "." + string(byte('0'+f))
 736	} else {
 737		s = itoa(w) + "." + string(byte('0'+f))
 738	}
 739	if neg && t != 0 {
 740		return "-" + s
 741	}
 742	return s
 743}
 744
 745func relPath(ps []pt) string {
 746	s := "M" + n1(ps[0].x) + " " + n1(ps[0].y) + "l"
 747	px, py := math.Round(ps[0].x*10)/10, math.Round(ps[0].y*10)/10
 748	for i := 1; i < len(ps); i++ {
 749		qx, qy := math.Round(ps[i].x*10)/10, math.Round(ps[i].y*10)/10
 750		dx, dy := n1(qx-px), n1(qy-py)
 751		if i > 1 && dx[0] != '-' {
 752			s += " "
 753		}
 754		s += dx
 755		if dy[0] != '-' {
 756			s += " "
 757		}
 758		s += dy
 759		px, py = qx, qy
 760	}
 761	return s + "z"
 762}
 763
 764func hex3(h string) string {
 765	r, g, b := hexToRGB(h)
 766	d := "0123456789abcdef"
 767	q := func(v int) byte { return d[(v+8)*15/255%16] }
 768	return "#" + string([]byte{q(r), q(g), q(b)})
 769}
 770
 771func convexHull(ps []pt) []pt {
 772	n := len(ps)
 773	idx := make([]int, n)
 774	for i := range idx {
 775		idx[i] = i
 776	}
 777	less := func(a, b int) bool {
 778		return ps[a].x < ps[b].x || (ps[a].x == ps[b].x && ps[a].y < ps[b].y)
 779	}
 780	tmp := make([]int, n)
 781	for w := 1; w < n; w *= 2 {
 782		for lo := 0; lo < n; lo += 2 * w {
 783			mid, hi := lo+w, lo+2*w
 784			if mid > n {
 785				mid = n
 786			}
 787			if hi > n {
 788				hi = n
 789			}
 790			i, j, k := lo, mid, lo
 791			for i < mid && j < hi {
 792				if less(idx[j], idx[i]) {
 793					tmp[k] = idx[j]
 794					j++
 795				} else {
 796					tmp[k] = idx[i]
 797					i++
 798				}
 799				k++
 800			}
 801			for i < mid {
 802				tmp[k] = idx[i]
 803				i++
 804				k++
 805			}
 806			for j < hi {
 807				tmp[k] = idx[j]
 808				j++
 809				k++
 810			}
 811		}
 812		idx, tmp = tmp, idx
 813	}
 814	cr := func(o, a, b pt) float64 { return (a.x-o.x)*(b.y-o.y) - (a.y-o.y)*(b.x-o.x) }
 815	var h []pt
 816	for pass := 0; pass < 2; pass++ {
 817		start := len(h)
 818		for i := 0; i < n; i++ {
 819			p := ps[idx[i]]
 820			if pass == 1 {
 821				p = ps[idx[n-1-i]]
 822			}
 823			for len(h) >= start+2 && cr(h[len(h)-2], h[len(h)-1], p) <= 0 {
 824				h = h[:len(h)-1]
 825			}
 826			h = append(h, p)
 827		}
 828		h = h[:len(h)-1]
 829	}
 830	return h
 831}
 832
 833func polyArea(ps []pt) float64 {
 834	a := 0.0
 835	for i := range ps {
 836		j := (i + 1) % len(ps)
 837		a += ps[i].x*ps[j].y - ps[j].x*ps[i].y
 838	}
 839	if a < 0 {
 840		return -a / 2
 841	}
 842	return a / 2
 843}
 844
 845func clipHalf(ps []pt, ox, oy, nx, ny float64) []pt {
 846	if len(ps) == 0 {
 847		return ps
 848	}
 849	d := func(q pt) float64 { return (q.x-ox)*nx + (q.y-oy)*ny }
 850	var out []pt
 851	for i := range ps {
 852		a, b := ps[i], ps[(i+1)%len(ps)]
 853		da, db := d(a), d(b)
 854		if da <= 0 {
 855			out = append(out, a)
 856		}
 857		if (da < 0 && db > 0) || (da > 0 && db < 0) {
 858			t := da / (da - db)
 859			out = append(out, pt{a.x + (b.x-a.x)*t, a.y + (b.y-a.y)*t})
 860		}
 861	}
 862	return out
 863}
 864
 865func clipToConvex(ps, win []pt) []pt {
 866	for i := range win {
 867		a, b := win[i], win[(i+1)%len(win)]
 868		nx, ny := b.y-a.y, a.x-b.x
 869		mid := pt{(a.x + b.x) / 2, (a.y + b.y) / 2}
 870		cx, cy := centroid(win)
 871		if (mid.x-cx)*nx+(mid.y-cy)*ny < 0 {
 872			nx, ny = -nx, -ny
 873		}
 874		ps = clipHalf(ps, a.x, a.y, nx, ny)
 875		if len(ps) < 3 {
 876			return nil
 877		}
 878	}
 879	return ps
 880}
 881
 882func splitPoly(ps []pt, ang float64) ([]pt, []pt) {
 883	cx, cy := centroid(ps)
 884	nx, ny := math.Cos(ang), math.Sin(ang)
 885	return clipHalf(ps, cx, cy, nx, ny), clipHalf(ps, cx, cy, -nx, -ny)
 886}
 887
 888func renderSolid3D(shapeIdx int, base, salt string, fs *facetStyle) (under, facets, over string) {
 889	c := cutFor(shapeIdx)
 890	var faces []face
 891	var girdle []v3
 892	switch shapeIdx {
 893	case shapeRose:
 894		faces, girdle = buildRose(c)
 895	case shapeRaw:
 896		faces, girdle = buildRaw(fs.stone)
 897	default:
 898		faces, girdle = buildCut(c)
 899	}
 900	raw := shapeIdx == shapeRaw
 901
 902	sh := fs.prof.shadow
 903	if fs.prof.neutral != "" {
 904		base = fs.prof.neutral
 905	}
 906	base = mixHex(base, sh, fs.prof.bodyMix)
 907	deep := darken(base, sh, 0.9)
 908	bright := lighten(base, 0.5)
 909	fs.flash = flashRamp(28, fs.prof.sat)
 910	deepI, baseI, brightI := toRGBI(deep), toRGBI(base), toRGBI(bright)
 911	toneI := func(lum float64) rgbI {
 912		if lum < 0 {
 913			lum = 0
 914		}
 915		if lum > 1 {
 916			lum = 1
 917		}
 918		if lum < 0.5 {
 919			return mixI(deepI, baseI, lum*2)
 920		}
 921		return mixI(baseI, brightI, (lum-0.5)*2)
 922	}
 923	tone := func(lum float64) string { return toneI(lum).hex() }
 924
 925	yaw := fs.zone.floatIn(-13, 13) * math.Pi / 180
 926	th := viewTilt * math.Pi / 180
 927	cy, sy := math.Cos(yaw), math.Sin(yaw)
 928	ct, stt := math.Cos(th), math.Sin(th)
 929	view := func(p v3) v3 {
 930		x1, y1 := p.x*cy-p.y*sy, p.x*sy+p.y*cy
 931		return v3{x1, y1*ct + p.z*stt, p.z*ct - y1*stt}
 932	}
 933	ex0, ey0, ex1, ey1 := 1e9, 1e9, -1e9, -1e9
 934	for _, f := range faces {
 935		for _, p := range f.p {
 936			q := view(p)
 937			ex0, ey0 = math.Min(ex0, q.x), math.Min(ey0, q.y)
 938			ex1, ey1 = math.Max(ex1, q.x), math.Max(ey1, q.y)
 939		}
 940	}
 941	ox, oy := -(ex0+ex1)/2, -(ey0+ey1)/2
 942	scr := func(p v3) pt { return pt{subjX + p.x + ox, pieceCY - (p.y + oy)} }
 943	light := vnorm(v3{-0.5, 0.62, 0.6})
 944	inside := v3{0, 0, -0.25 * c.ry}
 945	if raw {
 946		inside = v3{}
 947	} else if shapeIdx == shapeRose {
 948		inside = v3{0, 0, 0.15 * c.ry}
 949	}
 950
 951	type drawn struct {
 952		pts   []pt
 953		depth float64
 954		fill  string
 955		kind  int
 956		lum   float64
 957		nv                v3
 958		jit, drift, thick float64
 959		ext               bool
 960	}
 961	shadeFront := func(kind int, nv, L v3, jit, drift, thick float64, ext bool) (string, float64) {
 962		H := vnorm(v3{L.x, L.y, L.z + 1})
 963		lam := math.Max(0, vdot(nv, L))
 964		lum := 0.03 + 0.7*math.Pow(lam, 2.1) + jit
 965		if ext {
 966			lum *= 0.3
 967		}
 968		if kind == kTable {
 969			lum = 0.36 + 0.26*lam
 970		}
 971		if kind == kPav {
 972			lum = 0.22 + 0.5*math.Max(0, vdot(vscale(nv, -1), L)) + jit
 973		}
 974		if raw {
 975			lum = 0.3 + 0.42*math.Pow(lam, 1.4) + 0.22*math.Max(0, vdot(vscale(nv, -1), L)) + jit*0.6
 976		}
 977		col := mixI(toneI(lum), deepI, 0.34*thick*thick)
 978		if sp := math.Pow(math.Max(0, vdot(nv, H)), 28); sp > 0.3 {
 979			col = mixI(col, whiteI, sp*0.85)
 980		}
 981		if lum > 0.5 && kind != kTable {
 982			amt := math.Min(1, (lum-0.5)/0.4) * dispersionMix * fs.prof.sat
 983			t := math.Atan2(nv.y, nv.x)/math.Pi + drift/40
 984			col = mixI(col, spectrumAtI(t), amt)
 985		}
 986		return hex3I(hueRotateI(col, drift)), lum
 987	}
 988	var back, front, pav []drawn
 989	var all []pt
 990	for _, f := range faces {
 991		vp := make([]v3, len(f.p))
 992		ctr := v3{}
 993		for i, p := range f.p {
 994			vp[i] = view(p)
 995			ctr = v3{ctr.x + p.x, ctr.y + p.y, ctr.z + p.z}
 996		}
 997		ctr = vscale(ctr, 1/float64(len(f.p)))
 998		nm := v3{}
 999		for i := range f.p {
1000			a, b := f.p[i], f.p[(i+1)%len(f.p)]
1001			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)}
1002		}
1003		nm = vnorm(nm)
1004		if vdot(nm, vsub(ctr, inside)) < 0 {
1005			nm = vscale(nm, -1)
1006		}
1007		nv := vnorm(view(nm))
1008		d := drawn{kind: f.kind}
1009		for _, p := range vp {
1010			d.pts = append(d.pts, scr(p))
1011			d.depth += p.z
1012			all = append(all, scr(p))
1013		}
1014		d.depth /= float64(len(vp))
1015		rN := math.Sqrt((ctr.x/c.rx)*(ctr.x/c.rx) + (ctr.y/c.ry)*(ctr.y/c.ry))
1016		if rN > 1 {
1017			rN = 1
1018		}
1019		thick := 1 - rN
1020		jit := fs.zone.floatIn(-0.13, 0.13)
1021		if nv.z > 0.02 {
1022			ext := f.kind == kCrown && fs.zone.floatIn(0, 1) < 0.28
1023			drift := fs.zone.floatIn(-fireZoneDeg, fireZoneDeg)
1024			d.nv, d.jit, d.drift, d.thick, d.ext = nv, jit, drift, thick, ext
1025			d.fill, d.lum = shadeFront(f.kind, nv, light, jit, drift, thick, ext)
1026			front = append(front, d)
1027			if f.kind == kPav {
1028				pav = append(pav, d)
1029			}
1030		} else if f.kind == kPav {
1031			inner := vscale(nv, -1)
1032			lum := 0.2 + 0.6*math.Max(0, vdot(inner, light)) + jit
1033			d.fill = hex3(mixHex(tone(lum), deep, 0.34*thick*thick))
1034			d.lum = lum
1035			back = append(back, d)
1036			pav = append(pav, d)
1037		}
1038	}
1039	sortDrawn := func(ds []drawn) {
1040		for i := 1; i < len(ds); i++ {
1041			for j := i; j > 0 && ds[j].depth < ds[j-1].depth; j-- {
1042				ds[j], ds[j-1] = ds[j-1], ds[j]
1043			}
1044		}
1045	}
1046	sortDrawn(back)
1047	sortDrawn(front)
1048
1049	hull := convexHull(all)
1050	if shapeIdx == shapeHeart {
1051		hull = nil
1052		for _, g := range girdle {
1053			hull = append(hull, scr(view(g)))
1054		}
1055	}
1056	var underB, facetsB strings.Builder
1057	underB.WriteString(`<defs><radialGradient id="bd` + salt + `" cx=".4" cy=".35" r=".75">` +
1058		`<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>` +
1059		`<path d="` + relPath(hull) + `" fill="url(#bd` + salt + `)" opacity=".7"/><g fill-opacity=".6">`)
1060	for _, d := range back {
1061		underB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)
1062	}
1063	underB.WriteString(`</g>`)
1064
1065	nCrown := 0
1066	for _, d := range front {
1067		if d.kind == kCrown {
1068			nCrown++
1069		}
1070	}
1071	candP := 1.0
1072	if nCrown > fireMaxFacets {
1073		candP = float64(fireMaxFacets) / float64(nCrown) * 1.25
1074	}
1075	nCand := 0
1076	nSplit := 0
1077	if raw || fs.thumb {
1078		candP = 0
1079	}
1080	for _, d := range front {
1081		if d.kind == kTable && shapeIdx == shapeHeart {
1082			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)
1083			continue
1084		}
1085		if d.kind == kTable {
1086			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)
1087			win := convexHull(d.pts)
1088			tx, ty := centroid(win)
1089			if len(pav) > 0 && !fs.thumb {
1090				bx0, by0, bx1, by1 := 1e9, 1e9, -1e9, -1e9
1091				for _, b := range pav {
1092					for _, q := range b.pts {
1093						bx0, by0 = math.Min(bx0, q.x), math.Min(by0, q.y)
1094						bx1, by1 = math.Max(bx1, q.x), math.Max(by1, q.y)
1095					}
1096				}
1097				wx0, wy0, wx1, wy1 := 1e9, 1e9, -1e9, -1e9
1098				for _, q := range win {
1099					wx0, wy0 = math.Min(wx0, q.x), math.Min(wy0, q.y)
1100					wx1, wy1 = math.Max(wx1, q.x), math.Max(wy1, q.y)
1101				}
1102				k := 0.92 * math.Min((wx1-wx0)/math.Max(1, bx1-bx0), (wy1-wy0)/math.Max(1, by1-by0))
1103				bx, by := (bx0+bx1)/2, (by0+by1)/2
1104				var mosB strings.Builder
1105				nRefl := 0
1106				for _, b := range pav {
1107					if nRefl >= 8 {
1108						break
1109					}
1110					mp := make([]pt, len(b.pts))
1111					for i, q := range b.pts {
1112						mp[i] = pt{tx + (q.x-bx)*k, ty - (q.y-by)*k}
1113					}
1114					cl := clipToConvex(mp, win)
1115					if len(cl) < 3 || polyArea(cl) < 4 {
1116						continue
1117					}
1118					nRefl++
1119					mosB.WriteString(`<path d="` + relPath(cl) + `" fill="` + hex3(tone(b.lum*0.95)) + `"/>`)
1120				}
1121				if mosB.Len() > 0 {
1122					facetsB.WriteString(`<g fill-opacity=".55">` + mosB.String() + `</g>`)
1123				}
1124			}
1125			continue
1126		}
1127		if d.kind == kCrown && nCand < fireMaxFacets && fs.zone.floatIn(0, 1) < candP {
1128			nCand++
1129			cx, cyy := centroid(d.pts)
1130			fs.ringCent = append(fs.ringCent, pt{cx, cyy})
1131			facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" class="f"/>`)
1132			continue
1133		}
1134		if !raw && nSplit < 6 && polyArea(d.pts) > 26 && fs.zone.floatIn(0, 1) < 0.5 {
1135			a, b := splitPoly(d.pts, fs.zone.floatIn(0, math.Pi))
1136			if len(a) >= 3 && len(b) >= 3 {
1137				nSplit++
1138				lo := hex3(mixHex(tone(math.Max(0, d.lum*0.6-0.04)), deep, 0.18))
1139				hi := hex3(tone(math.Min(1, d.lum*1.35+0.06)))
1140				facetsB.WriteString(`<path d="` + relPath(a) + `" fill="` + hi + `"/><path d="` + relPath(b) + `" fill="` + lo + `"/>`)
1141				continue
1142			}
1143		}
1144		facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)
1145	}
1146
1147	_, _, _, _, rotDeg := motionPhases(salt)
1148	type cand struct {
1149		i     int
1150		fill  string
1151		delta float64
1152	}
1153	for si, sgn := range []float64{1, -1} {
1154		if fs.thumb {
1155			break
1156		}
1157		a := -sgn * rotDeg * lightSwing * math.Pi / 180
1158		ca, sa := math.Cos(a), math.Sin(a)
1159		L := vnorm(v3{light.x*ca - light.y*sa, light.x*sa + light.y*ca, light.z})
1160		var cs []cand
1161		for i, d := range front {
1162			if d.kind == kTable {
1163				continue
1164			}
1165			f2, _ := shadeFront(d.kind, d.nv, L, d.jit, d.drift, d.thick, d.ext)
1166			if dl := lumaDelta(f2, d.fill); dl > 0.06 && polyArea(d.pts) > 3 {
1167				cs = append(cs, cand{i, f2, dl})
1168			}
1169		}
1170		for i := 1; i < len(cs); i++ {
1171			for j := i; j > 0 && cs[j].delta > cs[j-1].delta; j-- {
1172				cs[j], cs[j-1] = cs[j-1], cs[j]
1173			}
1174		}
1175		if len(cs) > lightMaxFacets {
1176			cs = cs[:lightMaxFacets]
1177		}
1178		if len(cs) == 0 {
1179			continue
1180		}
1181		cls := "gt-la"
1182		if si == 1 {
1183			cls = "gt-lb"
1184		}
1185		facetsB.WriteString(`<g class="` + cls + `" opacity="0">`)
1186		for _, c := range cs {
1187			facetsB.WriteString(`<path d="` + relPath(front[c.i].pts) + `" fill="` + c.fill + `"/>`)
1188		}
1189		facetsB.WriteString(`</g>`)
1190	}
1191
1192	gd, lastG := "", ""
1193	for i := range girdle {
1194		a, b := view(girdle[i]), view(girdle[(i+1)%len(girdle)])
1195		mid := vscale(v3{girdle[i].x + girdle[(i+1)%len(girdle)].x, girdle[i].y + girdle[(i+1)%len(girdle)].y, 0}, 0.5)
1196		if view(vnorm(mid)).z-0 > -0.05 && view(mid).z >= view(v3{}).z-0.01 {
1197			pa, pb := scr(a), scr(b)
1198			sa := n1(pa.x) + " " + n1(pa.y)
1199			if sa != lastG {
1200				gd += "M" + sa
1201			}
1202			lastG = n1(pb.x) + " " + n1(pb.y)
1203			gd += " " + lastG
1204		}
1205	}
1206	over = `<path d="` + gd + `" fill="none" stroke="` + hex3(lighten(base, 0.18)) + `" stroke-width=".7" stroke-linecap="round" opacity=".4"/>`
1207	return underB.String(), facetsB.String(), over
1208}
1209
1210const dispersionMix = 0.32
1211
1212var spectrum = []string{"#ff4040", "#ff9a1f", "#ffe81f", "#35ff7a", "#22ccff", "#5566ff", "#c04dff"}
1213
1214var spectrumI = func() []rgbI {
1215	out := make([]rgbI, len(spectrum))
1216	for i, h := range spectrum {
1217		out[i] = toRGBI(h)
1218	}
1219	return out
1220}()
1221
1222func spectrumAtI(t float64) rgbI {
1223	t = t - math.Floor(t)
1224	f := t * float64(len(spectrumI))
1225	i := int(f)
1226	return mixI(spectrumI[i%len(spectrumI)], spectrumI[(i+1)%len(spectrumI)], f-float64(i))
1227}
1228
1229func spectrumAt(t float64) string {
1230	t = t - math.Floor(t)
1231	f := t * float64(len(spectrum))
1232	i := int(f)
1233	return mixHex(spectrum[i%len(spectrum)], spectrum[(i+1)%len(spectrum)], f-float64(i))
1234}
1235
1236const (
1237	lightSwing     = 2.2
1238	lightMaxFacets = 12
1239)
1240
1241func lumaDelta(a, b string) float64 {
1242	wide := func(h string) string {
1243		if len(h) == 4 {
1244			return "#" + h[1:2] + h[1:2] + h[2:3] + h[2:3] + h[3:4] + h[3:4]
1245		}
1246		return h
1247	}
1248	ar, ag, ab := hexToRGB(wide(a))
1249	br, bg, bb := hexToRGB(wide(b))
1250	d := (0.2126*float64(ar-br) + 0.7152*float64(ag-bg) + 0.0722*float64(ab-bb)) / 255
1251	if d < 0 {
1252		return -d
1253	}
1254	return d
1255}
1256
1257const fireMaxFacets = 12
1258
1259const starfield = true
1260
1261func renderStars(salt string) string {
1262	if !starfield {
1263		return ""
1264	}
1265	rnd := newPRNG("stars" + salt)
1266	type star struct{ x, y float64 }
1267	var byLevel [4][]star
1268	for i := 0; i < 36; i++ {
1269		x, y := rnd.floatIn(3, canvasW-3), rnd.floatIn(3, 127)
1270		k := rnd.intn(20)
1271		dx, dy := x-subjX, y-subjY
1272		if r := math.Sqrt(dx*dx + dy*dy); r > 43 && r < 58 {
1273			continue
1274		}
1275		lvl := 0 // most stars are faint; a few are bright
1276		switch {
1277		case k >= 18:
1278			lvl = 3
1279		case k >= 14:
1280			lvl = 2
1281		case k >= 8:
1282			lvl = 1
1283		}
1284		byLevel[lvl] = append(byLevel[lvl], star{x, y})
1285	}
1286	widths := [4]string{".45", ".6", ".85", "1.15"}
1287	ops := [4]string{".22", ".38", ".58", ".85"}
1288	var b strings.Builder
1289	b.WriteString(`<g stroke="#fff" stroke-linecap="round" fill="none">`)
1290	var tw []star
1291	for l := 0; l < 4; l++ {
1292		if len(byLevel[l]) == 0 {
1293			continue
1294		}
1295		d := ""
1296		for i, st := range byLevel[l] {
1297			if l >= 1 && l <= 2 && len(tw) < 3 && i%3 == 0 {
1298				tw = append(tw, st)
1299				continue
1300			}
1301			d += "M" + n1(st.x) + " " + n1(st.y) + "h.01"
1302		}
1303		if d != "" {
1304			b.WriteString(`<path d="` + d + `" stroke-width="` + widths[l] + `" opacity="` + ops[l] + `"/>`)
1305		}
1306	}
1307	for _, st := range tw {
1308		dur := rnd.floatIn(5, 12)
1309		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"/>`)
1310	}
1311	b.WriteString(`</g>`)
1312	return b.String()
1313}
1314
1315func pearlLusterGradient(gid, base, metal string) string {
1316	r, g, b := hexToRGB(base)
1317	lum := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 255
1318	shadow := "#4a4038"
1319	if lum < 0.4 {
1320		shadow = "#000000"
1321	}
1322	hi := base
1323	sc, ss, sw := ".6", ".32", ".16"
1324	mo := ".3"
1325	if lum < 0.4 {
1326		mo = ".22"
1327		hi = mixHex(base, "#c9d2d8", 0.28)
1328		sc, ss, sw = ".34", ".16", ".07"
1329	}
1330	return `<radialGradient id="` + gid + `" cx=".4" cy=".36" r=".7" fx=".34" fy=".28">` +
1331		`<stop offset="0" stop-color="` + hi + `"/>` +
1332		`<stop offset=".4" stop-color="` + mixHex(base, shadow, 0.13) + `"/>` +
1333		`<stop offset=".76" stop-color="` + mixHex(base, shadow, 0.36) + `"/>` +
1334		`<stop offset=".93" stop-color="` + mixHex(base, shadow, 0.5) + `"/>` +
1335		`<stop offset="1" stop-color="` + mixHex(base, shadow, 0.3) + `"/></radialGradient>` +
1336		`<radialGradient id="` + gid + `o" cx=".45" cy=".44" r=".62">` +
1337		`<stop offset=".3" stop-color="#fff" stop-opacity="0"/>` +
1338		`<stop offset=".62" stop-color="#ffb3cf" stop-opacity=".2"/>` +
1339		`<stop offset=".88" stop-color="#b3ffd6" stop-opacity=".2"/>` +
1340		`<stop offset="1" stop-color="#c3d3ff" stop-opacity=".12"/></radialGradient>` +
1341		`<radialGradient id="` + gid + `s" cx=".36" cy=".3" r=".4">` +
1342		`<stop offset="0" stop-color="#fff" stop-opacity="` + sc + `"/>` +
1343		`<stop offset=".35" stop-color="#fff" stop-opacity="` + ss + `"/>` +
1344		`<stop offset=".7" stop-color="#fff" stop-opacity=".06"/>` +
1345		`<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>` +
1346		`<radialGradient id="` + gid + `m" cx=".42" cy=".36" r=".68">` +
1347		`<stop offset=".78" stop-color="` + metal + `" stop-opacity="0"/>` +
1348		`<stop offset=".93" stop-color="` + metal + `" stop-opacity="` + mo + `"/>` +
1349		`<stop offset="1" stop-color="` + metal + `" stop-opacity="0"/></radialGradient>` +
1350		`<radialGradient id="` + gid + `w" cx=".66" cy=".73" r=".2">` +
1351		`<stop offset="0" stop-color="#fff" stop-opacity="` + sw + `"/>` +
1352		`<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>`
1353}
1354
1355func pearlLayers(shape, gid, between string) string {
1356	i := strings.Index(shape, ` fill="`)
1357	if i < 0 {
1358		return shape
1359	}
1360	cp := func(suffix string) string { return shape[:i] + ` fill="url(#` + gid + suffix + `)"/>` }
1361	return shape + cp("o") + cp("m") + between + cp("w") + cp("s")
1362}
1363
1364func pearlSweepColorIntensity(base string) float64 {
1365	r, g, b := hexToRGB(base)
1366	lum := 0.2126*float64(r)/255 + 0.7152*float64(g)/255 + 0.0722*float64(b)/255
1367	if lum < 0.4 {
1368		return 0.55
1369	}
1370	return 2.0
1371}
1372
1373func pearlSweepGradient(gid, base string) string {
1374	mult := pearlSweepColorIntensity(base)
1375	o1, o2, o3 := fnum(0.3*mult), fnum(0.35*mult), fnum(0.3*mult)
1376	return `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="0%">` +
1377		`<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>` +
1378		`<stop offset="18%" stop-color="#ffd2e6" stop-opacity="` + o1 + `"/>` +
1379		`<stop offset="38%" stop-color="#cfe8ff" stop-opacity="` + o2 + `"/>` +
1380		`<stop offset="58%" stop-color="#d4ffe4" stop-opacity="` + o3 + `"/>` +
1381		`<stop offset="80%" stop-color="#fff0c2" stop-opacity="` + o3 + `"/>` +
1382		`<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>` +
1383		`</linearGradient>`
1384}
1385
1386func pearlSweepOverlay(salt, base, shapeGeom string, w, h float64) string {
1387	rnd := newPRNG(salt + "-sweep")
1388	const passes = 3
1389	total := 15.0 + rnd.floatIn(0, 9)
1390
1391	durs := make([]float64, passes)
1392	sumDur := 0.0
1393	for i := 0; i < passes; i++ {
1394		durs[i] = 2.4 + rnd.floatIn(0, 2.2)
1395		sumDur += durs[i]
1396	}
1397	gapTotal := total - sumDur
1398	if gapTotal < float64(passes)*0.5 {
1399		gapTotal = float64(passes) * 0.5
1400	}
1401	weights := make([]float64, passes)
1402	sumW := 0.0
1403	for i := 0; i < passes; i++ {
1404		weights[i] = 0.4 + rnd.floatIn(0, 1)
1405		sumW += weights[i]
1406	}
1407	gaps := make([]float64, passes)
1408	for i := 0; i < passes; i++ {
1409		gaps[i] = weights[i] / sumW * gapTotal
1410	}
1411	starts := make([]float64, passes)
1412	t := gaps[0]
1413	for i := 0; i < passes; i++ {
1414		starts[i] = t
1415		t += durs[i]
1416		if i < passes-1 {
1417			t += gaps[i+1]
1418		}
1419	}
1420
1421	travel := w * 1.05
1422	clipID := "clp" + salt
1423	gradID := "swp" + salt
1424	off, on1, on2, out := fnum(-travel), fnum(-travel*0.5), fnum(travel*0.5), fnum(travel)
1425
1426	var defs strings.Builder
1427	defs.WriteString(`<defs><clipPath id="` + clipID + `">` + shapeGeom + `</clipPath>`)
1428	defs.WriteString(pearlSweepGradient(gradID, base))
1429
1430	var marks strings.Builder
1431	marks.WriteString(`<g clip-path="url(#` + clipID + `)">`)
1432	for i := 0; i < passes; i++ {
1433		angle := fnum(rnd.floatIn(0, 360))
1434		startPct := starts[i] / total * 100
1435		durPct := durs[i] / total * 100
1436		rampPct := durPct * 0.32
1437		if rampPct > 6 {
1438			rampPct = 6
1439		}
1440		p0, p1, p2, p3 := startPct, startPct+rampPct, startPct+durPct-rampPct, startPct+durPct
1441		kf := "swp" + salt + "_" + ufmt.Sprintf("%d", i)
1442
1443		closing := ""
1444		if p3 < 99.999 {
1445			closing = `100%{transform:translate(` + off + `px,0);opacity:0}`
1446		}
1447		defs.WriteString(`<style>@keyframes ` + kf + `{` +
1448			`0%{transform:translate(` + off + `px,0);opacity:0}` +
1449			fnum(p0) + `%{transform:translate(` + off + `px,0);opacity:0}` +
1450			fnum(p1) + `%{transform:translate(` + on1 + `px,0);opacity:1}` +
1451			fnum(p2) + `%{transform:translate(` + on2 + `px,0);opacity:1}` +
1452			fnum(p3) + `%{transform:translate(` + out + `px,0);opacity:0}` +
1453			closing +
1454			`}</style>`)
1455
1456		marks.WriteString(`<g transform="rotate(` + angle + ` ` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +
1457			`<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"/>` +
1458			`</g>`)
1459	}
1460	marks.WriteString(`</g>`)
1461	defs.WriteString(`</defs>`)
1462	return defs.String() + marks.String()
1463}
1464
1465func pearlRound(base, metal, salt string) string {
1466	gid := "prl" + salt
1467	r := 24.0 * stoneScale
1468	body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
1469	clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
1470	sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
1471	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1472}
1473
1474func pearlOval(base, metal, salt string) string {
1475	gid := "prl" + salt
1476	rx, ry := 18.0*stoneScale, 25.0*stoneScale
1477	body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
1478	clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
1479	sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
1480	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1481}
1482
1483func pearlButton(base, metal, salt string) string {
1484	gid := "prl" + salt
1485	rx, ry := 25.0*stoneScale, 18.0*stoneScale
1486	body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
1487	clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
1488	sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
1489	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1490}
1491
1492func pearlDrop(base, metal, salt string) string {
1493	gid := "prl" + salt
1494	w, h := 32.0*stoneScale, 42.0*stoneScale
1495	path := `M ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) +
1496		` 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) +
1497		` 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`
1498	body := `<path d="` + path + `" fill="url(#` + gid + `)"/>`
1499	clipGeom := `<path d="` + path + `"/>`
1500	sweep := pearlSweepOverlay(salt, base, clipGeom, w*1.6, h*0.9)
1501	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1502}
1503
1504func pearlBaroqueOutline(seed string) []pt {
1505	rnd := newPRNG(seed)
1506	n := 10
1507	pts := make([]pt, n)
1508	for k := 0; k < n; k++ {
1509		a := 2 * math.Pi * float64(k) / float64(n)
1510		rr := 21.0 * stoneScale * (0.75 + 0.4*rnd.floatIn(0, 1))
1511		pts[k] = pt{subjX + rr*math.Cos(a)*1.1, subjY + rr*math.Sin(a)*0.9}
1512	}
1513	return pts
1514}
1515
1516func pearlBaroqueSmoothPath(pts []pt) string {
1517	n := len(pts)
1518	mid := func(a, b pt) pt { return pt{(a.x + b.x) / 2, (a.y + b.y) / 2} }
1519	start := mid(pts[n-1], pts[0])
1520	path := "M " + fnum(start.x) + " " + fnum(start.y)
1521	for k := 0; k < n; k++ {
1522		next := pts[(k+1)%n]
1523		m := mid(pts[k], next)
1524		path += " Q " + fnum(pts[k].x) + " " + fnum(pts[k].y) + " " + fnum(m.x) + " " + fnum(m.y)
1525	}
1526	return path + " Z"
1527}
1528
1529func pearlBaroque(base, metal, salt string) string {
1530	gid := "prl" + salt
1531	pathD := pearlBaroqueSmoothPath(pearlBaroqueOutline("baroque" + salt))
1532	body := `<path d="` + pathD + `" fill="url(#` + gid + `)"/>`
1533	clipGeom := `<path d="` + pathD + `"/>`
1534	sweep := pearlSweepOverlay(salt, base, clipGeom, 40*stoneScale, 30*stoneScale)
1535	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep
1536}
1537
1538func pearlCircled(base, metal, salt string) string {
1539	gid := "prl" + salt
1540	r := 24.0 * stoneScale
1541	body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
1542	ringTone := mixHex(base, "#000000", 0.12)
1543	rings := ""
1544	for _, f := range []float64{0.42, 0.58, 0.74, 0.9} {
1545		rings += `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r*f) + `" fill="none" stroke="` + ringTone + `" stroke-width="0.9" opacity="0.35"/>`
1546	}
1547	clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
1548	sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
1549	return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, rings) + sweep
1550}
1551
1552var PearlShapeNames = []string{"Round", "Oval", "Button", "Drop", "Baroque", "Circled"}
1553
1554func renderPearlShape(shapeIdx int, base, metal, salt string) string {
1555	switch shapeIdx {
1556	case 0:
1557		return pearlRound(base, metal, salt)
1558	case 1:
1559		return pearlOval(base, metal, salt)
1560	case 2:
1561		return pearlButton(base, metal, salt)
1562	case 3:
1563		return pearlDrop(base, metal, salt)
1564	case 4:
1565		return pearlBaroque(base, metal, salt)
1566	default:
1567		return pearlCircled(base, metal, salt)
1568	}
1569}
1570
1571func renderRing(metalHi, metalLo, salt string) string {
1572	gid := "ring" + salt
1573	bright := lighten(metalHi, 0.45)
1574	deep := mixHex(metalLo, "#000000", 0.45)
1575	st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }
1576	defs := `<linearGradient id="` + gid + `" x2="1" y2="1">` +
1577		st("0", bright) + st(".12", metalHi) + st(".26", deep) + st(".4", metalLo) + st(".52", bright) +
1578		st(".64", metalHi) + st(".78", deep) + st(".9", metalLo) + st("1", bright) + `</linearGradient>`
1579
1580	outer := `<g fill="none" transform="translate(` + cnum(subjX) + ` ` + cnum(subjY) + `)">` +
1581		`<circle r="52.6" stroke="#000" stroke-width="1.6" opacity=".45"/>` +
1582		`<circle r="50" stroke="url(#` + gid + `)" stroke-width="4.4"/>` +
1583		`<circle r="52.3" stroke="` + deep + `" stroke-width=".7" opacity=".8"/>` +
1584		`<circle r="47.7" stroke="` + deep + `" stroke-width=".6" opacity=".7"/>` +
1585		`<circle r="48.5" stroke="` + lighten(metalHi, 0.35) + `" stroke-width=".5" opacity=".45"/></g>`
1586
1587	const rOut = 46.0
1588	const coreHalf = 0.6
1589	const outwardBloom = 0.9
1590	glowR := rOut + outwardBloom + 3
1591	pct := func(v float64) float64 {
1592		p := v / glowR * 100
1593		if p < 0 {
1594			p = 0
1595		}
1596		if p > 100 {
1597			p = 100
1598		}
1599		return p
1600	}
1601	coreColor := lighten(metalHi, 0.25)
1602	donutStops := func(inwardBloom, peakOp float64, gid string) string {
1603		bloomOp := peakOp * 0.35
1604		midOp := peakOp * 0.7
1605		return `<radialGradient id="` + gid + `" cx="50%" cy="50%" r="50%">` +
1606			`<stop offset="0%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
1607			`<stop offset="` + pctnum(pct(rOut-inwardBloom-1.5)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
1608			`<stop offset="` + pctnum(pct(rOut-inwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
1609			`<stop offset="` + pctnum(pct(rOut-coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
1610			`<stop offset="` + pctnum(pct(rOut)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(peakOp) + `"/>` +
1611			`<stop offset="` + pctnum(pct(rOut+coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
1612			`<stop offset="` + pctnum(pct(rOut+outwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
1613			`<stop offset="` + pctnum(pct(rOut+outwardBloom+1)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
1614			`<stop offset="100%" stop-color="` + metalHi + `" stop-opacity="0"/></radialGradient>`
1615	}
1616
1617	rnd := newPRNG("energy" + salt)
1618	gidA, gidB := "glowA"+salt, "glowB"+salt
1619	dur := rnd.floatIn(2.13, 3.2)
1620	phase := rnd.floatIn(0, dur)
1621
1622	stopsA := donutStops(2.5*2.0/3.0*0.5, 0.4, gidA)
1623	stopsB := donutStops(8.0*2.0/3.0*0.5, 0.6, gidB)
1624
1625	energy := `<defs>` + stopsA + stopsB + `</defs>` +
1626		`<g>` +
1627		`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidA + `)"/>` +
1628		`<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"/>` +
1629		`</g>`
1630
1631	return `<defs>` + defs + `</defs>` + outer + energy
1632}
1633
1634const (
1635	motionVDur, motionHDur, motionRDur, motionSDur = 3.4, 4.66, 5.81, 6.56
1636	motionRotMin, motionRotMax                     = 4.0, 8.0
1637	motionScale                                    = 1.09
1638)
1639
1640func motionPhases(salt string) (vphase, hphase, rphase, sphase, rotDeg float64) {
1641	rnd := newPRNG("motion" + salt)
1642	vphase = rnd.floatIn(0, motionVDur)
1643	hphase = rnd.floatIn(0, motionHDur)
1644	rphase = rnd.floatIn(0, motionRDur)
1645	sphase = rnd.floatIn(0, motionSDur)
1646	rotDeg = rnd.floatIn(motionRotMin, motionRotMax)
1647	return
1648}
1649
1650func applyMotion(content, salt string) string {
1651	vphase, hphase, rphase, sphase, rotDeg := motionPhases(salt)
1652
1653	return `<g transform="translate(` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +
1654		`<g class="gt-v" style="--v-dur:` + fnum(motionVDur) + `s;--v-delay:-` + fnum(vphase) + `s">` +
1655		`<g class="gt-h" style="--h-dur:` + fnum(motionHDur) + `s;--h-delay:-` + fnum(hphase) + `s">` +
1656		`<g class="gt-r" style="--r-dur:` + fnum(motionRDur) + `s;--r-delay:-` + fnum(rphase) + `s;--rot-deg:` + fnum(rotDeg) + `deg">` +
1657		`<g class="gt-s" style="--s-dur:` + fnum(motionSDur) + `s;--s-delay:-` + fnum(sphase) + `s">` +
1658		`<g transform="translate(-` + fnum(subjX) + ` -` + fnum(subjY) + `)">` +
1659		content + `</g></g></g></g></g></g>`
1660}
1661
1662const dotX, dotY = subjX + 50, subjY + 42
1663
1664func renderStatusBadge(active bool) string {
1665	if active {
1666		return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#7fd88f"/>` +
1667			`<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>`
1668	}
1669	return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#5a5f6b"/>`
1670}
1671
1672type facetSpan struct {
1673	start, end int
1674	ringMember bool
1675}
1676
1677func scanFacetSpans(markup string) []facetSpan {
1678	var spans []facetSpan
1679	i := 0
1680	for {
1681		rel := strings.Index(markup[i:], "<")
1682		if rel < 0 {
1683			break
1684		}
1685		start := i + rel
1686		relEnd := strings.Index(markup[start:], "/>")
1687		if relEnd < 0 {
1688			break
1689		}
1690		end := start + relEnd + 2
1691		tag := markup[start:end]
1692		i = end
1693		isFacetTag := strings.HasPrefix(tag, "<path") || strings.HasPrefix(tag, "<circle") || strings.HasPrefix(tag, "<polygon")
1694		if !isFacetTag {
1695			continue
1696		}
1697		spans = append(spans, facetSpan{start: start, end: end, ringMember: strings.Contains(tag, `class="f"`)})
1698	}
1699	return spans
1700}
1701
1702const (
1703	fireSweepAxis = -35.0 * math.Pi / 180
1704	fireSweepSpan = 1.6 // seconds a sweep takes to cross the stone
1705)
1706
1707func applyCascade(markup, tidStr string, fs *facetStyle) (overlay string, css string) {
1708	if fs == nil || len(fs.ringCent) == 0 {
1709		return "", ""
1710	}
1711	spans := scanFacetSpans(markup)
1712	rnd := newPRNG("fire" + tidStr)
1713	_, _, rphase, _, _ := motionPhases(tidStr)
1714	period := 2 * motionRDur
1715	axis := fireSweepAxis + rnd.floatIn(-0.3, 0.3)
1716	dx, dy := math.Cos(axis), math.Sin(axis)
1717
1718	ka, kb := "fa"+tidStr, "fb"+tidStr
1719	shape := func(at float64) string {
1720		return fnum1(at) + `%{opacity:0}` + fnum1(at+2) + `%{opacity:var(--o);fill:#fff}` +
1721			fnum1(at+4.5) + `%{opacity:.4;fill:var(--c)}` + fnum1(at+8) + `%{opacity:0}`
1722	}
1723	per := fnum(period)
1724	css = `.` + ka + `{animation:` + ka + ` ` + per + `s linear infinite}.` + kb + `{animation:` + kb + ` ` + per + `s linear infinite}` +
1725		`@keyframes ` + ka + `{` + shape(12.5) + `100%{opacity:0}}` +
1726		`@keyframes ` + kb + `{` + shape(62.5) + `100%{opacity:0}}`
1727
1728	var out strings.Builder
1729	ringIdx := -1
1730	for _, span := range spans {
1731		if !span.ringMember {
1732			continue
1733		}
1734		ringIdx++
1735		if ringIdx >= len(fs.ringCent) {
1736			break
1737		}
1738		c := fs.ringCent[ringIdx]
1739		color, colorOp := fs.flash.sample(c.x, c.y)
1740		pos := ((c.x-subjX)*dx + (c.y-pieceCY)*dy) / (30 * stoneScale)
1741		name := ka
1742		if rnd.floatIn(0, 1) < 0.5 {
1743			name, pos = kb, -pos
1744		}
1745		delay := rphase - 0.25*motionRDur + period - pos*fireSweepSpan/2
1746		tag := markup[span.start:span.end]
1747		geomEnd := strings.Index(tag, `" fill=`)
1748		if geomEnd < 0 {
1749			continue
1750		}
1751		out.WriteString(tag[:geomEnd] + `" opacity="0" class="` + name + `" style="--o:` + fnum(math.Round(rnd.floatIn(0.55, 0.9)*colorOp*10)/10) + `;--c:` + hex3(color) +
1752			`;animation-delay:-` + fnum1(delay) + `s"/>`)
1753	}
1754	return out.String(), css
1755}
1756
1757func renderMetalLabelRect(metalHi, metalLo string, x, y, w, h, rx float64, salt string) string {
1758	gid := "lg" + salt
1759	bright := lighten(metalHi, 0.4)
1760	deep := mixHex(metalLo, "#000000", 0.5)
1761	st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }
1762	defs := `<linearGradient id="` + gid + `">` +
1763		st("0", metalLo) + st(".22", metalHi) + st(".42", bright) + st(".62", metalHi) + st("1", metalLo) + `</linearGradient>`
1764	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"/>`
1765	const in_ = 0.9
1766	arc := ` a` + cnum(rx-in_) + ` ` + cnum(rx-in_) + ` 0 0 `
1767	d := cnum(rx - in_)
1768	edges := `<g fill="none" stroke-linecap="round">` +
1769		`<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"/>` +
1770		`<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>`
1771	sid := "sh" + salt
1772	defs += `<g id="` + sid + `"><circle cx=".15" cy=".2" r="1.6" fill="` + deep + `" opacity=".55"/>` +
1773		`<circle r="1.3" fill="` + mixHex(metalLo, metalHi, 0.4) + `" stroke="` + deep + `" stroke-width=".25"/>` +
1774		`<circle cx="-.4" cy="-.4" r=".75" fill="` + lighten(metalHi, 0.3) + `" opacity=".35"/></g>`
1775	seed := 0
1776	for k := 0; k < len(salt); k++ {
1777		seed += int(salt[k])
1778	}
1779	heads, slots := "", ""
1780	n := 0
1781	for _, px := range []float64{x + 4.2, x + w - 4.2} {
1782		for _, py := range []float64{y + 3.7, y + h - 3.7} {
1783			heads += `<use href="#` + sid + `" x="` + cnum(px) + `" y="` + cnum(py) + `"/>`
1784			ang := float64((seed*37+n*71)%180) * math.Pi / 180
1785			dx, dy := 0.9*math.Cos(ang), 0.9*math.Sin(ang)
1786			slots += `M` + cnum(px-dx) + ` ` + cnum(py-dy) + `l` + cnum(2*dx) + ` ` + cnum(2*dy)
1787			n++
1788		}
1789	}
1790	rivets := heads + `<path d="` + slots + `" stroke="` + deep + `" stroke-width=".45" stroke-linecap="round"/>`
1791	return `<defs>` + defs + `</defs>` + rect + edges + rivets
1792}
1793
1794const (
1795	plateFont    = `'DIN Alternate',Bahnschrift,'Roboto Condensed',sans-serif`
1796	plateSize    = 8.6
1797	plateSpacing = 1.1
1798	plateMaxW    = 100.0
1799)
1800
1801func renderPlateText(label, fill, metalHi string) string {
1802	g := `<g font-size="` + cnum(plateSize) + `" letter-spacing="` + cnum(plateSpacing) + `" text-anchor="middle" font-family="` + plateFont + `" font-weight="700"`
1803	if est := float64(len(label))*(plateSize*0.52+plateSpacing); est > plateMaxW {
1804		g += ` textLength="` + cnum(plateMaxW) + `" lengthAdjust="spacingAndGlyphs"`
1805	}
1806	x := cnum(subjX)
1807	return g + `><text x="` + x + `" y="144.9" fill="` + lighten(metalHi, 0.5) + `" opacity=".45">` + label + `</text>` +
1808		`<text x="` + x + `" y="144.4" fill="` + fill + `">` + label + `</text></g>`
1809}
1810
1811func renderSerialText(tidStr, color string) string {
1812	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>`
1813}
1814
1815type namedColor struct{ hex, name string }
1816
1817var stoneByChain = map[string]string{
1818	"TOPAZ-1":    "Topaz",
1819	"SAPPHIRE-1": "Sapphire",
1820	"PEARL-1":    "Pearl",
1821	"GNOLAND-1": "Diamond",
1822}
1823
1824var stonePalettes = map[string][]namedColor{
1825	"Sapphire": {
1826		{"#2a5fe0", "Blue"}, {"#ff6ea4", "Pink"}, {"#ff8501", "Orange"}, {"#ffd500", "Yellow"},
1827		{"#1aa88a", "Green"}, {"#a658ff", "Violet"}, {"#e6e9f8", "White"}, {"#ff8f72", "Padparadscha"},
1828	},
1829	"Topaz": {
1830		{"#f5edd1", "Colorless"}, {"#ffa300", "Imperial"}, {"#ffd100", "Golden"},
1831		{"#c96a28", "Sherry Brown"}, {"#86cdf2", "Sky Blue"}, {"#ff96ba", "Pink"},
1832	},
1833	"Quartz": {
1834		{"#eef2f7", "Rock Crystal"}, {"#9b59d6", "Amethyst"}, {"#f2b01e", "Citrine"},
1835		{"#f5b9ca", "Rose Quartz"}, {"#7d6250", "Smoky Quartz"}, {"#a9d9a2", "Prasiolite"},
1836	},
1837	"Diamond": {
1838		{"#f3f6fc", "Colorless"}, {"#ffc400", "Fancy Yellow"}, {"#ff4f9a", "Fancy Pink"},
1839		{"#1f8fff", "Fancy Blue"}, {"#14c46e", "Fancy Green"}, {"#d9a560", "Champagne"},
1840		{"#b4561a", "Cognac"}, {"#2b2d34", "Black"},
1841		{"#e0243a", "Red"}, {"#ff7a1a", "Orange"}, {"#9b4fd6", "Purple"},
1842	},
1843	"Pearl": {
1844		{"#f8f6f0", "White"}, {"#f0e4c8", "Cream"}, {"#3a3a3c", "Black"}, {"#c8cdd4", "Silver"},
1845		{"#d4af6a", "Gold"}, {"#f0d4dc", "Pink"}, {"#f2c9a8", "Peach"}, {"#d8cbe0", "Lavender"},
1846	},
1847}
1848
1849var colorWeights = map[string][]int{
1850	"Diamond": {30, 18, 5, 4, 3, 14, 12, 8, 1, 3, 2},
1851	"Quartz": {25, 25, 18, 9, 18, 5},
1852}
1853
1854const defaultColorWeight = 5
1855
1856func colorWeight(stone string, idx int) int {
1857	w, ok := colorWeights[stone]
1858	if !ok {
1859		return 1
1860	}
1861	if idx < len(w) {
1862		return w[idx]
1863	}
1864	return defaultColorWeight
1865}
1866
1867func PickColorIndex(stone string, roll uint64) int {
1868	n := ColorPaletteSize(stone)
1869	total := 0
1870	for i := 0; i < n; i++ {
1871		total += colorWeight(stone, i)
1872	}
1873	r := int(roll % uint64(total))
1874	for i := 0; i < n; i++ {
1875		r -= colorWeight(stone, i)
1876		if r < 0 {
1877			return i
1878		}
1879	}
1880	return n - 1
1881}
1882
1883func ColorProbability(stone string, idx int) float64 {
1884	n := ColorPaletteSize(stone)
1885	total := 0
1886	for i := 0; i < n; i++ {
1887		total += colorWeight(stone, i)
1888	}
1889	return float64(colorWeight(stone, idx)) / float64(total)
1890}
1891
1892func ColorRarityCSV(stone string) string {
1893	n := ColorPaletteSize(stone)
1894	total := 0
1895	for i := 0; i < n; i++ {
1896		total += colorWeight(stone, i)
1897	}
1898	out := make([]string, n)
1899	for i := 0; i < n; i++ {
1900		pm := colorWeight(stone, i) * 1000 / total
1901		out[i] = colorForStone(stone, i).name + ":" + ufmt.Sprintf("%d.%d", pm/10, pm%10)
1902	}
1903	return strings.Join(out, ";")
1904}
1905
1906func ChainAlwaysActive(chain string) bool { return chain == "GNOLAND-1" }
1907
1908var stoneNames = []string{"Sapphire", "Topaz", "Pearl", "Diamond", "Quartz"}
1909
1910func registerStoneName(stone string) {
1911	for _, s := range stoneNames {
1912		if s == stone {
1913			return
1914		}
1915	}
1916	stoneNames = append(stoneNames, stone)
1917}
1918
1919func SetStoneForChain(chainName, stone string) {
1920	stoneByChain[chainName] = stone
1921	registerStoneName(stone)
1922}
1923
1924func AddStoneColor(stone, hex, name string) {
1925	stonePalettes[stone] = append(stonePalettes[stone], namedColor{hex: hex, name: name})
1926	registerStoneName(stone)
1927}
1928
1929func StoneNamesCSV() string {
1930	return strings.Join(stoneNames, ";")
1931}
1932
1933func ColorPaletteCSV(stone string) string {
1934	palette := stonePalettes[stone]
1935	pairs := make([]string, len(palette))
1936	for i, c := range palette {
1937		pairs[i] = c.hex + ":" + c.name
1938	}
1939	return strings.Join(pairs, ";")
1940}
1941
1942func MetalToneHex(metalName string) string {
1943	t, ok := metals[metalName]
1944	if !ok {
1945		return ""
1946	}
1947	return t.hi + ";" + t.lo
1948}
1949
1950func StoneForChain(chain string) string {
1951	if s, ok := stoneByChain[chain]; ok {
1952		return s
1953	}
1954	return "Quartz"
1955}
1956
1957func colorForStone(stone string, colorIdx int) namedColor {
1958	palette := stonePalettes[stone]
1959	if len(palette) == 0 {
1960		palette = stonePalettes["Quartz"]
1961	}
1962	if colorIdx < 0 {
1963		colorIdx = 0
1964	}
1965	if colorIdx >= len(palette) {
1966		colorIdx = len(palette) - 1
1967	}
1968	return palette[colorIdx]
1969}
1970
1971func ColorPaletteSize(stone string) int {
1972	if n := len(stonePalettes[stone]); n > 0 {
1973		return n
1974	}
1975	return len(stonePalettes["Quartz"])
1976}
1977
1978func FindShapeIndex(stone, name string) (idx int, ok bool) {
1979	names := ShapeNames
1980	if stone == "Pearl" {
1981		names = PearlShapeNames
1982	}
1983	for i, s := range names {
1984		if s == name {
1985			return i, true
1986		}
1987	}
1988	return -1, false
1989}
1990
1991func FindColorIndex(chain, colorName string) (idx int, ok bool) {
1992	stone := StoneForChain(chain)
1993	palette := stonePalettes[stone]
1994	if len(palette) == 0 {
1995		palette = stonePalettes["Quartz"]
1996	}
1997	for i, c := range palette {
1998		if c.name == colorName {
1999			return i, true
2000		}
2001	}
2002	return -1, false
2003}
2004
2005type metalTone struct{ hi, lo string }
2006
2007var metals = map[string]metalTone{
2008	"Gold":     {"#a48b3f", "#6a5117"},
2009	"Silver":   {"#95989c", "#5e646c"},
2010	"Bronze":   {"#865b34", "#52391d"},
2011	"Platinum": {"#c3c6c8", "#87909a"},
2012	"Obsidian": {"#3a3d47", "#0d0e12"},
2013}
2014
2015var MetalNames = []string{"Gold", "Silver", "Bronze", "Platinum", "Obsidian"}
2016
2017type metalLabelTone struct{ bg, text string }
2018
2019var metalLabel = map[string]metalLabelTone{
2020	"Gold":     {"#f4d060", "#04121a"},
2021	"Silver":   {"#dfe4ea", "#04121a"},
2022	"Bronze":   {"#c9884f", "#04121a"},
2023	"Platinum": {"#eef2f5", "#04121a"},
2024	"Obsidian": {"#5a5f6b", "#e8e9f0"},
2025}
2026
2027func RenderTokenThumbSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) string {
2028	svg, _, _, _ := renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, true)
2029	return svg
2030}
2031
2032func RenderTokenSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) (svg string, stone string, colorHex string, colorName string) {
2033	return renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, false)
2034}
2035
2036func renderToken(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string, thumb bool) (svg string, stone string, colorHex string, colorName string) {
2037	var cascadeCSS string
2038	stone = StoneForChain(chain)
2039	if ChainAlwaysActive(chain) {
2040		active = true
2041	}
2042	nc := colorForStone(stone, colorIdx)
2043	colorHex, colorName = nc.hex, nc.name
2044	mt := metals[metal]
2045
2046	ringEls := renderRing(mt.hi, mt.lo, tidStr)
2047	var pieceMarkup string
2048	if stone == "Pearl" {
2049		pearl := renderPearlShape(shapeIdx, colorHex, mt.hi, tidStr)
2050		overlay, css := applyCascade(pearl, tidStr, nil)
2051		pieceMarkup, cascadeCSS = pearl+overlay, css
2052	} else {
2053		fs := &facetStyle{prof: fireProfileFor(stone, colorName), zone: newPRNG("zone" + tidStr), stone: stone, thumb: thumb}
2054		under, facets, over := renderSolid3D(shapeIdx, colorHex, tidStr, fs)
2055		overlay, css := applyCascade(facets, tidStr, fs)
2056		cascadeCSS = css
2057		pieceMarkup = under + `<g fill-opacity=".9">` + facets + `</g>` + overlay + over
2058	}
2059	floatingPiece := applyMotion(pieceMarkup, tidStr)
2060	status := renderStatusBadge(active)
2061	serial := renderSerialText(tidStr, mt.hi)
2062	labelText := metalLabel[metal].text
2063	labelRect := renderMetalLabelRect(mt.hi, mt.lo, 24, 134, 122, 15, 3, tidStr)
2064
2065	inner := `<rect width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" fill="#0a0b10"/>` +
2066		renderStars(tidStr) + ringEls + floatingPiece + status + serial + labelRect +
2067		renderPlateText(chain+" CHAIN", labelText, mt.hi)
2068
2069	svg = `<svg xmlns="http://www.w3.org/2000/svg" width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" viewBox="0 0 ` + fnum(canvasW) + ` ` + fnum(canvasH) + `">` + styleBlock() +
2070		`<style>` + cascadeCSS + `</style>` + inner + `</svg>`
2071	return svg, stone, colorHex, colorName
2072}
2073
2074func styleBlock() string {
2075	const easeOut = "cubic-bezier(0,0,.3,1)"
2076	return `<style>.gt-v,.gt-h,.gt-r,.gt-s{will-change:transform}` +
2077
2078		`.gt-v{animation:gt-v var(--v-dur) ease-in-out var(--v-delay) infinite}` +
2079		`.gt-h{animation:gt-h var(--h-dur) ease-in-out var(--h-delay) infinite}` +
2080		`.gt-r{animation:gt-r var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2081		`.gt-s{animation:gt-s var(--s-dur) ease-in-out var(--s-delay) infinite}` +
2082		`.gt-glow{animation:gt-glow var(--glow-dur) ease-in-out var(--glow-delay) infinite}` +
2083		`.gt-la{animation:gt-la var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2084		`.gt-lb{animation:gt-lb var(--r-dur) ease-in-out var(--r-delay) infinite}` +
2085		`@keyframes gt-la{0%,50%,100%{opacity:0}25%{opacity:1}}` +
2086		`@keyframes gt-lb{0%,50%,100%{opacity:0}75%{opacity:1}}` +
2087		`.gt-badge-pulse{animation:gt-bp 1.8s ` + easeOut + ` infinite}` +
2088		`@keyframes gt-v{0%,100%{transform:translateY(2.5px)}50%{transform:translateY(-2.5px)}}` +
2089		`@keyframes gt-h{50%{transform:translateX(1.8px)}}` +
2090		`@keyframes gt-r{25%{transform:rotate(calc(-1*var(--rot-deg)))}75%{transform:rotate(var(--rot-deg))}}` +
2091		`@keyframes gt-s{50%{transform:scale(` + fnum(motionScale) + `)}}` +
2092		`.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}}` +
2093		`@keyframes gt-glow{50%{opacity:1}}` +
2094		`@keyframes gt-bp{from{transform:scale(1);opacity:.8}to{transform:scale(2.25);opacity:0}}` +
2095		`</style>`
2096}