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