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

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}