1// Package gemsartfab: on-chain SVG generator for the "gem in a ring"2// collection -- the "fab" iteration of gemsart2, re-lit to read as a3// transparent, polished crystal instead of a flat matte cutout. Same4// geometry, same traits, same cascade schedule; what changed is only5// how a faceted stone is painted (see the gem shapes section). Two hard6// constraints shaped every choice there: bytes per token (a token is7// re-rendered on every TokenURI/Render read and shipped inline) and8// WebKit rendering cost -- this project has twice fixed real Safari9// slowdowns by removing SVG <filter>s and SMIL, so the new look uses10// only static gradients, paths, one inherited stroke, and CSS opacity11// keyframes. Nothing else.12//13// Originally ported from the Python prototype used to design and14// approve the first look. Every function here is pure — no state reads15// beyond its own stone/color tables — so a token's full image can be16// recomputed on any read (TokenURI/Render) instead of being stored as a17// precomputed blob.18//19// A separate deploy from the nftminter realm that imports it (not one20// combined package as originally designed) -- gnomcp's own gno_addpkg21// tool hardcodes a 10,000,000 ugnot max-deposit ceiling per deploy call22// with no override, and the combined source (~175KB) needs ~17.6M. Each23// half fits under that ceiling deployed separately. The public surface24// below (ShapeNames/MetalNames/RenderTokenSVG/StoneForChain/25// ColorPaletteSize, plus SetStoneForChain/AddStoneColor/StoneNamesCSV/26// ColorPaletteCSV/MetalToneHex) exists specifically so nftminter.gno can27// still reach everything it needs across that package boundary.28//29// This is the architecture change requested explicitly, back when this30// was still one file: instead of the owner precomputing and storing a31// full ~7.6KB image string per token, this package's shared generator32// code is stored ONCE, and each token only needs a handful of small33// trait selections (see traitPreset in nftminter.gno) — the image is34// assembled from those on demand. Storage cost stops scaling with token35// count; it's dominated by this package's own one-time deployment cost36// instead.37package gemsart3d23839import (40 "crypto/sha256"41 "math"42 "strings"4344 "gno.land/p/nt/ufmt/v0"45)4647const (48 canvasW, canvasH = 170.0, 170.049 subjX, subjY = 85.0, 78.050)5152/* ------------------------------ number/color formatting ------------------------------ */5354// fnum formats a float to 2 decimal places without depending on ufmt55// supporting %f/%g (uncertain in this environment) — plain integer math56// and %d instead. Zero-pads the fraction manually rather than via57// "%02d": ufmt.Sprintf does NOT implement width/zero-pad flags (confirmed58// empirically -- Sprintf("%d.%02d", 1, 9) returns "1.9", not "1.09"), so59// relying on "%02d" silently corrupted any value whose hundredths digit60// rounded to 1-9 (e.g. 1.09 rendered as "1.9", a ~10x error) throughout61// this whole file, since every coordinate/radius/angle goes through this62// one function.63// itoa writes a non-negative integer's digits. Every number in a token64// goes through here: ufmt.Sprintf is general-purpose and, on-chain, most65// of a render's gas went to formatting coordinates with it.66func itoa(v int64) string {67 if v == 0 {68 return "0"69 }70 neg := v < 071 if neg {72 v = -v73 }74 var b [20]byte75 i := len(b)76 for v > 0 {77 i--78 b[i] = byte('0' + v%10)79 v /= 1080 }81 if neg {82 i--83 b[i] = '-'84 }85 return string(b[i:])86}8788// fnum is the compact form every emitter uses: two decimals at most,89// trailing zeros and a leading "0" dropped ("85.00" -> "85", "0.45" ->90// ".45"). Valid in SVG attributes and CSS alike; a quarter of a token's91// bytes were padding before this.92func fnum(v float64) string { return cnum(v) }9394func fnumPad(v float64) string {95 neg := v < 096 if neg {97 v = -v98 }99 scaled := int64(v*100 + 0.5)100 whole := scaled / 100101 frac := scaled % 100102 fracStr := itoa(frac)103 if frac < 10 {104 fracStr = "0" + fracStr105 }106 s := itoa(whole) + "." + fracStr107 if neg {108 return "-" + s109 }110 return s111}112113// fnum1 is fnum at one decimal -- for keyframe percentages, where the114// second decimal is a fraction of a frame and only costs bytes (the115// keyframe CSS is the largest slice of every token).116// cnum is fnum without padding: trailing zeros and a leading "0" before117// the point are dropped ("0.45" -> ".45", "85.00" -> "85"). Used by the118// ring and plate markup, which is emitted on every token read.119func cnum(v float64) string {120 s := fnumPad(v)121 if strings.Contains(s, ".") {122 s = strings.TrimRight(strings.TrimRight(s, "0"), ".")123 }124 if strings.HasPrefix(s, "0.") {125 s = s[1:]126 } else if strings.HasPrefix(s, "-0.") {127 s = "-" + s[2:]128 }129 return s130}131132func fnum1(v float64) string {133 neg := v < 0134 if neg {135 v = -v136 }137 scaled := int64(v*10 + 0.5)138 s := itoa(scaled/10) + "." + itoa(scaled%10)139 if neg {140 return "-" + s141 }142 return s143}144145// pctnum formats a float already meant as a percentage (2 decimals, no146// clamping — callers clamp first where it matters).147func pctnum(v float64) string { return fnum(v) }148149func hexToRGB(hex string) (int, int, int) {150 h := hex151 if len(h) > 0 && h[0] == '#' {152 h = h[1:]153 }154 r := hexByte(h[0:2])155 g := hexByte(h[2:4])156 b := hexByte(h[4:6])157 return r, g, b158}159160func hexByte(s string) int {161 v := 0162 for i := 0; i < len(s); i++ {163 c := s[i]164 var d int165 switch {166 case c >= '0' && c <= '9':167 d = int(c - '0')168 case c >= 'a' && c <= 'f':169 d = int(c-'a') + 10170 case c >= 'A' && c <= 'F':171 d = int(c-'A') + 10172 }173 v = v*16 + d174 }175 return v176}177178func rgbToHex(r, g, b int) string {179 return "#" + hexDigit2(r) + hexDigit2(g) + hexDigit2(b)180}181182func hexDigit2(v int) string {183 if v < 0 {184 v = 0185 }186 if v > 255 {187 v = 255188 }189 const digits = "0123456789abcdef"190 return string(digits[v/16]) + string(digits[v%16])191}192193func clampByte(v float64) int {194 iv := int(v)195 if iv < 0 {196 return 0197 }198 if iv > 255 {199 return 255200 }201 return iv202}203204// lighten blends hex toward white by amt (0-1) — used so a neon glow's205// core reads as genuinely brighter than its own base color, not just a206// higher-opacity version of the same tone.207func lighten(hex string, amt float64) string {208 r, g, b := hexToRGB(hex)209 nr := clampByte(float64(r) + (255-float64(r))*amt)210 ng := clampByte(float64(g) + (255-float64(g))*amt)211 nb := clampByte(float64(b) + (255-float64(b))*amt)212 return rgbToHex(nr, ng, nb)213}214215// mixHex blends hex toward target by amt (0-1) — used to soften a hard216// two-color gradient stop into an in-between tone, e.g. the label bar's217// muted echo of the ring's own multi-band highlight/shadow swing.218func mixHex(hex, target string, amt float64) string {219 r1, g1, b1 := hexToRGB(hex)220 r2, g2, b2 := hexToRGB(target)221 nr := clampByte(float64(r1) + float64(r2-r1)*amt)222 ng := clampByte(float64(g1) + float64(g2-g1)*amt)223 nb := clampByte(float64(b1) + float64(b2-b1)*amt)224 return rgbToHex(nr, ng, nb)225}226227// hueRotate turns hex's hue by deg degrees -- the exact matrix SVG's228// feColorMatrix type="hueRotate" applies, done here once at render so229// no live filter is ever needed. Used for colour zoning: a real crystal230// is not one hue, it drifts a few degrees from facet to facet.231func hueRotate(hex string, deg float64) string {232 c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)233 r, g, b := hexToRGB(hex)234 fr, fg, fb := float64(r), float64(g), float64(b)235 nr := (0.213+c*0.787-sn*0.213)*fr + (0.715-c*0.715-sn*0.715)*fg + (0.072-c*0.072+sn*0.928)*fb236 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)*fb237 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)*fb238 return rgbToHex(clampByte(nr), clampByte(ng), clampByte(nb))239}240241// shadeSeries returns n shades of hex scaled between lo and hi brightness242// — the palette a faceted gem's individual facets pick from so adjacent243// triangles/quads read as catching light differently.244func shadeSeries(hex string, n int, lo, hi float64) []string {245 r, g, b := hexToRGB(hex)246 out := make([]string, n)247 for i := 0; i < n; i++ {248 t := lo249 if n > 1 {250 t = lo + (hi-lo)*float64(i)/float64(n-1)251 }252 out[i] = rgbToHex(clampByte(float64(r)*t), clampByte(float64(g)*t), clampByte(float64(b)*t))253 }254 return out255}256257/* --------------------------------- tiny deterministic PRNG --------------------------------- */258259// prng is a small deterministic generator seeded from a string, used only260// for per-piece COSMETIC animation timing (glow phase/duration, motion261// phases/rotation degree) — never for which trait combination a piece262// has (that's traitPreset, randomly assembled by nftminter.gno's own263// separate PRNG in randomTraitPreset, not curated and not this one).264// Determinism here doesn't weaken the anti-sniping design at all: knowing265// in advance that a glow pulses at 2.1s instead of 1.9s isn't a trait266// anyone would snipe for.267type prng struct {268 seed string269 n int270}271272func newPRNG(seed string) *prng { return &prng{seed: seed} }273274func (p *prng) next() uint64 {275 p.n++276 material := p.seed + ":" + itoa(int64(p.n)) // same bytes as "%s:%d"277 sum := sha256.Sum256([]byte(material))278 var v uint64279 for i := 0; i < 8; i++ {280 v = v<<8 | uint64(sum[i])281 }282 return v283}284285func (p *prng) floatIn(lo, hi float64) float64 {286 frac := float64(p.next()%1000000) / 1000000.0287 return lo + frac*(hi-lo)288}289290func (p *prng) intn(n int) int {291 if n <= 1 {292 return 0293 }294 return int(p.next() % uint64(n))295}296297/* ------------------------------------ path helpers ------------------------------------ */298299type pt struct{ x, y float64 }300301func polyPath(pts []pt) string {302 s := "M"303 for i, p := range pts {304 if i > 0 {305 s += " L"306 }307 s += fnum(p.x) + "," + fnum(p.y)308 }309 return s + " Z"310}311312func polygonPoints(pts []pt) string {313 s := ""314 for i, p := range pts {315 if i > 0 {316 s += " "317 }318 s += fnum(p.x) + "," + fnum(p.y)319 }320 return s321}322323/* ------------------------------------ gem shapes ------------------------------------ */324// Stones are real 3D cuts, not flat diagrams. Each cut is a small solid325// (table, crown break, girdle, pavilion, culet) built from rings of326// vertices on the cut's own outline, tilted toward the viewer and327// projected. Every facet is shaded from its actual surface normal under328// one light, the pavilion's back facets are drawn first and show through329// the front ones, and the table is a window onto them -- which is what330// finally makes the stone read as transparent: there is now something331// behind it to see. Everything is still flat solid fills (one body-glow332// gradient per stone): the realism comes from geometry and normals, not333// from paint effects, so Safari pays nothing extra for it.334//335// Bytes are budgeted, this runs on every token read: relative path336// coordinates at one decimal, 3-digit hex colours (a facet is one flat337// tone, 12 bits is plenty), shared attributes hoisted onto groups, the338// girdle as ONE stroke, hidden crown back-faces never emitted, and the339// fire capped at ~14 overlays per stone however many facets a cut has.340341type gstop struct {342 pct float64343 color string344 op float64345}346347// gramp is a linear colour ramp in absolute coordinates, sampled by348// projection -- used for the spectral fire so each facet flashes the349// band of the spectrum it sits in.350type gramp struct {351 x1, y1, x2, y2 float64352 stops []gstop353}354355func (g gramp) sample(px, py float64) (string, float64) {356 dx, dy := g.x2-g.x1, g.y2-g.y1357 t := ((px-g.x1)*dx + (py-g.y1)*dy) / (dx*dx + dy*dy) * 100358 if t < 0 {359 t = 0360 }361 if t > 100 {362 t = 100363 }364 for i := 1; i < len(g.stops); i++ {365 if t <= g.stops[i].pct {366 a, b := g.stops[i-1], g.stops[i]367 f := (t - a.pct) / (b.pct - a.pct)368 return mixHex(a.color, b.color, f), a.op + (b.op-a.op)*f369 }370 }371 last := g.stops[len(g.stops)-1]372 return last.color, last.op373}374375func centroid(poly []pt) (float64, float64) {376 cx, cy := 0.0, 0.0377 for _, p := range poly {378 cx += p.x379 cy += p.y380 }381 return cx / float64(len(poly)), cy / float64(len(poly))382}383384// facetStyle is what the fire needs from the stone: each lit-capable385// facet's 2D centroid in draw order, the spectral ramp, and the stone's386// optical profile.387type facetStyle struct {388 flash gramp389 ringCent []pt390 prof fireProfile391 zone *prng // per-facet hue drift and shade jitter, seeded per token392 stone string // which crystal habit a Raw Crystal takes393 // thumb: a list thumbnail -- skips what does not read at thumbnail394 // size (fire flashes, rock relighting, table reflections) to cut395 // render gas. The stone, its facets and colours are identical.396 thumb bool397 // glints: star sparkles applyCascade builds, drawn above everything398 // else on the stone (after the table reflections) so nothing dims them.399 glints string400}401402// fireProfile is how a stone's optics differ by what it is. Dispersion403// is a property of the material: colourless stones throw strong404// spectral fire, sapphire much less, topaz less still. shadow is what a405// facet darkens toward; bodyMix/neutral rebuild the working base for406// stones whose palette hex is near-white (see fireProfileFor).407type fireProfile struct {408 sat, hit float64409 pinpoints int410 shadow string411 bodyMix float64412 neutral string413}414415func fireProfileFor(stone, colorName string) fireProfile {416 if stone == "Diamond" {417 // dispersion 0.044, the highest of any stone here: every colour418 // throws strong spectral fire, the colourless one most of all.419 // Black diamond is opaque: shine only, almost no fire.420 switch colorName {421 case "Colorless":422 // a light body mix: a diamond is a contrast of near-white423 // and near-black facets, not a mid-grey (0.5 read as slate)424 return fireProfile{1.0, 0.85, 5, "#0d1222", 0.12, "#f4f7fc"}425 case "Black":426 return fireProfile{0.15, 0.35, 1, "#000000", 0, ""}427 }428 return fireProfile{0.95, 0.7, 4, "#0b0d18", 0, ""}429 }430 if stone == "Quartz" {431 // dispersion 0.013, the lowest here: faint fire, mostly glassy shine432 if colorName == "Rock Crystal" {433 return fireProfile{0.4, 0.6, 3, "#141a2c", 0.25, "#e8edf4"}434 }435 return fireProfile{0.35, 0.5, 3, "#000000", 0, ""}436 }437 if colorName == "Colorless" {438 return fireProfile{1.0, 0.7, 4, "#141a2c", 0.55, "#dfe4ee"}439 }440 if colorName == "White" {441 return fireProfile{0.85, 0.6, 3, "#7d86a0", 0, ""}442 }443 if colorName == "Crystal" {444 return fireProfile{1.0, 0.78, 4, "#141a2c", 0.3, "#e4e9f2"}445 }446 if stone == "Topaz" {447 return fireProfile{0.5, 0.5, 3, "#000000", 0, ""}448 }449 return fireProfile{0.7, 0.5, 3, "#000000", 0, ""}450}451452const fireZoneDeg = 7.0453454// rgbI is a colour as three 0-255 ints. The facet shading works on these455// instead of "#rrggbb" strings: parsing and re-formatting a hex string at456// every mix was a large share of a render's gas. Each op rounds exactly457// as its hex-string twin does (clampByte per channel), so the output is458// byte-identical.459type rgbI struct{ r, g, b int }460461func toRGBI(hex string) rgbI { r, g, b := hexToRGB(hex); return rgbI{r, g, b} }462463func (c rgbI) hex() string { return rgbToHex(c.r, c.g, c.b) }464465func mixI(a, b rgbI, amt float64) rgbI {466 return rgbI{467 clampByte(float64(a.r) + float64(b.r-a.r)*amt),468 clampByte(float64(a.g) + float64(b.g-a.g)*amt),469 clampByte(float64(a.b) + float64(b.b-a.b)*amt),470 }471}472473func hueRotateI(x rgbI, deg float64) rgbI {474 c, sn := math.Cos(deg*math.Pi/180), math.Sin(deg*math.Pi/180)475 fr, fg, fb := float64(x.r), float64(x.g), float64(x.b)476 nr := (0.213+c*0.787-sn*0.213)*fr + (0.715-c*0.715-sn*0.715)*fg + (0.072-c*0.072+sn*0.928)*fb477 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)*fb478 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)*fb479 return rgbI{clampByte(nr), clampByte(ng), clampByte(nb)}480}481482func hex3I(x rgbI) string {483 d := "0123456789abcdef"484 q := func(v int) byte { return d[(v+8)*15/255%16] }485 return "#" + string([]byte{q(x.r), q(x.g), q(x.b)})486}487488var whiteI = rgbI{255, 255, 255}489490func darken(hex, shadow string, amt float64) string {491 if amt > 0.95 {492 amt = 0.95493 }494 return mixHex(hex, shadow, amt)495}496497const prismFlash = true498499func flashRamp(ext, sat float64) gramp {500 r := gramp{x1: subjX - ext, y1: subjY - ext, x2: subjX + ext, y2: subjY + ext}501 if !prismFlash {502 r.stops = []gstop{{0, "#ffffff", 1}, {100, "#ffffff", 0.25}}503 return r504 }505 r.stops = []gstop{506 {0, "#ffffff", 1}, {14, "#ff4040", 0.95}, {28, "#ff9a1f", 0.95}, {42, "#ffe81f", 0.9},507 {56, "#35ff7a", 0.85}, {70, "#22ccff", 0.8}, {84, "#5566ff", 0.75}, {100, "#c04dff", 0.7},508 }509 for i := range r.stops {510 r.stops[i].color = mixHex(r.stops[i].color, "#ffffff", 1-sat)511 }512 return r513}514515/* ---- tiny vector maths ---- */516517type v3 struct{ x, y, z float64 }518519func vsub(a, b v3) v3 { return v3{a.x - b.x, a.y - b.y, a.z - b.z} }520func vdot(a, b v3) float64 { return a.x*b.x + a.y*b.y + a.z*b.z }521func vcross(a, b v3) v3 { return v3{a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x} }522func vscale(a v3, k float64) v3 { return v3{a.x * k, a.y * k, a.z * k} }523func vnorm(a v3) v3 {524 l := math.Sqrt(vdot(a, a))525 if l == 0 {526 return a527 }528 return v3{a.x / l, a.y / l, a.z / l}529}530531func sgnPow(v, e float64) float64 {532 if v < 0 {533 return -math.Pow(-v, e)534 }535 return math.Pow(v, e)536}537538/* ---- outlines: a closed curve t in [0,1) -> (x, y) in [-1,1] ---- */539540type outlineFn func(t float64) (float64, float64)541542func circleOutline(t float64) (float64, float64) {543 a := 2 * math.Pi * t544 return math.Cos(a), math.Sin(a)545}546547// superOutline is a superellipse: p=2 is an ellipse, higher p squares548// the shoulders -- a cushion.549func superOutline(p float64) outlineFn {550 return func(t float64) (float64, float64) {551 a := 2 * math.Pi * t552 return sgnPow(math.Cos(a), 2/p), sgnPow(math.Sin(a), 2/p)553 }554}555556// polyOutline walks a polygon by vertex index: t=k/len lands exactly on557// vertex k, so step cuts keep their true corners at any ring size.558func polyOutline(vs []pt) outlineFn {559 return func(t float64) (float64, float64) {560 n := len(vs)561 f := t * float64(n)562 k := int(math.Floor(f))563 fr := f - float64(k)564 a := vs[((k%n)+n)%n]565 b := vs[(((k+1)%n)+n)%n]566 return a.x + (b.x-a.x)*fr, a.y + (b.y-a.y)*fr567 }568}569570// octagon is a rectangle hw x hh with its corners cut by c.571func octagon(hw, hh, c float64) []pt {572 return []pt{573 {hw - c, hh}, {-hw + c, hh}, {-hw, hh - c}, {-hw, -hh + c},574 {-hw + c, -hh}, {hw - c, -hh}, {hw, -hh + c}, {hw, hh - c},575 }576}577578func pearOutline(t float64) (float64, float64) {579 a := 2 * math.Pi * t580 c := math.Cos(a)581 return c*0.92 + 0.08, 0.7 * math.Sin(a) * (1 - 0.46*c)582}583584func marquiseOutline(t float64) (float64, float64) {585 a := 2 * math.Pi * t586 return math.Cos(a), 0.52 * sgnPow(math.Sin(a), 1.35)587}588589// heartOutline is a heart CUT, not a valentine: full rounded lobes, a590// shallow notch (~14% of the length; the valentine curve's 24% read as a591// drawing), gently convex sides and a clean point, as a heart-cut592// diamond is. Notch at t=0 (far side, +y), point at t=.5 (near side),593// normalised to [-1,1]; the cut's rx/ry set its width-to-length ratio594// (real heart cuts run 0.9-1.1).595func heartOutline(t float64) (float64, float64) {596 a := 2 * math.Pi * t597 sn := math.Sin(a)598 y := (13*math.Cos(a) - 4*math.Cos(2*a) - 1.4*math.Cos(3*a) - 0.5*math.Cos(4*a)) / 17599 return 0.88*sn*sn*sn + 0.12*sn, (y + 0.1576) / 0.7894600}601602func trillionOutline(t float64) (float64, float64) {603 vs := []pt{{0, 1}, {-0.866, -0.5}, {0.866, -0.5}}604 f := t * 3605 k := int(math.Floor(f))606 fr := f - float64(k)607 a, b := vs[((k%3)+3)%3], vs[(((k+1)%3)+3)%3]608 bulge := 1 + 0.16*math.Sin(math.Pi*fr)609 return (a.x + (b.x-a.x)*fr) * bulge, (a.y + (b.y-a.y)*fr) * bulge610}611612/* ---- the cuts ---- */613614// cutSpec is one cut: its outline, how many table vertices it has, and615// whether it is a brilliant (triangular/kite facets radiating from the616// table, girdle at twice the table's vertex count) or a step cut617// (concentric rows of long rectangular facets).618type cutSpec struct {619 n int620 brilliant bool621 outline outlineFn622 rx, ry float64623}624625// ShapeNames is the faceted-cut vocabulary, in index order. Heart, Rose626// Cut and Raw Crystal were dropped from the first 3D pass and are back627// (appended, so the first ten keep their indexes), each only on the628// stones that are really sold that way -- see CutsForStone. Raw Crystal629// is the stone's own natural crystal, not a cut: an octahedron for630// diamond, a hexagonal barrel for sapphire, a prism with a pyramid tip631// and a flat cleavage break for topaz (buildRaw).632var ShapeNames = []string{633 "Round Brilliant", "Oval", "Cushion", "Emerald Cut", "Asscher",634 "Radiant", "Princess Cut", "Pear", "Marquise", "Trillion",635 "Heart", "Rose Cut", "Raw Crystal",636}637638const (639 shapeHeart = 10640 shapeRose = 11641 shapeRaw = 12642)643644// stoneScale enlarges every stone and pearl inside the ring opening:645// 1.22 (the first gemsart3d2 pass) crowded the ring; 0.98 was 4/5 of646// that, and 0.78 is 4/5 again (2026-09-26, per request). Then 1.02 with647// the ring drawn 10% larger, and finally 1.21 with the ring 16% larger648// (size "D+" of the size trials, chosen 2026-09-26; see ringScale).649const stoneScale = 1.21650651func cutFor(shapeIdx int) cutSpec {652 c := cutForUnscaled(shapeIdx)653 c.rx, c.ry = c.rx*stoneScale, c.ry*stoneScale654 return c655}656657func cutForUnscaled(shapeIdx int) cutSpec {658 switch shapeIdx {659 case 1:660 return cutSpec{8, true, circleOutline, 30, 21.5}661 case 2:662 return cutSpec{8, true, superOutline(3.4), 27, 24}663 case 3:664 // Emerald Cut: 10% smaller than the other cuts' scale (was 30 x 21);665 // its long step-cut outline read larger than the rest at 1.21666 return cutSpec{8, false, polyOutline(octagon(1, 1, 0.3)), 27, 18.9}667 case 4:668 return cutSpec{8, false, polyOutline(octagon(1, 1, 0.36)), 25, 25}669 case 5:670 return cutSpec{8, true, polyOutline(octagon(1, 1, 0.3)), 29, 22.5}671 case 6:672 return cutSpec{8, true, polyOutline([]pt{{1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}, {1, 0}}), 23.5, 23.5}673 case 7:674 return cutSpec{8, true, pearOutline, 31, 27}675 case 8:676 return cutSpec{8, true, marquiseOutline, 33, 30}677 case 9:678 return cutSpec{9, true, trillionOutline, 27, 27}679 case shapeHeart:680 return cutSpec{10, true, heartOutline, 29, 28}681 case shapeRose:682 return cutSpec{6, true, circleOutline, 27, 27}683 case shapeRaw:684 return cutSpec{0, false, circleOutline, 27, 27} // sized per stone in buildRaw685 }686 return cutSpec{8, true, circleOutline, 27, 27}687}688689// CutsForStone is which cuts a stone is really sold in -- the cuts the690// trade actually produces for it in quantity, not every cut that has691// ever been tried. A mint picks shapeIdx from this slice.692// Diamond: every cut here; brilliance cuts (Radiant, Princess, Asscher)693// were invented for it, Rose Cut is its oldest faceted form, and raw694// octahedra are set in jewellery as they come from the mine.695// Sapphire: rough favours ovals and cushions (weight retention), then696// rounds, emerald cuts, pears, hearts, marquises, trillions (side697// stones), princess (calibrated squares), rose cuts, raw barrels.698// Asscher and Radiant are diamond-optimised and rare in sapphire.699// Topaz: crystals are long prisms, so emerald cuts, ovals, pears,700// cushions, marquises, then rounds, trillions, hearts, princess, and701// raw crystals (topaz is one of the classic collector crystals).702// Quartz (every chain without its own stone): cut in every style (it703// is the lapidary's everyday stone), rose-cut amethyst is a classic,704// and the hexagonal quartz point is THE raw crystal.705func CutsForStone(stone string) []int {706 switch stone {707 case "Diamond":708 return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}709 case "Sapphire":710 return []int{1, 2, 0, 3, 7, shapeHeart, 8, 9, 6, shapeRose, shapeRaw}711 case "Topaz":712 return []int{3, 1, 7, 2, 8, 0, 9, shapeHeart, 6, shapeRaw}713 }714 return []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, shapeHeart, shapeRose, shapeRaw}715}716717const (718 kCrown = iota719 kTable720 kPav721)722723type face struct {724 p []v3725 kind int726}727728func ringPts(c cutSpec, n int, scale, z, off float64) []v3 {729 out := make([]v3, n)730 for k := 0; k < n; k++ {731 x, y := c.outline((float64(k) + off) / float64(n))732 out[k] = v3{x * c.rx * scale, y * c.ry * scale, z}733 }734 return out735}736737// buildCut returns the solid's faces plus its girdle ring (drawn as one738// stroke, not as faces). Proportions follow a real stone: table ~56% of739// the width, crown ~1/3 as tall as the pavilion is deep.740func buildCut(c cutSpec) (faces []face, girdle []v3) {741 base := c.rx742 if c.ry < base {743 base = c.ry744 }745 hc, hp := 0.34*base, 0.9*base746 n := c.n747 if c.brilliant {748 T := ringPts(c, n, 0.56, hc, 0)749 B := ringPts(c, n, 0.8, hc*0.46, 0.5)750 G := ringPts(c, 2*n, 1, 0, 0)751 culet := v3{0, 0, -hp}752 faces = append(faces, face{T, kTable})753 for k := 0; k < n; k++ {754 k1 := (k + 1) % n755 // star, then the kite as one quad down to the girdle756 faces = append(faces, face{[]v3{T[k], B[k], T[k1]}, kCrown})757 faces = append(faces, face{[]v3{T[k1], B[k], G[(2*k+2)%(2*n)], B[k1]}, kCrown})758 // the two upper-girdle facets under this break vertex759 faces = append(faces, face{[]v3{B[k], G[2*k], G[2*k+1]}, kCrown})760 faces = append(faces, face{[]v3{B[k], G[2*k+1], G[(2*k+2)%(2*n)]}, kCrown})761 // pavilion main, spanning two girdle segments762 faces = append(faces, face{[]v3{G[2*k], G[2*k+1], G[(2*k+2)%(2*n)], culet}, kPav})763 }764 return faces, G765 }766 T := ringPts(c, n, 0.54, hc, 0)767 S := ringPts(c, n, 0.78, hc*0.5, 0)768 G := ringPts(c, n, 1, 0, 0)769 P1 := ringPts(c, n, 0.7, -hp*0.42, 0)770 P2 := ringPts(c, n, 0.38, -hp*0.78, 0)771 K := ringPts(c, n, 0.1, -hp, 0)772 faces = append(faces, face{T, kTable})773 rows := [][2][]v3{{T, S}, {S, G}}774 for _, r := range rows {775 for k := 0; k < n; k++ {776 k1 := (k + 1) % n777 faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kCrown})778 }779 }780 prow := [][2][]v3{{G, P1}, {P1, P2}, {P2, K}}781 for _, r := range prow {782 for k := 0; k < n; k++ {783 k1 := (k + 1) % n784 faces = append(faces, face{[]v3{r[0][k], r[0][k1], r[1][k1], r[1][k]}, kPav})785 }786 }787 return faces, G788}789790// buildRose is a rose cut: a flat base and a dome of triangular facets791// rising to a point -- six at the top, twelve around the edge. No table,792// no pavilion; the flat back is the one pavilion face.793func buildRose(c cutSpec) (faces []face, girdle []v3) {794 n := c.n795 base := math.Min(c.rx, c.ry)796 h := 0.62 * base797 apex := v3{0, 0, h}798 R := ringPts(c, n, 0.55, h*0.62, 0.5)799 G := ringPts(c, 2*n, 1, 0, 0)800 for k := 0; k < n; k++ {801 k1 := (k + 1) % n802 faces = append(faces, face{[]v3{apex, R[k], R[k1]}, kCrown})803 faces = append(faces, face{[]v3{G[2*k], G[2*k+1], R[k]}, kCrown})804 faces = append(faces, face{[]v3{G[2*k+1], G[(2*k+2)%(2*n)], R[k]}, kCrown})805 faces = append(faces, face{[]v3{R[k], G[(2*k+2)%(2*n)], R[k1]}, kCrown})806 }807 back := make([]v3, len(G))808 for i := range G {809 back[len(G)-1-i] = G[i]810 }811 faces = append(faces, face{back, kPav})812 return faces, G813}814815// ringX is a ring of n points around the x axis at position x.816func ringX(n int, x, ry, rz, phase float64) []v3 {817 out := make([]v3, n)818 for k := 0; k < n; k++ {819 a := 2*math.Pi*(float64(k)+phase)/float64(n) + math.Pi/2820 out[k] = v3{x, ry * math.Cos(a), rz * math.Sin(a)}821 }822 return out823}824825// bandX joins two rings of equal size with quads.826func bandX(a, b []v3) []face {827 var fs []face828 for k := range a {829 k1 := (k + 1) % len(a)830 fs = append(fs, face{[]v3{a[k], a[k1], b[k1], b[k]}, kCrown})831 }832 return fs833}834835// capX closes a ring with one flat face (a pinacoid or a cleavage break).836func capX(r []v3) face { return face{r, kCrown} }837838// turnFaces rotates every vertex about x by ax, then about z by az839// (radians): a rough crystal is set at an angle that shows several of its840// faces, not square-on to the viewer.841func turnFaces(fs []face, ax, az float64) []face {842 cx, sx, cz, sz := math.Cos(ax), math.Sin(ax), math.Cos(az), math.Sin(az)843 for i := range fs {844 // A rough crystal is translucent: lit from inside like a845 // pavilion, and its far faces show through the near ones846 // (renderGem3D draws back-facing kPav faces).847 fs[i].kind = kPav848 for j, p := range fs[i].p {849 y, z := p.y*cx-p.z*sx, p.y*sx+p.z*cx850 fs[i].p[j] = v3{p.x*cz - y*sz, p.x*sz + y*cz, z}851 }852 }853 return fs854}855856// buildRaw is the stone's natural crystal, lying on its side as it would857// in a raw-crystal setting. Rough crystals have no table and no girdle,858// and none is perfectly regular: each habit is stretched a little on one859// axis.860func buildRaw(stone string) (faces []face, girdle []v3) {861 deg := math.Pi / 180862 switch stone {863 case "Diamond":864 // the octahedron, diamond's commonest habit, tipped so four or five865 // of its eight faces show866 a, b, h := 25.0*stoneScale, 22.0*stoneScale, 23.0*stoneScale867 v := []v3{{a, 0, 0}, {0, b, 0}, {-a, 0, 0}, {0, -b, 0}}868 top, bot := v3{0, 0, h}, v3{0, 0, -h}869 for k := 0; k < 4; k++ {870 k1 := (k + 1) % 4871 faces = append(faces, face{[]v3{top, v[k], v[k1]}, kCrown})872 faces = append(faces, face{[]v3{bot, v[k1], v[k]}, kCrown})873 }874 return turnFaces(faces, -38*deg, 28*deg), nil875 case "Sapphire":876 // corundum's barrel: a hexagonal prism swelling in the middle,877 // tapering to flat hexagonal ends878 L, R := 26.0*stoneScale, 16.0*stoneScale879 r0 := ringX(6, -L, R*0.42, R*0.42, 0)880 r1 := ringX(6, -L*0.3, R, R*0.94, 0)881 r2 := ringX(6, L*0.3, R, R*0.94, 0)882 r3 := ringX(6, L, R*0.42, R*0.42, 0)883 faces = append(faces, bandX(r0, r1)...)884 faces = append(faces, bandX(r1, r2)...)885 faces = append(faces, bandX(r2, r3)...)886 faces = append(faces, capX(r3))887 rev := make([]v3, 6)888 for i := range r0 {889 rev[5-i] = r0[i]890 }891 faces = append(faces, capX(rev))892 return turnFaces(faces, -30*deg, -14*deg), nil893 }894 if stone == "Quartz" {895 // the quartz point: a six-sided prism ending in a six-faced896 // pyramid, the far end broken off where it grew from the matrix897 L, R := 27.0*stoneScale, 12.5*stoneScale898 r0 := ringX(6, -L, R*1.04, R, 0)899 r1 := ringX(6, L*0.4, R, R*0.96, 0)900 point := v3{L, 0, 0}901 faces = append(faces, bandX(r0, r1)...)902 for k := 0; k < 6; k++ {903 faces = append(faces, face{[]v3{r1[k], r1[(k+1)%6], point}, kCrown})904 }905 rev := make([]v3, 6)906 for i := range r0 {907 rev[5-i] = r0[i]908 }909 faces = append(faces, capX(rev))910 return turnFaces(faces, -32*deg, 16*deg), nil911 }912 // topaz: an eight-sided prism (rhombic prism with bevelled edges),913 // a four-faced pyramid at one end, and the flat break of topaz's914 // perfect basal cleavage at the other915 L, ry, rz := 26.0*stoneScale, 16.0*stoneScale, 13.0*stoneScale916 r0 := ringX(8, -L, ry, rz, 0.5)917 r1 := ringX(8, L*0.5, ry, rz, 0.5)918 tip := ringX(8, L*0.82, ry*0.45, rz*0.45, 0.5)919 point := v3{L, 0, 0}920 faces = append(faces, bandX(r0, r1)...)921 faces = append(faces, bandX(r1, tip)...)922 for k := 0; k < 8; k++ {923 faces = append(faces, face{[]v3{tip[k], tip[(k+1)%8], point}, kCrown})924 }925 rev := make([]v3, 8)926 for i := range r0 {927 rev[7-i] = r0[i]928 }929 faces = append(faces, capX(rev))930 return turnFaces(faces, -30*deg, 12*deg), nil931}932933/* ---- view, light, paint ---- */934935// gemTilt is degrees from a straight-down view: 61 is enough to see under936// the girdle (the saved "oblique" view; the "profile" review set is the937// same code rendered with 76).938const gemTilt = 61.0939940const (941 // Optically centred: the pavilion (the lower half) is dark and melts942 // into the ground, so a stone centred by geometry reads high; measured943 // on still renders, its visible part sat ~1.5 units above the ring944 // centre. Pearls are evenly lit and use subjY as is. (Was subjY-1.)945 gemCY = subjY + 1.5946)947948// n1 formats to one decimal as compactly as SVG allows: no trailing949// ".0", no leading "0".950func n1(v float64) string {951 neg := v < 0952 if neg {953 v = -v954 }955 t := int64(v*10 + 0.5)956 w, f := t/10, t%10957 s := ""958 if f == 0 {959 s = itoa(w)960 } else if w == 0 {961 s = "." + string(byte('0'+f))962 } else {963 s = itoa(w) + "." + string(byte('0'+f))964 }965 if neg && t != 0 {966 return "-" + s967 }968 return s969}970971// relPath writes a closed polygon with relative coordinates; a negative972// number needs no separator before it, which is where the bytes go.973func relPath(ps []pt) string {974 s := "M" + n1(ps[0].x) + " " + n1(ps[0].y) + "l"975 px, py := math.Round(ps[0].x*10)/10, math.Round(ps[0].y*10)/10976 for i := 1; i < len(ps); i++ {977 qx, qy := math.Round(ps[i].x*10)/10, math.Round(ps[i].y*10)/10978 dx, dy := n1(qx-px), n1(qy-py)979 if i > 1 && dx[0] != '-' {980 s += " "981 }982 s += dx983 if dy[0] != '-' {984 s += " "985 }986 s += dy987 px, py = qx, qy988 }989 return s + "z"990}991992// hex3 quantises #rrggbb to #rgb.993func hex3(h string) string {994 r, g, b := hexToRGB(h)995 d := "0123456789abcdef"996 q := func(v int) byte { return d[(v+8)*15/255%16] }997 return "#" + string([]byte{q(r), q(g), q(b)})998}9991000func convexHull(ps []pt) []pt {1001 n := len(ps)1002 idx := make([]int, n)1003 for i := range idx {1004 idx[i] = i1005 }1006 // bottom-up merge sort by (x, y): the body hull takes every projected1007 // vertex of the stone (hundreds), where an insertion sort's n^21008 // comparisons were a real share of a render's gas1009 less := func(a, b int) bool {1010 return ps[a].x < ps[b].x || (ps[a].x == ps[b].x && ps[a].y < ps[b].y)1011 }1012 tmp := make([]int, n)1013 for w := 1; w < n; w *= 2 {1014 for lo := 0; lo < n; lo += 2 * w {1015 mid, hi := lo+w, lo+2*w1016 if mid > n {1017 mid = n1018 }1019 if hi > n {1020 hi = n1021 }1022 i, j, k := lo, mid, lo1023 for i < mid && j < hi {1024 if less(idx[j], idx[i]) {1025 tmp[k] = idx[j]1026 j++1027 } else {1028 tmp[k] = idx[i]1029 i++1030 }1031 k++1032 }1033 for i < mid {1034 tmp[k] = idx[i]1035 i++1036 k++1037 }1038 for j < hi {1039 tmp[k] = idx[j]1040 j++1041 k++1042 }1043 }1044 idx, tmp = tmp, idx1045 }1046 cr := func(o, a, b pt) float64 { return (a.x-o.x)*(b.y-o.y) - (a.y-o.y)*(b.x-o.x) }1047 var h []pt1048 for pass := 0; pass < 2; pass++ {1049 start := len(h)1050 for i := 0; i < n; i++ {1051 p := ps[idx[i]]1052 if pass == 1 {1053 p = ps[idx[n-1-i]]1054 }1055 for len(h) >= start+2 && cr(h[len(h)-2], h[len(h)-1], p) <= 0 {1056 h = h[:len(h)-1]1057 }1058 h = append(h, p)1059 }1060 h = h[:len(h)-1]1061 }1062 return h1063}10641065/* ---- polygon helpers for the realism passes ---- */10661067// polyArea is twice the signed area; sign tells winding.1068func polyArea(ps []pt) float64 {1069 a := 0.01070 for i := range ps {1071 j := (i + 1) % len(ps)1072 a += ps[i].x*ps[j].y - ps[j].x*ps[i].y1073 }1074 if a < 0 {1075 return -a / 21076 }1077 return a / 21078}10791080// clipHalf is one Sutherland-Hodgman pass: keeps the part of ps on the1081// inner side of the line through (ox,oy) with outward normal (nx,ny).1082func clipHalf(ps []pt, ox, oy, nx, ny float64) []pt {1083 if len(ps) == 0 {1084 return ps1085 }1086 d := func(q pt) float64 { return (q.x-ox)*nx + (q.y-oy)*ny }1087 var out []pt1088 for i := range ps {1089 a, b := ps[i], ps[(i+1)%len(ps)]1090 da, db := d(a), d(b)1091 if da <= 0 {1092 out = append(out, a)1093 }1094 if (da < 0 && db > 0) || (da > 0 && db < 0) {1095 t := da / (da - db)1096 out = append(out, pt{a.x + (b.x-a.x)*t, a.y + (b.y-a.y)*t})1097 }1098 }1099 return out1100}11011102// clipToConvex keeps the part of ps inside the convex window win.1103func clipToConvex(ps, win []pt) []pt {1104 // win from convexHull is counter-clockwise in screen coords; the1105 // outward normal of edge (a,b) is (dy,-dx) rotated to point away.1106 for i := range win {1107 a, b := win[i], win[(i+1)%len(win)]1108 nx, ny := b.y-a.y, a.x-b.x1109 mid := pt{(a.x + b.x) / 2, (a.y + b.y) / 2}1110 // make the normal point outward: away from the window centre1111 cx, cy := centroid(win)1112 if (mid.x-cx)*nx+(mid.y-cy)*ny < 0 {1113 nx, ny = -nx, -ny1114 }1115 ps = clipHalf(ps, a.x, a.y, nx, ny)1116 if len(ps) < 3 {1117 return nil1118 }1119 }1120 return ps1121}11221123// splitPoly cuts ps in two along the line through its centroid at ang.1124func splitPoly(ps []pt, ang float64) ([]pt, []pt) {1125 cx, cy := centroid(ps)1126 nx, ny := math.Cos(ang), math.Sin(ang)1127 return clipHalf(ps, cx, cy, nx, ny), clipHalf(ps, cx, cy, -nx, -ny)1128}11291130// renderGem3D builds the stone. Returns what goes under the facets1131// (defs, body glow, the pavilion's back faces), the front facets as bare1132// self-closing paths (applyCascade scans these), and what goes over1133// (girdle stroke, rim).1134func renderGem3D(shapeIdx int, base, salt string, fs *facetStyle) (under, facets, over string) {1135 c := cutFor(shapeIdx)1136 var faces []face1137 var girdle []v31138 switch shapeIdx {1139 case shapeRose:1140 faces, girdle = buildRose(c)1141 case shapeRaw:1142 faces, girdle = buildRaw(fs.stone)1143 default:1144 faces, girdle = buildCut(c)1145 }1146 raw := shapeIdx == shapeRaw11471148 sh := fs.prof.shadow1149 if fs.prof.neutral != "" {1150 base = fs.prof.neutral1151 }1152 base = mixHex(base, sh, fs.prof.bodyMix)1153 deep := darken(base, sh, 0.9)1154 // Highlights stay in the stone's hue; only the specular goes to1155 // white. Mixing facets most of the way to white is what read pastel.1156 bright := lighten(base, 0.5)1157 fs.flash = flashRamp(28, fs.prof.sat)1158 deepI, baseI, brightI := toRGBI(deep), toRGBI(base), toRGBI(bright)1159 toneI := func(lum float64) rgbI {1160 if lum < 0 {1161 lum = 01162 }1163 if lum > 1 {1164 lum = 11165 }1166 if lum < 0.5 {1167 return mixI(deepI, baseI, lum*2)1168 }1169 return mixI(baseI, brightI, (lum-0.5)*2)1170 }1171 tone := func(lum float64) string { return toneI(lum).hex() }11721173 // view: a small per-token yaw, then the tilt toward the viewer1174 yaw := fs.zone.floatIn(-13, 13) * math.Pi / 1801175 th := gemTilt * math.Pi / 1801176 cy, sy := math.Cos(yaw), math.Sin(yaw)1177 ct, stt := math.Cos(th), math.Sin(th)1178 view := func(p v3) v3 {1179 x1, y1 := p.x*cy-p.y*sy, p.x*sy+p.y*cy1180 return v3{x1, y1*ct + p.z*stt, p.z*ct - y1*stt}1181 }1182 // Centre every stone on its own drawn extent, not on its geometric1183 // origin: a rose cut is all dome and no pavilion, so centred on its1184 // girdle it sat high in the ring; raw crystals and pears are lopsided1185 // the same way. This shifts the drawing only; the rock still pivots1186 // on the ring centre.1187 ex0, ey0, ex1, ey1 := 1e9, 1e9, -1e9, -1e91188 for _, f := range faces {1189 for _, p := range f.p {1190 q := view(p)1191 ex0, ey0 = math.Min(ex0, q.x), math.Min(ey0, q.y)1192 ex1, ey1 = math.Max(ex1, q.x), math.Max(ey1, q.y)1193 }1194 }1195 ox, oy := -(ex0+ex1)/2, -(ey0+ey1)/21196 scr := func(p v3) pt { return pt{subjX + p.x + ox, gemCY - (p.y + oy)} }1197 light := vnorm(v3{-0.5, 0.62, 0.6})1198 inside := v3{0, 0, -0.25 * c.ry}1199 if raw {1200 inside = v3{}1201 } else if shapeIdx == shapeRose {1202 inside = v3{0, 0, 0.15 * c.ry}1203 }12041205 type drawn struct {1206 pts []pt1207 depth float641208 fill string1209 kind int1210 lum float641211 // what shadeFront needs to relight this facet for the rock1212 nv v31213 jit, drift, thick float641214 ext bool1215 }1216 // shadeFront is the lighting of a facet seen from outside, for a1217 // given light direction. Kept as one function so the rock states1218 // (see lightStates below) are lit by exactly the same model.1219 shadeFront := func(kind int, nv, L v3, jit, drift, thick float64, ext bool) (string, float64) {1220 H := vnorm(v3{L.x, L.y, L.z + 1})1221 // A real stone is a contrast pattern, not an evenly lit solid:1222 // lambert with a gamma so mid facets stay deep and saturated,1223 // plus the odd "extinction" facet that returns no light at all.1224 lam := math.Max(0, vdot(nv, L))1225 lum := 0.03 + 0.7*math.Pow(lam, 2.1) + jit1226 if ext {1227 lum *= 0.31228 }1229 if kind == kTable {1230 lum = 0.36 + 0.26*lam1231 }1232 if kind == kPav {1233 // A pavilion facet seen from outside faces away from the1234 // lamp, but it is where light that entered through the1235 // crown LEAVES the stone: lit from its inner side.1236 lum = 0.22 + 0.5*math.Max(0, vdot(vscale(nv, -1), L)) + jit1237 }1238 if raw {1239 // A natural crystal face is not polished, but topaz, quartz,1240 // sapphire and diamond rough all have a glassy (vitreous to1241 // adamantine) lustre: faces toward the lamp shine from1242 // outside as well as glowing from inside. Lit only from1243 // inside, the crystals read as dull plastic.1244 lum = 0.3 + 0.42*math.Pow(lam, 1.4) + 0.22*math.Max(0, vdot(vscale(nv, -1), L)) + jit*0.61245 }1246 col := mixI(toneI(lum), deepI, 0.34*thick*thick)1247 if sp := math.Pow(math.Max(0, vdot(nv, H)), 28); sp > 0.3 {1248 col = mixI(col, whiteI, sp*0.85)1249 }1250 // Dispersion on the bright facets: white light split by the stone1251 // leaves each facet in a slightly different spectral colour, set by1252 // which way the facet faces (its azimuth), so neighbours differ1253 // the way real fire does instead of every bright facet being one1254 // lighter shade of the body. Scaled by the stone's own dispersion1255 // (prof.sat: diamond most, topaz least) and only above mid1256 // brightness; dark facets keep the body colour.1257 if lum > 0.5 && kind != kTable {1258 amt := math.Min(1, (lum-0.5)/0.4) * dispersionMix * fs.prof.sat1259 t := math.Atan2(nv.y, nv.x)/math.Pi + drift/401260 col = mixI(col, spectrumAtI(t), amt)1261 }1262 return hex3I(hueRotateI(col, drift)), lum1263 }1264 var back, front, pav []drawn1265 var all []pt1266 for _, f := range faces {1267 vp := make([]v3, len(f.p))1268 ctr := v3{}1269 for i, p := range f.p {1270 vp[i] = view(p)1271 ctr = v3{ctr.x + p.x, ctr.y + p.y, ctr.z + p.z}1272 }1273 ctr = vscale(ctr, 1/float64(len(f.p)))1274 // Newell's normal, not a cross product of the first three points:1275 // Princess Cut's table has three collinear corners there, which1276 // gave a zero normal and dropped its table face entirely.1277 nm := v3{}1278 for i := range f.p {1279 a, b := f.p[i], f.p[(i+1)%len(f.p)]1280 nm = v3{nm.x + (a.y-b.y)*(a.z+b.z), nm.y + (a.z-b.z)*(a.x+b.x), nm.z + (a.x-b.x)*(a.y+b.y)}1281 }1282 nm = vnorm(nm)1283 if vdot(nm, vsub(ctr, inside)) < 0 {1284 nm = vscale(nm, -1)1285 }1286 nv := vnorm(view(nm))1287 d := drawn{kind: f.kind}1288 for _, p := range vp {1289 d.pts = append(d.pts, scr(p))1290 d.depth += p.z1291 all = append(all, scr(p))1292 }1293 d.depth /= float64(len(vp))1294 // How much stone the light crosses under this facet: the solid is1295 // thickest over the axis and thins to nothing at the girdle, so a1296 // facet near the centre shows more of the body colour (Beer-Lambert,1297 // the reason a deep stone reads darker and more saturated than a1298 // shallow one). Costs no extra markup -- it only shifts the fill.1299 rN := math.Sqrt((ctr.x/c.rx)*(ctr.x/c.rx) + (ctr.y/c.ry)*(ctr.y/c.ry))1300 if rN > 1 {1301 rN = 11302 }1303 thick := 1 - rN1304 jit := fs.zone.floatIn(-0.13, 0.13)1305 if nv.z > 0.02 {1306 ext := f.kind == kCrown && fs.zone.floatIn(0, 1) < 0.281307 drift := fs.zone.floatIn(-fireZoneDeg, fireZoneDeg)1308 d.nv, d.jit, d.drift, d.thick, d.ext = nv, jit, drift, thick, ext1309 d.fill, d.lum = shadeFront(f.kind, nv, light, jit, drift, thick, ext)1310 front = append(front, d)1311 if f.kind == kPav {1312 pav = append(pav, d)1313 }1314 } else if f.kind == kPav {1315 // seen from inside through the crown: lit on its inner side1316 inner := vscale(nv, -1)1317 lum := 0.2 + 0.6*math.Max(0, vdot(inner, light)) + jit1318 d.fill = hex3(mixHex(tone(lum), deep, 0.34*thick*thick))1319 d.lum = lum1320 back = append(back, d)1321 pav = append(pav, d)1322 }1323 }1324 sortDrawn := func(ds []drawn) {1325 for i := 1; i < len(ds); i++ {1326 for j := i; j > 0 && ds[j].depth < ds[j-1].depth; j-- {1327 ds[j], ds[j-1] = ds[j-1], ds[j]1328 }1329 }1330 }1331 sortDrawn(back)1332 sortDrawn(front)13331334 hull := convexHull(all)1335 if shapeIdx == shapeHeart {1336 // the notch: a hull would fill it; the girdle ring is the outline1337 hull = nil1338 for _, g := range girdle {1339 hull = append(hull, scr(view(g)))1340 }1341 }1342 // strings.Builder, not +=: appending to a growing string re-copies it1343 // every time, and on-chain that copying was the bulk of a render's gas1344 var underB, facetsB strings.Builder1345 underB.WriteString(`<defs><radialGradient id="bd` + salt + `" cx=".4" cy=".35" r=".75">` +1346 `<stop offset="0" stop-color="` + hex3(base) + `"/><stop offset=".5" stop-color="` + hex3(darken(base, sh, 0.25)) + `"/><stop offset="1" stop-color="` + hex3(darken(base, sh, 0.85)) + `"/></radialGradient></defs>` +1347 `<path d="` + relPath(hull) + `" fill="url(#bd` + salt + `)" opacity=".7"/><g fill-opacity=".6">`)1348 for _, d := range back {1349 underB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)1350 }1351 underB.WriteString(`</g>`)13521353 nCrown := 01354 for _, d := range front {1355 if d.kind == kCrown {1356 nCrown++1357 }1358 }1359 candP := 1.01360 if nCrown > fireMaxFacets {1361 candP = float64(fireMaxFacets) / float64(nCrown) * 1.251362 }1363 nCand := 01364 nSplit := 01365 if raw || fs.thumb {1366 // rough crystal: no fire flashes, no split facets -- a natural1367 // face is one frosted plane. A thumbnail: no flashes either.1368 candP = 01369 }1370 for _, d := range front {1371 if d.kind == kTable && shapeIdx == shapeHeart {1372 // a non-convex window: skip the reflected mosaic, which is1373 // clipped to a convex outline and would spill into the notch1374 facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)1375 continue1376 }1377 if d.kind == kTable {1378 // The window. Through it you do not see the pavilion's own1379 // faces straight on -- you see them mirrored back up at you off1380 // the pavilion walls, small and inverted. That mosaic is what a1381 // real table shows, so it is drawn here: each pavilion face mapped1382 // into the table, flipped, and cut to the table's outline (a1383 // Sutherland-Hodgman clip, no <clipPath>).1384 facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" fill-opacity=".46"/>`)1385 win := convexHull(d.pts)1386 tx, ty := centroid(win)1387 if len(pav) > 0 && !fs.thumb {1388 bx0, by0, bx1, by1 := 1e9, 1e9, -1e9, -1e91389 for _, b := range pav {1390 for _, q := range b.pts {1391 bx0, by0 = math.Min(bx0, q.x), math.Min(by0, q.y)1392 bx1, by1 = math.Max(bx1, q.x), math.Max(by1, q.y)1393 }1394 }1395 wx0, wy0, wx1, wy1 := 1e9, 1e9, -1e9, -1e91396 for _, q := range win {1397 wx0, wy0 = math.Min(wx0, q.x), math.Min(wy0, q.y)1398 wx1, wy1 = math.Max(wx1, q.x), math.Max(wy1, q.y)1399 }1400 k := 0.92 * math.Min((wx1-wx0)/math.Max(1, bx1-bx0), (wy1-wy0)/math.Max(1, by1-by0))1401 bx, by := (bx0+bx1)/2, (by0+by1)/21402 var mosB strings.Builder1403 nRefl := 01404 for _, b := range pav {1405 if nRefl >= 8 {1406 break1407 }1408 mp := make([]pt, len(b.pts))1409 for i, q := range b.pts {1410 mp[i] = pt{tx + (q.x-bx)*k, ty - (q.y-by)*k}1411 }1412 cl := clipToConvex(mp, win)1413 if len(cl) < 3 || polyArea(cl) < 4 {1414 continue1415 }1416 nRefl++1417 mosB.WriteString(`<path d="` + relPath(cl) + `" fill="` + hex3(tone(b.lum*0.95)) + `"/>`)1418 }1419 if mosB.Len() > 0 {1420 facetsB.WriteString(`<g fill-opacity=".55">` + mosB.String() + `</g>`)1421 }1422 }1423 continue1424 }1425 // Only some crown facets can flash (fireMaxFacets): every one that1426 // can costs an overlay path, so the set is bounded per stone.1427 if d.kind == kCrown && nCand < fireMaxFacets && fs.zone.floatIn(0, 1) < candP {1428 nCand++1429 cx, cyy := centroid(d.pts)1430 fs.ringCent = append(fs.ringCent, pt{cx, cyy})1431 facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `" class="f"/>`)1432 continue1433 }1434 // Two-tone facets: a real facet rarely reflects one thing. Part of1435 // it catches the lamp, the rest catches the dark room, and the1436 // boundary is a straight line across the facet. Split a bounded1437 // number of the larger ones (each split costs one extra path).1438 if !raw && nSplit < 6 && polyArea(d.pts) > 26 && fs.zone.floatIn(0, 1) < 0.5 {1439 a, b := splitPoly(d.pts, fs.zone.floatIn(0, math.Pi))1440 if len(a) >= 3 && len(b) >= 3 {1441 nSplit++1442 lo := hex3(mixHex(tone(math.Max(0, d.lum*0.6-0.04)), deep, 0.18))1443 hi := hex3(tone(math.Min(1, d.lum*1.35+0.06)))1444 facetsB.WriteString(`<path d="` + relPath(a) + `" fill="` + hi + `"/><path d="` + relPath(b) + `" fill="` + lo + `"/>`)1445 continue1446 }1447 }1448 facetsB.WriteString(`<path d="` + relPath(d.pts) + `" fill="` + d.fill + `"/>`)1449 }14501451 // Lighting that follows the rock. The stone swings to -rot and +rot1452 // (motionPhases); relative to the stone that turns the lamp the other1453 // way, so every facet meets the light at a new angle and they all1454 // change together. For each swing the facets that change most get a1455 // copy in their new shade, grouped, and the group fades in with the1456 // swing (gt-la / gt-lb in styleBlock, timed off the rock's own1457 // duration and delay, which the group inherits from .gt-r). Only1458 // group opacity animates -- no per-facet colour animation, which is1459 // what keeps it smooth in Safari. The lamp turns lightSwing times the1460 // rock angle: a real stone flashes hard on a tiny movement, and at 1x1461 // a 4-8 degree rock barely moves the shading.1462 _, _, _, _, rotDeg := motionPhases(salt)1463 type cand struct {1464 i int1465 fill string1466 delta float641467 }1468 for si, sgn := range []float64{1, -1} {1469 if fs.thumb {1470 break1471 }1472 // rotate(-rot) in SVG (y down) is a visual counter-clockwise turn,1473 // which in this y-up view space turns the stone +rot, i.e. the1474 // lamp -rot relative to it1475 a := -sgn * rotDeg * lightSwing * math.Pi / 1801476 ca, sa := math.Cos(a), math.Sin(a)1477 L := vnorm(v3{light.x*ca - light.y*sa, light.x*sa + light.y*ca, light.z})1478 var cs []cand1479 for i, d := range front {1480 if d.kind == kTable {1481 continue1482 }1483 f2, _ := shadeFront(d.kind, d.nv, L, d.jit, d.drift, d.thick, d.ext)1484 if dl := lumaDelta(f2, d.fill); dl > 0.06 && polyArea(d.pts) > 3 {1485 cs = append(cs, cand{i, f2, dl})1486 }1487 }1488 // strongest changes first; insertion sort, the list is short1489 for i := 1; i < len(cs); i++ {1490 for j := i; j > 0 && cs[j].delta > cs[j-1].delta; j-- {1491 cs[j], cs[j-1] = cs[j-1], cs[j]1492 }1493 }1494 if len(cs) > lightMaxFacets {1495 cs = cs[:lightMaxFacets]1496 }1497 if len(cs) == 0 {1498 continue1499 }1500 cls := "gt-la"1501 if si == 1 {1502 cls = "gt-lb"1503 }1504 facetsB.WriteString(`<g class="` + cls + `" opacity="0">`)1505 for _, c := range cs {1506 facetsB.WriteString(`<path d="` + relPath(front[c.i].pts) + `" fill="` + c.fill + `"/>`)1507 }1508 facetsB.WriteString(`</g>`)1509 }15101511 // girdle: the near half of the ring as one frosted stroke1512 gd, lastG := "", ""1513 for i := range girdle {1514 a, b := view(girdle[i]), view(girdle[(i+1)%len(girdle)])1515 mid := vscale(v3{girdle[i].x + girdle[(i+1)%len(girdle)].x, girdle[i].y + girdle[(i+1)%len(girdle)].y, 0}, 0.5)1516 if view(vnorm(mid)).z-0 > -0.05 && view(mid).z >= view(v3{}).z-0.01 {1517 pa, pb := scr(a), scr(b)1518 // consecutive segments share an end point: continue the1519 // polyline instead of a fresh M...L pair per segment1520 sa := n1(pa.x) + " " + n1(pa.y)1521 if sa != lastG {1522 gd += "M" + sa1523 }1524 lastG = n1(pb.x) + " " + n1(pb.y)1525 gd += " " + lastG1526 }1527 }1528 // The girdle is a faint line in the stone's own tone, not a light1529 // edge, and there is no outline stroke: a drawn rim reads as a1530 // sticker border, real stones end where their facets end.1531 over = `<path d="` + gd + `" fill="none" stroke="` + hex3(lighten(base, 0.18)) + `" stroke-width=".7" stroke-linecap="round" opacity=".4"/>`1532 return underB.String(), facetsB.String(), over1533}15341535// dispersionMix is the most a bright facet is tinted toward its spectral1536// colour (for a stone with prof.sat 1, i.e. diamond).1537const dispersionMix = 0.3215381539var spectrum = []string{"#ff4040", "#ff9a1f", "#ffe81f", "#35ff7a", "#22ccff", "#5566ff", "#c04dff"}15401541var spectrumI = func() []rgbI {1542 out := make([]rgbI, len(spectrum))1543 for i, h := range spectrum {1544 out[i] = toRGBI(h)1545 }1546 return out1547}()15481549func spectrumAtI(t float64) rgbI {1550 t = t - math.Floor(t)1551 f := t * float64(len(spectrumI))1552 i := int(f)1553 return mixI(spectrumI[i%len(spectrumI)], spectrumI[(i+1)%len(spectrumI)], f-float64(i))1554}15551556// spectrumAt returns the spectral colour at t, wrapping every 1.0 (so1557// facets facing opposite ways get the same hue, as a symmetric cut does).1558func spectrumAt(t float64) string {1559 t = t - math.Floor(t)1560 f := t * float64(len(spectrum))1561 i := int(f)1562 return mixHex(spectrum[i%len(spectrum)], spectrum[(i+1)%len(spectrum)], f-float64(i))1563}15641565// lightSwing is how far the lamp turns per degree of rock, and1566// lightMaxFacets how many facets per swing get a relit copy (each costs1567// one path, ~60 bytes).1568const (1569 lightSwing = 2.21570 lightMaxFacets = 121571)15721573// lumaDelta is the perceived brightness difference of two hex colours,1574// 0..1.1575func lumaDelta(a, b string) float64 {1576 // facet fills are #rgb (hex3); widen to #rrggbb for hexToRGB1577 wide := func(h string) string {1578 if len(h) == 4 {1579 return "#" + h[1:2] + h[1:2] + h[2:3] + h[2:3] + h[3:4] + h[3:4]1580 }1581 return h1582 }1583 ar, ag, ab := hexToRGB(wide(a))1584 br, bg, bb := hexToRGB(wide(b))1585 d := (0.2126*float64(ar-br) + 0.7152*float64(ag-bg) + 0.0722*float64(ab-bb)) / 2551586 if d < 0 {1587 return -d1588 }1589 return d1590}15911592// fireMaxFacets bounds how many facets of a stone can flash.1593const fireMaxFacets = 1215941595// starfield toggles the background stars (see renderStars).1596const starfield = true15971598// renderStars scatters a sparse starfield behind everything. The field1599// itself is still: every star keeps its own fixed brightness (four levels,1600// sizes to match), the way a night sky looks. Only a few stars twinkle,1601// and each twinkler is its own element with its own period and phase,1602// drawn per token, so no two ever blink together -- the old field had one1603// whole group of stars fading in and out in unison. The ring band is kept1604// clear. Seeded per token.1605func renderStars(salt string) string {1606 if !starfield {1607 return ""1608 }1609 rnd := newPRNG("stars" + salt)1610 type star struct{ x, y float64 }1611 var byLevel [4][]star1612 for i := 0; i < 36; i++ {1613 x, y := rnd.floatIn(3, canvasW-3), rnd.floatIn(3, 127)1614 k := rnd.intn(20)1615 dx, dy := x-subjX, y-subjY1616 if r := math.Sqrt(dx*dx + dy*dy); r > 43 && r < 58 {1617 continue1618 }1619 lvl := 0 // most stars are faint; a few are bright1620 switch {1621 case k >= 18:1622 lvl = 31623 case k >= 14:1624 lvl = 21625 case k >= 8:1626 lvl = 11627 }1628 byLevel[lvl] = append(byLevel[lvl], star{x, y})1629 }1630 widths := [4]string{".45", ".6", ".85", "1.15"}1631 ops := [4]string{".22", ".38", ".58", ".85"}1632 var b strings.Builder1633 b.WriteString(`<g stroke="#fff" stroke-linecap="round" fill="none">`)1634 var tw []star1635 for l := 0; l < 4; l++ {1636 if len(byLevel[l]) == 0 {1637 continue1638 }1639 d := ""1640 for i, st := range byLevel[l] {1641 // the twinklers come from the middle levels (visible, not the1642 // brightest), at most three per token1643 if l >= 1 && l <= 2 && len(tw) < 3 && i%3 == 0 {1644 tw = append(tw, st)1645 continue1646 }1647 d += "M" + n1(st.x) + " " + n1(st.y) + "h.01"1648 }1649 if d != "" {1650 b.WriteString(`<path d="` + d + `" stroke-width="` + widths[l] + `" opacity="` + ops[l] + `"/>`)1651 }1652 }1653 for _, st := range tw {1654 // 5-12 s periods (irrational-ish ratios between them) and random1655 // phases: each twinkles alone, briefly, now and then1656 dur := rnd.floatIn(5, 12)1657 b.WriteString(`<path d="M` + n1(st.x) + " " + n1(st.y) + `h.01" stroke-width=".9" opacity=".4" class="gt-tw" style="--d:` + fnum1(dur) + `s;--dl:-` + fnum1(rnd.floatIn(0, dur)) + `s"/>`)1658 }1659 b.WriteString(`</g>`)1660 return b.String()1661}16621663/* ------------------------------------ pearl shapes ------------------------------------ */1664// Pearls are organic gems -- they grow into a form, they are never cut or1665// faceted the way a crystal is. So unlike the cut vocabulary above1666// (ShapeNames/renderGem), everything here draws a smooth, continuous1667// surface: one radial luster gradient plus a soft diagonal highlight band1668// (a pearl's real "orient" sheen), no straight-edged facet triangles.16691670// pearlLusterGradient returns the defs for a pearl's nacre, as four1671// gradients the body shape is filled with in turn (pearlLayers):1672// gid body: a sphere lit from the upper left -- lifted where the light1673// lands, the body colour through the middle, a real terminator1674// (the shadowed limb, lower right), and a thin bounce-lit rim.1675// The old version only lightened a white body, which cannot get1676// brighter, so white pearls read as flat discs.1677// gid+o overtone: nacre's resting iridescence, rose over the body and1678// green toward the rim, the two interference colours pearls show.1679// gid+s specular: the lamp itself, a small hot core with a soft skirt.1680// Nacre is glossy; the core is what says "pearl" at 200 px.1681// gid+w window: the second, dimmer reflection of the room on the far1682// side, which is what makes a sphere read as a mirror, not paint.1683// Shadow tones are warm for light bodies and neutral for dark ones: a1684// white pearl shades toward grey-taupe, never toward black.1685func pearlLusterGradient(gid, base, metal string) string {1686 r, g, b := hexToRGB(base)1687 lum := (0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)) / 2551688 shadow := "#4a4038"1689 if lum < 0.4 {1690 shadow = "#000000"1691 }1692 // The lit side is the body colour itself and everything else is1693 // shaded down from it, so the specular (pure white) is the only thing1694 // that brightest -- that contrast is what reads as gloss. Full 6-digit1695 // hex here: #rgb quantising flattened the subtle pearl tones.1696 hi := base1697 // highlight strengths; a dark (Tahitian) pearl gets a smaller, softer1698 // highlight: at full strength a white hot spot on a near-black body1699 // read as a glare, not as nacre1700 // A soft, wide sheen rather than a hot spot: a real pearl's lustre is1701 // a broad glow with a gentle falloff (nacre scatters light), not a1702 // pinpoint. Peak kept well below full white, spread over a larger1703 // radius with an extra mid stop so there is no hard edge.1704 sc, ss, sw := ".6", ".32", ".16"1705 mo := ".3"1706 if lum < 0.4 {1707 mo = ".22"1708 hi = mixHex(base, "#c9d2d8", 0.28)1709 sc, ss, sw = ".34", ".16", ".07"1710 }1711 return `<radialGradient id="` + gid + `" cx=".4" cy=".36" r=".7" fx=".34" fy=".28">` +1712 `<stop offset="0" stop-color="` + hi + `"/>` +1713 `<stop offset=".4" stop-color="` + mixHex(base, shadow, 0.13) + `"/>` +1714 `<stop offset=".76" stop-color="` + mixHex(base, shadow, 0.36) + `"/>` +1715 `<stop offset=".93" stop-color="` + mixHex(base, shadow, 0.5) + `"/>` +1716 `<stop offset="1" stop-color="` + mixHex(base, shadow, 0.3) + `"/></radialGradient>` +1717 `<radialGradient id="` + gid + `o" cx=".45" cy=".44" r=".62">` +1718 `<stop offset=".3" stop-color="#fff" stop-opacity="0"/>` +1719 `<stop offset=".62" stop-color="#ffb3cf" stop-opacity=".2"/>` +1720 `<stop offset=".88" stop-color="#b3ffd6" stop-opacity=".2"/>` +1721 `<stop offset="1" stop-color="#c3d3ff" stop-opacity=".12"/></radialGradient>` +1722 `<radialGradient id="` + gid + `s" cx=".36" cy=".3" r=".4">` +1723 `<stop offset="0" stop-color="#fff" stop-opacity="` + sc + `"/>` +1724 `<stop offset=".35" stop-color="#fff" stop-opacity="` + ss + `"/>` +1725 `<stop offset=".7" stop-color="#fff" stop-opacity=".06"/>` +1726 `<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>` +1727 // gid+m: the setting mirrored in the nacre -- a thin crescent of1728 // the ring's metal along the lower limb, where a sphere reflects1729 // what is beside and below it. A pearl in gold shows a warm edge,1730 // in platinum a cool one: that is what makes it read as a mirror.1731 `<radialGradient id="` + gid + `m" cx=".42" cy=".36" r=".68">` +1732 `<stop offset=".78" stop-color="` + metal + `" stop-opacity="0"/>` +1733 `<stop offset=".93" stop-color="` + metal + `" stop-opacity="` + mo + `"/>` +1734 `<stop offset="1" stop-color="` + metal + `" stop-opacity="0"/></radialGradient>` +1735 `<radialGradient id="` + gid + `w" cx=".66" cy=".73" r=".2">` +1736 `<stop offset="0" stop-color="#fff" stop-opacity="` + sw + `"/>` +1737 `<stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>`1738}17391740// pearlLayers re-emits a pearl's body shape once per lustre layer (body,1741// overtone, metal reflection, window, specular) (see1742// pearlLusterGradient). shape is the full self-closing element with its1743// fill attribute; only the fill is swapped. The rings of a Circled pearl1744// go between the overtone and the highlights, so the highlight sits on1745// top of the grooves as it would on a real surface.1746func pearlLayers(shape, gid, between string) string {1747 i := strings.Index(shape, ` fill="`)1748 if i < 0 {1749 return shape1750 }1751 cp := func(suffix string) string { return shape[:i] + ` fill="url(#` + gid + suffix + `)"/>` }1752 return shape + cp("o") + cp("m") + between + cp("w") + cp("s")1753}17541755// pearlSweepColorIntensity picks the sweep gradient's opacity multiplier1756// off the base color's own luminance: a dark body (e.g. Black) already1757// reads a screen-blended color band far more strongly than a light body1758// does at the same opacity, so light bodies get a bigger boost off the1759// shared baseline than dark ones do. Threshold/multipliers tuned by eye1760// against the full 8-color palette, not derived from anything physical.1761func pearlSweepColorIntensity(base string) float64 {1762 r, g, b := hexToRGB(base)1763 lum := 0.2126*float64(r)/255 + 0.7152*float64(g)/255 + 0.0722*float64(b)/2551764 if lum < 0.4 {1765 // was 1.2: on a black pearl the colour band read as a bright1766 // stripe; nacre's orient on a dark body is subtle1767 return 0.551768 }1769 return 2.01770}17711772// pearlSweepGradient is the traveling band's own color: transparent at1773// both ends, a soft pink/blue/green/gold spectrum in the middle -- an1774// approximation of thin-film interference (the real source of nacre's1775// iridescence) rather than a single flat highlight tone.1776func pearlSweepGradient(gid, base string) string {1777 mult := pearlSweepColorIntensity(base)1778 o1, o2, o3 := fnum(0.3*mult), fnum(0.35*mult), fnum(0.3*mult)1779 return `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="0%">` +1780 `<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>` +1781 `<stop offset="18%" stop-color="#ffd2e6" stop-opacity="` + o1 + `"/>` +1782 `<stop offset="38%" stop-color="#cfe8ff" stop-opacity="` + o2 + `"/>` +1783 `<stop offset="58%" stop-color="#d4ffe4" stop-opacity="` + o3 + `"/>` +1784 `<stop offset="80%" stop-color="#fff0c2" stop-opacity="` + o3 + `"/>` +1785 `<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>` +1786 `</linearGradient>`1787}17881789// pearlSweepOverlay replaces the old fixed pearlOrientSheen ellipse: a1790// handful of slow, wide, translucent color bands that each cross the1791// pearl's own silhouette once from a random angle, then wait, then cross1792// again from a new random angle, on repeat. shapeGeom is that pearl's own1793// outline (no fill needed -- only used inside a clipPath) so the color1794// never spills past the gem's edge, the same way applyCascade's facet1795// flashes are seeded per token off tidStr rather than being identical1796// across every piece.1797func pearlSweepOverlay(salt, base, shapeGeom string, w, h float64) string {1798 rnd := newPRNG(salt + "-sweep")1799 const passes = 31800 total := 15.0 + rnd.floatIn(0, 9)18011802 durs := make([]float64, passes)1803 sumDur := 0.01804 for i := 0; i < passes; i++ {1805 durs[i] = 2.4 + rnd.floatIn(0, 2.2)1806 sumDur += durs[i]1807 }1808 gapTotal := total - sumDur1809 if gapTotal < float64(passes)*0.5 {1810 gapTotal = float64(passes) * 0.51811 }1812 weights := make([]float64, passes)1813 sumW := 0.01814 for i := 0; i < passes; i++ {1815 weights[i] = 0.4 + rnd.floatIn(0, 1)1816 sumW += weights[i]1817 }1818 gaps := make([]float64, passes)1819 for i := 0; i < passes; i++ {1820 gaps[i] = weights[i] / sumW * gapTotal1821 }1822 starts := make([]float64, passes)1823 t := gaps[0]1824 for i := 0; i < passes; i++ {1825 starts[i] = t1826 t += durs[i]1827 if i < passes-1 {1828 t += gaps[i+1]1829 }1830 }18311832 travel := w * 1.051833 clipID := "clp" + salt1834 gradID := "swp" + salt1835 off, on1, on2, out := fnum(-travel), fnum(-travel*0.5), fnum(travel*0.5), fnum(travel)18361837 var defs strings.Builder1838 defs.WriteString(`<defs><clipPath id="` + clipID + `">` + shapeGeom + `</clipPath>`)1839 defs.WriteString(pearlSweepGradient(gradID, base))18401841 var marks strings.Builder1842 marks.WriteString(`<g clip-path="url(#` + clipID + `)">`)1843 for i := 0; i < passes; i++ {1844 angle := fnum(rnd.floatIn(0, 360))1845 startPct := starts[i] / total * 1001846 durPct := durs[i] / total * 1001847 rampPct := durPct * 0.321848 if rampPct > 6 {1849 rampPct = 61850 }1851 p0, p1, p2, p3 := startPct, startPct+rampPct, startPct+durPct-rampPct, startPct+durPct1852 kf := "swp" + salt + "_" + ufmt.Sprintf("%d", i)18531854 closing := ""1855 if p3 < 99.999 {1856 closing = `100%{transform:translate(` + off + `px,0);opacity:0}`1857 }1858 defs.WriteString(`<style>@keyframes ` + kf + `{` +1859 `0%{transform:translate(` + off + `px,0);opacity:0}` +1860 fnum(p0) + `%{transform:translate(` + off + `px,0);opacity:0}` +1861 fnum(p1) + `%{transform:translate(` + on1 + `px,0);opacity:1}` +1862 fnum(p2) + `%{transform:translate(` + on2 + `px,0);opacity:1}` +1863 fnum(p3) + `%{transform:translate(` + out + `px,0);opacity:0}` +1864 closing +1865 `}</style>`)18661867 marks.WriteString(`<g transform="rotate(` + angle + ` ` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +1868 `<rect x="` + fnum(subjX-w*1.3) + `" y="` + fnum(subjY-h*0.95) + `" width="` + fnum(w*2.6) + `" height="` + fnum(h*1.9) + `" fill="url(#` + gradID + `)" opacity="0" style="mix-blend-mode:screen;animation:` + kf + ` ` + fnum(total) + `s linear infinite"/>` +1869 `</g>`)1870 }1871 marks.WriteString(`</g>`)1872 defs.WriteString(`</defs>`)1873 return defs.String() + marks.String()1874}18751876func pearlRound(base, metal, salt string) string {1877 gid := "prl" + salt1878 r := 24.0 * stoneScale1879 body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`1880 clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`1881 sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)1882 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep1883}18841885func pearlOval(base, metal, salt string) string {1886 gid := "prl" + salt1887 rx, ry := 18.0*stoneScale, 25.0*stoneScale1888 body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`1889 clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`1890 sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)1891 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep1892}18931894// pearlButton is flattened rather than round -- wider than tall, the1895// real "button pearl" silhouette (as if gently pressed), popular in1896// jewelry specifically because the flat back sits well against skin.1897func pearlButton(base, metal, salt string) string {1898 gid := "prl" + salt1899 rx, ry := 25.0*stoneScale, 18.0*stoneScale1900 body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`1901 clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`1902 sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)1903 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep1904}19051906// pearlDrop is a smooth teardrop built from two Bezier arcs -- unlike1907// gemPear's faceted polygon, a real drop pearl tapers continuously, with1908// no straight edges anywhere on its surface.1909func pearlDrop(base, metal, salt string) string {1910 gid := "prl" + salt1911 // wider and taller than the round's footprint suggests: a drop only1912 // fills half its bounding box, and at 20x30 it read as a seed1913 w, h := 32.0*stoneScale, 42.0*stoneScale1914 path := `M ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) +1915 ` C ` + fnum(subjX+w*0.55) + ` ` + fnum(subjY-h*0.15) + ` ` + fnum(subjX+w*0.42) + ` ` + fnum(subjY+h*0.42) + ` ` + fnum(subjX) + ` ` + fnum(subjY+h*0.48) +1916 ` C ` + fnum(subjX-w*0.42) + ` ` + fnum(subjY+h*0.42) + ` ` + fnum(subjX-w*0.55) + ` ` + fnum(subjY-h*0.15) + ` ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) + ` Z`1917 body := `<path d="` + path + `" fill="url(#` + gid + `)"/>`1918 clipGeom := `<path d="` + path + `"/>`1919 sweep := pearlSweepOverlay(salt, base, clipGeom, w*1.6, h*0.9)1920 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep1921}19221923// pearlBaroqueOutline draws an irregular, asymmetric silhouette -- a real1924// baroque pearl has no symmetry at all, which is exactly what makes it a1925// baroque pearl rather than a round one.1926func pearlBaroqueOutline(seed string) []pt {1927 rnd := newPRNG(seed)1928 n := 101929 pts := make([]pt, n)1930 for k := 0; k < n; k++ {1931 a := 2 * math.Pi * float64(k) / float64(n)1932 rr := 21.0 * stoneScale * (0.75 + 0.4*rnd.floatIn(0, 1))1933 pts[k] = pt{subjX + rr*math.Cos(a)*1.1, subjY + rr*math.Sin(a)*0.9}1934 }1935 return pts1936}19371938// pearlBaroqueSmoothPath threads a quadratic Bezier through the midpoint1939// of every outline segment, so the irregular outline still reads as one1940// continuous organic surface -- a straight-edged polygon here would read1941// as a cut facet, which a baroque pearl's surface never has.1942func pearlBaroqueSmoothPath(pts []pt) string {1943 n := len(pts)1944 mid := func(a, b pt) pt { return pt{(a.x + b.x) / 2, (a.y + b.y) / 2} }1945 start := mid(pts[n-1], pts[0])1946 path := "M " + fnum(start.x) + " " + fnum(start.y)1947 for k := 0; k < n; k++ {1948 next := pts[(k+1)%n]1949 m := mid(pts[k], next)1950 path += " Q " + fnum(pts[k].x) + " " + fnum(pts[k].y) + " " + fnum(m.x) + " " + fnum(m.y)1951 }1952 return path + " Z"1953}19541955func pearlBaroque(base, metal, salt string) string {1956 gid := "prl" + salt1957 pathD := pearlBaroqueSmoothPath(pearlBaroqueOutline("baroque" + salt))1958 body := `<path d="` + pathD + `" fill="url(#` + gid + `)"/>`1959 clipGeom := `<path d="` + pathD + `"/>`1960 sweep := pearlSweepOverlay(salt, base, clipGeom, 40*stoneScale, 30*stoneScale)1961 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, "") + sweep1962}19631964// pearlCircled draws thin concentric growth rings over a round body --1965// the defining feature of a real "circled" (or "ringed") pearl, visible1966// grooves left by its own growth, not a decorative pattern.1967func pearlCircled(base, metal, salt string) string {1968 gid := "prl" + salt1969 r := 24.0 * stoneScale1970 body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`1971 ringTone := mixHex(base, "#000000", 0.12)1972 rings := ""1973 for _, f := range []float64{0.42, 0.58, 0.74, 0.9} {1974 rings += `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r*f) + `" fill="none" stroke="` + ringTone + `" stroke-width="0.9" opacity="0.35"/>`1975 }1976 clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`1977 sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)1978 return `<defs>` + pearlLusterGradient(gid, base, metal) + `</defs>` + pearlLayers(body, gid, rings) + sweep1979}19801981// PearlShapeNames lists the real jewelry-trade pearl shape categories --1982// deliberately NOT the faceted-cut vocabulary above (ShapeNames). Round1983// is the rarest/most prized (perfectly spherical); Baroque is the least1984// regular (freeform, no symmetry); Circled pearls show visible1985// concentric growth grooves.1986var PearlShapeNames = []string{"Round", "Oval", "Button", "Drop", "Baroque", "Circled"}19871988// renderPearlShape is PearlShapeNames' own counterpart to renderGem.1989func renderPearlShape(shapeIdx int, base, metal, salt string) string {1990 switch shapeIdx {1991 case 0:1992 return pearlRound(base, metal, salt)1993 case 1:1994 return pearlOval(base, metal, salt)1995 case 2:1996 return pearlButton(base, metal, salt)1997 case 3:1998 return pearlDrop(base, metal, salt)1999 case 4:2000 return pearlBaroque(base, metal, salt)2001 default:2002 return pearlCircled(base, metal, salt)2003 }2004}20052006/* -------------------------------------- ring -------------------------------------- */20072008// renderRing draws the metal band (with bevel) plus the neon energy-field2009// glow — fixed outer edge (never animates position), two static-geometry2010// layers crossfaded via opacity only. No blur filter: an SVG <filter>2011// wrapping an opacity-animated child is expensive to composite in2012// WebKit specifically (confirmed via a live Safari test against a mock2013// page with it removed — Chrome never showed the problem, masking it).2014// The gradient's own multi-stop falloff carries the glow instead.2015func renderRing(metalHi, metalLo, salt string) string {2016 // Light-from-top-left tilt: instead of one smooth diagonal fade, the2017 // gradient alternates hi/lo twice along the same diagonal axis, so2018 // the band reads as catching more than one reflected highlight as it2019 // curves — a real polished ring's "multi-band Fresnel" look, chosen2020 // over a plain 2-stop fade after a live side-by-side comparison of2021 // six alternative ring treatments. All three strokes (outer/main/2022 // inner) reference this one gradient so the light direction is2023 // consistent across the whole band, not just its center.2024 gid := "ring" + salt2025 // Wider tonal swing than the metal table's own hi/lo pair: the bright2026 // bands lift toward white and the dark bands sink toward black, so2027 // the band reads as polished metal turning through the light instead2028 // of a flat painted hoop (the old hi/lo-only version read as matte).2029 bright := lighten(metalHi, 0.45)2030 deep := mixHex(metalLo, "#000000", 0.45)2031 // Compact markup (same picture as the long form): stop offsets as2032 // fractions, circles in one translated group so cx/cy/fill aren't2033 // repeated -- this runs on every token read, bytes matter.2034 st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }2035 defs := `<linearGradient id="` + gid + `" x2="1" y2="1">` +2036 st("0", bright) + st(".12", metalHi) + st(".26", deep) + st(".4", metalLo) + st(".52", bright) +2037 st(".64", metalHi) + st(".78", deep) + st(".9", metalLo) + st("1", bright) + `</linearGradient>`20382039 // Drop shadow under the band (grounds it), the band, then bevel edges:2040 // a bright inner-top edge and a dark outer edge, the way a real2041 // rounded band catches light on the side facing the lamp and falls2042 // into shadow on the far side. No drawn specular arcs: they read as2043 // cartoon highlights; the band gradient carries the light on its own.2044 // All plain strokes, no filters.2045 outer := `<g fill="none" transform="translate(` + cnum(subjX) + ` ` + cnum(subjY) + `)">` +2046 `<circle r="52.6" stroke="#000" stroke-width="1.6" opacity=".45"/>` +2047 `<circle r="50" stroke="url(#` + gid + `)" stroke-width="4.4"/>` +2048 `<circle r="52.3" stroke="` + deep + `" stroke-width=".7" opacity=".8"/>` +2049 `<circle r="47.7" stroke="` + deep + `" stroke-width=".6" opacity=".7"/>` +2050 `<circle r="48.5" stroke="` + lighten(metalHi, 0.35) + `" stroke-width=".5" opacity=".45"/></g>`20512052 const rOut = 46.02053 const coreHalf = 0.62054 const outwardBloom = 0.92055 glowR := rOut + outwardBloom + 32056 pct := func(v float64) float64 {2057 p := v / glowR * 1002058 if p < 0 {2059 p = 02060 }2061 if p > 100 {2062 p = 1002063 }2064 return p2065 }2066 // Core tinted in the metal's own tone rather than near-white, and the2067 // animated layer peaks lower and breathes slower (see dur): next to a2068 // realistic stone the old white neon pulse was the most stylised thing2069 // on the canvas.2070 coreColor := lighten(metalHi, 0.25)2071 donutStops := func(inwardBloom, peakOp float64, gid string) string {2072 bloomOp := peakOp * 0.352073 midOp := peakOp * 0.72074 return `<radialGradient id="` + gid + `" cx="50%" cy="50%" r="50%">` +2075 `<stop offset="0%" stop-color="` + metalHi + `" stop-opacity="0"/>` +2076 `<stop offset="` + pctnum(pct(rOut-inwardBloom-1.5)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +2077 `<stop offset="` + pctnum(pct(rOut-inwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +2078 `<stop offset="` + pctnum(pct(rOut-coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +2079 `<stop offset="` + pctnum(pct(rOut)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(peakOp) + `"/>` +2080 `<stop offset="` + pctnum(pct(rOut+coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +2081 `<stop offset="` + pctnum(pct(rOut+outwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +2082 `<stop offset="` + pctnum(pct(rOut+outwardBloom+1)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +2083 `<stop offset="100%" stop-color="` + metalHi + `" stop-opacity="0"/></radialGradient>`2084 }20852086 rnd := newPRNG("energy" + salt)2087 gidA, gidB := "glowA"+salt, "glowB"+salt2088 // 2.13-3.2 s: 75% of the original energy pulse's speed (gems.gno and2089 // gemsart2 ran 1.6-2.4 s; gemsartfab had slowed it to 3.5-5.5 s)2090 dur := rnd.floatIn(2.13, 3.2)2091 phase := rnd.floatIn(0, dur)20922093 stopsA := donutStops(2.5*2.0/3.0*0.5, 0.4, gidA)2094 stopsB := donutStops(8.0*2.0/3.0*0.5, 0.6, gidB)20952096 energy := `<defs>` + stopsA + stopsB + `</defs>` +2097 `<g>` +2098 `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidA + `)"/>` +2099 `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidB + `)" opacity="0" class="gt-glow" style="--glow-dur:` + fnum(dur) + `s;--glow-delay:-` + fnum(phase) + `s"/>` +2100 `</g>`21012102 return `<defs>` + defs + `</defs>` + outer + energy2103}21042105/* ------------------------------------- motion ------------------------------------- */21062107const (2108 motionVDur, motionHDur, motionRDur, motionSDur = 3.4, 4.66, 5.81, 6.562109 motionRotMin, motionRotMax = 4.0, 8.02110 motionScale = 1.092111)21122113// applyMotion wraps content in the floating-holder animation (vertical2114// float + sideways drift + left/right rock + scale pulse), each axis on2115// its own independently-phased period so pieces never move in sync.2116// CSS @keyframes (see styleBlock), not SMIL -- WebKit's SMIL engine2117// scales badly with many concurrently-animated elements on one page,2118// confirmed via a live cross-browser test. transform-origin:view-box2119// on gt-r/gt-s replaces SMIL's manual translate-to-pivot/scale/2120// translate-back trick, so this nests in fewer elements than before,2121// not more.2122// motionPhases draws a token's four motion phases and its rock angle.2123// Shared with applyCascade so the fire can lock to the rock: a flash2124// should happen when the stone tilts, not on a timer of its own.2125func motionPhases(salt string) (vphase, hphase, rphase, sphase, rotDeg float64) {2126 rnd := newPRNG("motion" + salt)2127 vphase = rnd.floatIn(0, motionVDur)2128 hphase = rnd.floatIn(0, motionHDur)2129 rphase = rnd.floatIn(0, motionRDur)2130 sphase = rnd.floatIn(0, motionSDur)2131 rotDeg = rnd.floatIn(motionRotMin, motionRotMax)2132 return2133}21342135func applyMotion(content, salt string) string {2136 vphase, hphase, rphase, sphase, rotDeg := motionPhases(salt)21372138 // The rock and the pulse pivot on the ring centre by geometry, not by2139 // transform-box/transform-origin: the motion groups sit inside a group2140 // already translated to (subjX, subjY), so their own origin IS the ring2141 // centre, and the content is shifted back inside them. Safari has2142 // mishandled transform-box inside <img> SVGs, which swings a stone2143 // around the image corner instead of its centre.2144 return `<g transform="translate(` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +2145 `<g class="gt-v" style="--v-dur:` + fnum(motionVDur) + `s;--v-delay:-` + fnum(vphase) + `s">` +2146 `<g class="gt-h" style="--h-dur:` + fnum(motionHDur) + `s;--h-delay:-` + fnum(hphase) + `s">` +2147 `<g class="gt-r" style="--r-dur:` + fnum(motionRDur) + `s;--r-delay:-` + fnum(rphase) + `s;--rot-deg:` + fnum(rotDeg) + `deg">` +2148 `<g class="gt-s" style="--s-dur:` + fnum(motionSDur) + `s;--s-delay:-` + fnum(sphase) + `s">` +2149 `<g transform="translate(-` + fnum(subjX) + ` -` + fnum(subjY) + `)">` +2150 content + `</g></g></g></g></g></g>`2151}21522153/* ------------------------------- ring and stone size ------------------------------- */2154// Chosen 2026-09-26 from side-by-sides of several sizes (100 pieces each,2155// all animated): the ring is drawn 16% larger and the stone at scale 1.212156// (was 0.78), so a bigger stone still keeps clear of the band. Both sit2157// sizeLift units higher so the larger ring clears the chain plate (top at2158// y 134), and the status dot moves out so it does not touch the band. The2159// ring is scaled as one group around its own centre: its strokes, bevel2160// and glow keep their proportions. Plain group transforms, no filters.2161const (2162 ringScale = 1.162163 sizeLift = 6.02164)21652166func setRing(els string) string {2167 return `<g transform="translate(` + fnum(subjX) + ` ` + fnum(subjY-sizeLift) + `) scale(` + fnum(ringScale) + `) translate(-` + fnum(subjX) + ` -` + fnum(subjY) + `)">` + els + `</g>`2168}2169func setStone(els string) string { return `<g transform="translate(0 -` + fnum(sizeLift) + `)">` + els + `</g>` }2170func setBadge(els string) string { return `<g transform="translate(5 2)">` + els + `</g>` }21712172/* ---------------------------------- status badge ---------------------------------- */21732174const dotX, dotY = subjX + 50, subjY + 4221752176func renderStatusBadge(active bool) string {2177 if active {2178 return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#7fd88f"/>` +2179 // r starts at 4 (the solid dot's own radius, so the ring2180 // begins flush with its edge) and scales up via transform --2181 // see styleBlock's gt-kf-badge-pulse comment for why not r.2182 // pulse ring drawn at the origin of a translated group, so its2183 // scale grows from the dot's own centre in every browser2184 `<g transform="translate(` + fnum(dotX) + ` ` + fnum(dotY) + `)"><circle r="4" fill="none" stroke="#7fd88f" stroke-width="1.2" class="gt-badge-pulse" opacity="0.8"/></g>`2185 }2186 return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#5a5f6b"/>`2187}21882189/* ------------------------------------ facet fire ------------------------------------ */2190// The stone's sparkle, modelled on what a cut diamond does under a2191// moving light rather than on a decorative shimmer:2192// - scintillation: a facet flashes on sharply and decays, in a third2193// of a second, not a slow symmetric fade;2194// - fire: the flash is white at its peak and takes on that facet's2195// spectral colour as it dies -- colour follows brilliance;2196// - sweep: a flash passes ACROSS the stone in one direction (facets2197// ordered by their projection along a random sweep axis), and only2198// about half of them light on any pass, so it stays sparse;2199// - motion-locked: the sweeps sit on the MID-swings of the rock2200// animation (see motionPhases and applyCascade's delay), where the2201// stone turns fastest and a real reflection moves fastest, and each2202// sweep travels the way the stone is turning at that moment -- one2203// way on the clockwise swing, the reverse on the way back -- along2204// the axis a fixed top-left light traces across a turning stone. So2205// the sparkle reads as caused by the motion. A few lone pinpoint2206// flashes at random times are the exception.2207// Cost is unchanged from the old cascade: one solid-filled overlay path2208// per lit facet, animated on opacity and fill only.22092210// facetSpan is one self-closing facet element found in renderGem's own2211// markup, in the same left-to-right order it was drawn -- which is also2212// each facet's real adjacency order around its ring, since every gem_*2213// function draws its ring in one continuous angular sweep. ringMember2214// distinguishes a real facet (class="f", set by facetStyle.facet) from2215// the one non-ring table/center/backing element each shape draws2216// without it -- that one stays static, outside the cascade.2217type facetSpan struct {2218 start, end int2219 ringMember bool2220}22212222// scanFacetSpans walks renderGem's own markup once, left to right, with2223// plain string scanning rather than regexp -- this runs on every2224// TokenURI/Render call, and CollectionSummary/WalletSummary's own2225// pagination fix already established that anything unbounded or2226// unnecessarily expensive in a per-token read path becomes a real "out2227// of gas" failure at real collection sizes, not a theoretical concern.2228func scanFacetSpans(markup string) []facetSpan {2229 var spans []facetSpan2230 i := 02231 for {2232 rel := strings.Index(markup[i:], "<")2233 if rel < 0 {2234 break2235 }2236 start := i + rel2237 relEnd := strings.Index(markup[start:], "/>")2238 if relEnd < 0 {2239 break2240 }2241 end := start + relEnd + 22242 tag := markup[start:end]2243 i = end2244 isFacetTag := strings.HasPrefix(tag, "<path") || strings.HasPrefix(tag, "<circle") || strings.HasPrefix(tag, "<polygon")2245 if !isFacetTag {2246 continue2247 }2248 spans = append(spans, facetSpan{start: start, end: end, ringMember: strings.Contains(tag, `class="f"`)})2249 }2250 return spans2251}225222532254// applyCascade is the stone's fire. Every flash-capable facet (class2255// "f", chosen in renderGem3D) gets a duplicate of its path stacked above2256// the facets, and ALL of them share two keyframe rules per stone instead2257// of owning one each -- the per-facet keyframe blocks were several2258// kilobytes of every token. What differs per facet rides on the element2259// itself: --c its spectral colour, --o its peak strength, and an2260// animation-delay that places it in the sweep.2261//2262// The flash shape is unchanged from the hand-scheduled version: white at2263// the peak, the facet's own spectral colour on the decay, under a second.2264// Rule A fires on the rock animation's clockwise mid-swing and sweeps2265// along fireSweepAxis; rule B fires on the return swing and sweeps the2266// other way. Facets are split between the two, so a stone sparkles2267// forth and back in time with its own motion (period = two rock cycles,2268// delays offset from the token's rock phase, see motionPhases).2269const (2270 fireSweepAxis = -35.0 * math.Pi / 1802271 fireSweepSpan = 1.6 // seconds a sweep takes to cross the stone2272)22732274func applyCascade(markup, tidStr string, fs *facetStyle) (overlay string, css string) {2275 if fs == nil || len(fs.ringCent) == 0 {2276 return "", ""2277 }2278 spans := scanFacetSpans(markup)2279 rnd := newPRNG("fire" + tidStr)2280 _, _, rphase, _, _ := motionPhases(tidStr)2281 period := 2 * motionRDur2282 axis := fireSweepAxis + rnd.floatIn(-0.3, 0.3)2283 dx, dy := math.Cos(axis), math.Sin(axis)22842285 // flash: 0 -> white peak -> coloured decay -> 0, in the first ~8% of2286 // the period (~0.9s); B is the same shape half a period later.2287 ka, kb := "fa"+tidStr, "fb"+tidStr2288 shape := func(at float64) string {2289 return fnum1(at) + `%{opacity:0}` + fnum1(at+2) + `%{opacity:var(--o);fill:#fff}` +2290 fnum1(at+4.5) + `%{opacity:.4;fill:var(--c)}` + fnum1(at+8) + `%{opacity:0}`2291 }2292 per := fnum(period)2293 // opacity="0" rides on each overlay as an attribute, not only in this2294 // CSS: a thumbnailer that ignores CSS animation (or CSS) must see the2295 // resting state, not an unfilled (black) copy of every flash facet.2296 css = `.` + ka + `{animation:` + ka + ` ` + per + `s linear infinite}.` + kb + `{animation:` + kb + ` ` + per + `s linear infinite}` +2297 `@keyframes ` + ka + `{` + shape(12.5) + `100%{opacity:0}}` +2298 `@keyframes ` + kb + `{` + shape(62.5) + `100%{opacity:0}}`22992300 var out strings.Builder2301 type glintAt struct {2302 x, y, delay float642303 rule string2304 }2305 var cands []glintAt2306 ringIdx := -12307 for _, span := range spans {2308 if !span.ringMember {2309 continue2310 }2311 ringIdx++2312 if ringIdx >= len(fs.ringCent) {2313 break2314 }2315 c := fs.ringCent[ringIdx]2316 color, colorOp := fs.flash.sample(c.x, c.y)2317 // position along the sweep axis, -1..1 across the stone2318 pos := ((c.x-subjX)*dx + (c.y-gemCY)*dy) / (30 * stoneScale)2319 name := ka2320 if rnd.floatIn(0, 1) < 0.5 {2321 name, pos = kb, -pos2322 }2323 // the rock runs at delay -rphase; the flash windows sit at 12.5%2324 // and 62.5% of two rock cycles = the rock's two mid-swings2325 delay := rphase - 0.25*motionRDur + period - pos*fireSweepSpan/22326 tag := markup[span.start:span.end]2327 geomEnd := strings.Index(tag, `" fill=`)2328 if geomEnd < 0 {2329 continue2330 }2331 cands = append(cands, glintAt{c.x, c.y, delay, name})2332 out.WriteString(tag[:geomEnd] + `" opacity="0" class="` + name + `" style="--o:` + fnum(math.Round(math.Min(1, rnd.floatIn(0.85, 1.05)*colorOp)*10)/10) + `;--c:` + hex3(color) +2333 `;animation-delay:-` + fnum1(delay) + `s"/>`)2334 }2335 // Star glints: the brightest moment of a real diamond is a pinpoint that2336 // throws four rays. Three facets (one on the forward sweep, two spread2337 // out) each get a four-point star that pops at that facet's white peak2338 // -- same delay as its flash, so it lands exactly as the facet goes2339 // white -- scales up with a small twist and collapses. Plain paths on2340 // transform + opacity only; opacity="0" rests them for thumbnailers.2341 if len(cands) > 0 && fs.prof.sat >= 0.3 {2342 n := 32343 if fs.prof.sat < 0.6 {2344 n = 2 // quartz, topaz: less fire, fewer stars2345 }2346 ga, gb := "ga"+tidStr, "gb"+tidStr2347 star := func(at float64) string {2348 return fnum1(at+0.6) + `%{opacity:0;transform:scale(.2) rotate(0)}` +2349 fnum1(at+2.2) + `%{opacity:1;transform:scale(1) rotate(25deg)}` +2350 fnum1(at+4.4) + `%{opacity:0;transform:scale(.35) rotate(55deg)}`2351 }2352 css += `.` + ga + `,.` + gb + `{opacity:0}` +2353 `.` + ga + `{animation:` + ga + ` ` + per + `s linear infinite}.` + gb + `{animation:` + gb + ` ` + per + `s linear infinite}` +2354 `@keyframes ` + ga + `{0%{opacity:0}` + star(12.5) + `100%{opacity:0}}` +2355 `@keyframes ` + gb + `{0%{opacity:0}` + star(62.5) + `100%{opacity:0}}`2356 r := 7.5 * stoneScale2357 w := 0.9 * stoneScale2358 ray := `M0 -` + fnum1(r) + `L` + fnum1(w) + ` -` + fnum1(w) + `L` + fnum1(r) + ` 0L` + fnum1(w) + ` ` + fnum1(w) +2359 `L0 ` + fnum1(r) + `L-` + fnum1(w) + ` ` + fnum1(w) + `L-` + fnum1(r) + ` 0L-` + fnum1(w) + ` -` + fnum1(w) + `Z`2360 var g strings.Builder2361 used := map[int]bool{}2362 for k := 0; k < n && k < len(cands); k++ {2363 i := rnd.intn(len(cands))2364 for tries := 0; used[i] && tries < len(cands); tries++ {2365 i = (i + 1) % len(cands)2366 }2367 used[i] = true2368 c := cands[i]2369 cls := "ga" + tidStr2370 if c.rule == kb {2371 cls = "gb" + tidStr2372 }2373 g.WriteString(`<g transform="translate(` + fnum1(c.x) + ` ` + fnum1(c.y) + `)"><g class="` + cls + `" opacity="0" style="animation-delay:-` + fnum1(c.delay) + `s">` +2374 `<path d="` + ray + `" fill="#fff"/><circle r="` + fnum1(1.1*stoneScale) + `" fill="#fff"/></g></g>`)2375 }2376 fs.glints = g.String()2377 }2378 return out.String(), css2379}23802381/* ---------------------------------- label + serial ---------------------------------- */23822383func renderMetalLabelRect(metalHi, metalLo string, x, y, w, h, rx float64, salt string) string {2384 // Echoes the ring's multi-band tilt but heavily muted — a soft2385 // two-step blend instead of the ring's hard alternating stops, so the2386 // bar doesn't visually fight the chain-name text sitting on top of it.2387 gid := "lg" + salt2388 // A brushed metal plate rather than a flat bar: a horizontal sweep2389 // with a bright band, a dark outline that seats it on the canvas, a2390 // light top edge and dark bottom edge (bevel), and a rivet in each2391 // corner. The chain text is drawn on top by RenderTokenSVG with an2392 // engraved shadow (see there). Plain shapes, one gradient.2393 bright := lighten(metalHi, 0.4)2394 deep := mixHex(metalLo, "#000000", 0.5)2395 st := func(off, c string) string { return `<stop offset="` + off + `" stop-color="` + c + `"/>` }2396 defs := `<linearGradient id="` + gid + `">` +2397 st("0", metalLo) + st(".22", metalHi) + st(".42", bright) + st(".62", metalHi) + st("1", metalLo) + `</linearGradient>`2398 rect := `<rect x="` + cnum(x) + `" y="` + cnum(y) + `" width="` + cnum(w) + `" height="` + cnum(h) + `" rx="` + cnum(rx) + `" fill="url(#` + gid + `)" stroke="` + deep + `" stroke-width=".8"/>`2399 // Bevel edges follow the plate's own rounded outline, inset a hair:2400 // a thin soft highlight along the top chamfer, a thin shade along the2401 // bottom one -- low contrast, the way a real machined edge reads.2402 const in_ = 0.92403 arc := ` a` + cnum(rx-in_) + ` ` + cnum(rx-in_) + ` 0 0 `2404 d := cnum(rx - in_)2405 edges := `<g fill="none" stroke-linecap="round">` +2406 `<path d="M` + cnum(x+in_) + ` ` + cnum(y+rx) + arc + `1 ` + d + ` -` + d + `H` + cnum(x+w-rx) + arc + `1 ` + d + ` ` + d + `" stroke="` + lighten(metalHi, 0.45) + `" stroke-width=".45" opacity=".4"/>` +2407 `<path d="M` + cnum(x+in_) + ` ` + cnum(y+h-rx) + arc + `0 ` + d + ` ` + d + `H` + cnum(x+w-rx) + arc + `0 ` + d + ` -` + d + `" stroke="` + deep + `" stroke-width=".5" opacity=".5"/></g>`2408 // Slotted screws: a shadowed countersink, a head a shade off the plate2409 // tone with a soft sheen up-left, and a slot at a different angle per2410 // screw (and per token). The head is defined once and placed with2411 // <use>; all four slots are one path -- bytes matter on every read.2412 sid := "sh" + salt2413 defs += `<g id="` + sid + `"><circle cx=".15" cy=".2" r="1.6" fill="` + deep + `" opacity=".55"/>` +2414 `<circle r="1.3" fill="` + mixHex(metalLo, metalHi, 0.4) + `" stroke="` + deep + `" stroke-width=".25"/>` +2415 `<circle cx="-.4" cy="-.4" r=".75" fill="` + lighten(metalHi, 0.3) + `" opacity=".35"/></g>`2416 seed := 02417 for k := 0; k < len(salt); k++ {2418 seed += int(salt[k])2419 }2420 heads, slots := "", ""2421 n := 02422 for _, px := range []float64{x + 4.2, x + w - 4.2} {2423 for _, py := range []float64{y + 3.7, y + h - 3.7} {2424 heads += `<use href="#` + sid + `" x="` + cnum(px) + `" y="` + cnum(py) + `"/>`2425 ang := float64((seed*37+n*71)%180) * math.Pi / 1802426 dx, dy := 0.9*math.Cos(ang), 0.9*math.Sin(ang)2427 slots += `M` + cnum(px-dx) + ` ` + cnum(py-dy) + `l` + cnum(2*dx) + ` ` + cnum(2*dy)2428 n++2429 }2430 }2431 rivets := heads + `<path d="` + slots + `" stroke="` + deep + `" stroke-width=".45" stroke-linecap="round"/>`2432 return `<defs>` + defs + `</defs>` + rect + edges + rivets2433}24342435// Plate lettering. SVGs shown as <img> can't load web fonts, so this is a2436// system-font stack: DIN-style engineering capitals where the OS has one2437// (DIN Alternate on Apple, Bahnschrift on Windows, Roboto Condensed on2438// Android), tracked out like stamped nameplate type. Engraved rather than2439// embossed: the letters sit in the plate and their lower lip catches the2440// light, so the highlight copy goes a hair BELOW the fill.2441const (2442 plateFont = `'DIN Alternate',Bahnschrift,'Roboto Condensed',sans-serif`2443 plateSize = 8.62444 plateSpacing = 1.12445 plateMaxW = 100.02446)24472448func renderPlateText(label, fill, metalHi string) string {2449 g := `<g font-size="` + cnum(plateSize) + `" letter-spacing="` + cnum(plateSpacing) + `" text-anchor="middle" font-family="` + plateFont + `" font-weight="700"`2450 // Rough width estimate (condensed caps ~0.52em plus tracking); only a2451 // label that would run into the screws gets squeezed to fit.2452 if est := float64(len(label))*(plateSize*0.52+plateSpacing); est > plateMaxW {2453 g += ` textLength="` + cnum(plateMaxW) + `" lengthAdjust="spacingAndGlyphs"`2454 }2455 x := cnum(subjX)2456 return g + `><text x="` + x + `" y="144.9" fill="` + lighten(metalHi, 0.5) + `" opacity=".45">` + label + `</text>` +2457 `<text x="` + x + `" y="144.4" fill="` + fill + `">` + label + `</text></g>`2458}24592460// renderSerialText uses the token's REAL assigned ID (already the exact2461// Crockford-base32 seqid format gno.land itself produces) — no lookup2462// table or reimplemented encoding needed, unlike the Python prototype2463// which had to hardcode a verified sample of real IDs since it couldn't2464// call the real package directly.2465func renderSerialText(tidStr, color string) string {2466 return `<text x="` + fnum(subjX) + `" y="159.5" font-size="6.5" text-anchor="middle" fill="` + color + `" font-family="ui-monospace,monospace" opacity="0.85">#` + tidStr + `</text>`2467}24682469/* ------------------------------- stone / color / metal tables ------------------------------- */24702471type namedColor struct{ hex, name string }24722473// stoneByChain, stonePalettes, and stoneNames are mutable, not const --2474// a deliberate departure from this file's "pure functions, no state"2475// design (see the package doc comment). Originally hardcoded switch2476// statements covering only Topaz-1/Sapphire-1, seeded here with the2477// same real gemology research, but now editable via SetStoneForChain/2478// AddStoneColor (nftminter.gno) so a FUTURE gno.land testnet generation2479// that gets a real gem codename can be recognized without a contract2480// redeploy — the whole point of putting this data in state instead of2481// source.2482var stoneByChain = map[string]string{2483 "TOPAZ-1": "Topaz",2484 "SAPPHIRE-1": "Sapphire",2485 "PEARL-1": "Pearl",2486 // Mainnet. Diamond: the hardest, most-dispersive stone for the chain2487 // that is final, and the one gem every collector reads at a glance.2488 "GNOLAND-1": "Diamond",2489}24902491// All hex values here (except Red, see "Not Set" below) are the original2492// palette run through the exact CSS/SVG feColorMatrix type="saturate"2493// formula at s=1.5 (150%) -- confirmed against a live side-by-side2494// comparison (three saturation levels, same six real minted pieces)2495// before choosing this one, "the enhanced saturation" over both the2496// original 100% and a more extreme 200% option. Not a hand-picked2497// repaint -- every value is the deterministic output of that one matrix,2498// so hues stay true, only intensity increases.2499//2500// Baked directly into these constants rather than applied via a live2501// <filter> (or an equivalent Go function called on every render): a2502// live filter under several CSS-animated ancestors, times potentially2503// dozens of gems rendered on one page at once, was a real, confirmed2504// Safari motion-jitter source in this project (same category of fix2505// already applied once before, to the rings' glow effect). Precomputing2506// the target color once, here, and shipping it as an ordinary static2507// fill value removes that cost entirely -- nothing left for any browser2508// to re-rasterize. The matrix itself, if a future palette addition ever2509// needs to reproduce it by hand:2510// a=.213+.787s b=.715-.715s c=.072-.072s2511// d=.213-.213s e=.715+.285s f=.072-.072s2512// g=.213-.213s h=.715-.715s i=.072+.928s2513// R'=a*R+b*G+c*B G'=d*R+e*G+f*B B'=g*R+h*G+i*B (s=1.5 here)2514var stonePalettes = map[string][]namedColor{2515 // Accuracy pass (2026-09-25), five hand-set exceptions to the matrix2516 // rule, each because the boosted value named the wrong stone: blue2517 // sapphire is royal/cornflower blue, not cyan (#00b6ff read as2518 // aquamarine); green sapphire is teal-green, not emerald green;2519 // padparadscha is by definition pinkish-orange (lotus), not orange-2520 // red; natural blue topaz is pale sky blue (saturated blue topaz is2521 // irradiated); sherry topaz is yellowish/reddish brown, not orange.2522 "Sapphire": {2523 {"#2a5fe0", "Blue"}, {"#ff6ea4", "Pink"}, {"#ff8501", "Orange"}, {"#ffd500", "Yellow"},2524 {"#1aa88a", "Green"}, {"#a658ff", "Violet"}, {"#e6e9f8", "White"}, {"#ff8f72", "Padparadscha"},2525 },2526 "Topaz": {2527 {"#f5edd1", "Colorless"}, {"#ffa300", "Imperial"}, {"#ffd100", "Golden"},2528 {"#c96a28", "Sherry Brown"}, {"#86cdf2", "Sky Blue"}, {"#ff96ba", "Pink"},2529 },2530 // Not Set was originally a muted, deliberately non-gem palette (dull2531 // grays), reasoning that a chain with no assigned stone shouldn't get2532 // gem-like colors. Replaced per request -- these read as dull/boring2533 // in practice, and this stone accounts for the large majority of2534 // chains (every testnet except Topaz-1/Sapphire-1), so it now gets2535 // the full generic gem-color spectrum instead: every hue already used2536 // by Sapphire/Topaz (this is intentional reuse, not new hex values --2537 // a "Not Set" piece can land on any color a real gem might), plus Red,2538 // which neither Sapphire nor Topaz can use (Sapphire excludes it2539 // because red corundum is classified as ruby, a separate gem name).2540 // Red is the one deliberate exception to this file's "every hex is2541 // the s=1.5 matrix output" rule above -- the matrix-derived #ff4b4b2542 // read too saturated in practice, so it's hand-tuned ~12.5% less2543 // saturated (#f45656) instead, per request.2544 // Crystal (appended last, so every earlier index keeps its meaning)2545 // is the clearest stone in the pool. Not called Transparent: in2546 // gemology transparency is diaphaneity, a separate axis from colour,2547 // and this is a colour list. See fireProfileFor for how it is2548 // painted -- the hex here is only what the metadata reports and2549 // what the rim stroke lightens from.2550 // Quartz (2026-09-25) replaces "Not Set" for every chain without its2551 // own codename (TEST-1..13, BETANET, any future one): a generic2552 // "Red Asscher" was not a real gem, and quartz is the one gem family2553 // whose natural varieties cover the range. Hand-set real colours:2554 // Rock Crystal (colourless), Amethyst (purple), Citrine (yellow to2555 // orange-yellow), Rose Quartz (pale, slightly milky pink), Smoky2556 // Quartz (grey-brown), Prasiolite (pale leek green; mostly heated2557 // amethyst, rare in nature). Weighted in colorWeights.2558 "Quartz": {2559 {"#eef2f7", "Rock Crystal"}, {"#9b59d6", "Amethyst"}, {"#f2b01e", "Citrine"},2560 {"#f5b9ca", "Rose Quartz"}, {"#7d6250", "Smoky Quartz"}, {"#a9d9a2", "Prasiolite"},2561 },2562 // Pearl (PEARL-1, added for the Pearl testnet generation) is the one2563 // deliberate exception to this file's "every hex is the s=1.5 matrix2564 // output" rule above, for a real gemological reason rather than a2565 // stylistic one: pearls are organic gems with a soft nacre sheen, not2566 // faceted crystals -- the same saturation boost that makes Sapphire/2567 // Topaz read as vivid gemstones would misrepresent what a pearl2568 // actually looks like. These 8 are real naturally-occurring pearl2569 // body colors (not dyed), sourced from gemological references, hand-2570 // tuned to a muted/pearly range instead of the boosted-saturation one:2571 // white (Akoya), cream, black (Tahitian -- reads as a very dark grey/2572 // green, never true black), silver, gold (South Sea), pink and peach2573 // (freshwater), and lavender (freshwater, one of the rarer natural2574 // overtones).2575 // Diamond (GNOLAND-1) is hand-picked like Pearl, not the s=1.5 matrix:2576 // real diamond colour grades. Colorless first (D-F, the classic stone),2577 // then the natural "fancy" colours in the trade's own names, including2578 // the browns sold as Champagne and Cognac, and Black (opaque, a metallic2579 // shine and no fire -- fireProfileFor paints it separately).2580 "Diamond": {2581 {"#f3f6fc", "Colorless"}, {"#ffc400", "Fancy Yellow"}, {"#ff4f9a", "Fancy Pink"},2582 {"#1f8fff", "Fancy Blue"}, {"#14c46e", "Fancy Green"}, {"#d9a560", "Champagne"},2583 {"#b4561a", "Cognac"}, {"#2b2d34", "Black"},2584 // appended 2026-09-25, so every earlier index keeps its meaning;2585 // red diamonds are purplish-red in life, purple ones are mostly2586 // violet-purple2587 {"#e0243a", "Red"}, {"#ff7a1a", "Orange"}, {"#9b4fd6", "Purple"},2588 },2589 "Pearl": {2590 {"#f8f6f0", "White"}, {"#f0e4c8", "Cream"}, {"#3a3a3c", "Black"}, {"#c8cdd4", "Silver"},2591 {"#d4af6a", "Gold"}, {"#f0d4dc", "Pink"}, {"#f2c9a8", "Peach"}, {"#d8cbe0", "Lavender"},2592 },2593}25942595// colorWeights is how often each palette entry is drawn at mint, in2596// parts per 100 by palette index. Diamond and Quartz are weighted, in the order2597// of real-world rarity: colourless and the yellows and browns are what2598// the mines mostly give, pink/blue/green are rare, red is the rarest2599// diamond there is. Every other stone draws its colours evenly. A colour2600// appended later (AddStoneColor) with no weight here gets2601// defaultColorWeight.2602var colorWeights = map[string][]int{2603 // Colorless Yellow Pink Blue Green Champagne Cognac Black Red Orange Purple2604 "Diamond": {30, 18, 5, 4, 3, 14, 12, 8, 1, 3, 2},2605 // Rock Crystal and Amethyst are the commonest quartz, Citrine and Smoky2606 // next, Rose Quartz is rarely clear enough to facet, Prasiolite rare.2607 "Quartz": {25, 25, 18, 9, 18, 5},2608}26092610const defaultColorWeight = 526112612func colorWeight(stone string, idx int) int {2613 w, ok := colorWeights[stone]2614 if !ok {2615 return 12616 }2617 if idx < len(w) {2618 return w[idx]2619 }2620 return defaultColorWeight2621}26222623// PickColorIndex maps a uniform random roll to a palette index for stone,2624// honouring colorWeights. The mint realm calls this instead of an even2625// intn(ColorPaletteSize(stone)).2626func PickColorIndex(stone string, roll uint64) int {2627 n := ColorPaletteSize(stone)2628 total := 02629 for i := 0; i < n; i++ {2630 total += colorWeight(stone, i)2631 }2632 r := int(roll % uint64(total))2633 for i := 0; i < n; i++ {2634 r -= colorWeight(stone, i)2635 if r < 0 {2636 return i2637 }2638 }2639 return n - 12640}26412642// ColorProbability is the chance (0..1) that a mint of stone draws colour2643// idx -- the same weights PickColorIndex uses. The mint realm multiplies2644// it into a piece's Rarity tier.2645func ColorProbability(stone string, idx int) float64 {2646 n := ColorPaletteSize(stone)2647 total := 02648 for i := 0; i < n; i++ {2649 total += colorWeight(stone, i)2650 }2651 return float64(colorWeight(stone, idx)) / float64(total)2652}26532654// ColorRarityCSV lists "name:percent" for every colour of stone, e.g. for2655// a mint page's odds table. Percent has one decimal.2656func ColorRarityCSV(stone string) string {2657 n := ColorPaletteSize(stone)2658 total := 02659 for i := 0; i < n; i++ {2660 total += colorWeight(stone, i)2661 }2662 out := make([]string, n)2663 for i := 0; i < n; i++ {2664 pm := colorWeight(stone, i) * 1000 / total2665 out[i] = colorForStone(stone, i).name + ":" + ufmt.Sprintf("%d.%d", pm/10, pm%10)2666 }2667 return strings.Join(out, ";")2668}26692670// ChainAlwaysActive: mainnet never sunsets, so a GNOLAND-1 piece always2671// shows the green active light. RenderTokenSVG forces it; the mint realm2672// should record Active=true for it too so the metadata agrees.2673func ChainAlwaysActive(chain string) bool { return chain == "GNOLAND-1" }26742675// stoneNames tracks insertion order for deterministic listing (Go/Gno2676// map iteration order is randomized) -- kept in sync with stonePalettes2677// by registerStoneName, called from both AddStoneColor and2678// SetStoneForChain (nftminter.gno).2679var stoneNames = []string{"Sapphire", "Topaz", "Pearl", "Diamond", "Quartz"}26802681func registerStoneName(stone string) {2682 for _, s := range stoneNames {2683 if s == stone {2684 return2685 }2686 }2687 stoneNames = append(stoneNames, stone)2688}26892690// SetStoneForChain and AddStoneColor live here (not in nftminter.gno's own2691// wrapper functions of the same purpose) because they mutate this2692// package's own private maps -- stoneByChain/stonePalettes/stoneNames2693// aren't visible outside this package once gemsart is its own separate2694// deploy, unlike when this was one combined realm. nftminter.gno's2695// SetStoneForChain/AddStoneColor now just assertOwner() then delegate here.2696func SetStoneForChain(chainName, stone string) {2697 stoneByChain[chainName] = stone2698 registerStoneName(stone)2699}27002701func AddStoneColor(stone, hex, name string) {2702 stonePalettes[stone] = append(stonePalettes[stone], namedColor{hex: hex, name: name})2703 registerStoneName(stone)2704}27052706// StoneNamesCSV lists every registered stone name, semicolon-separated,2707// in the order each was first introduced.2708func StoneNamesCSV() string {2709 return strings.Join(stoneNames, ";")2710}27112712// ColorPaletteCSV lists stone's full color palette as "hex:name;...", in2713// colorIdx order. Implemented here rather than in nftminter.gno since it2714// needs namedColor's own unexported hex/name fields.2715func ColorPaletteCSV(stone string) string {2716 palette := stonePalettes[stone]2717 pairs := make([]string, len(palette))2718 for i, c := range palette {2719 pairs[i] = c.hex + ":" + c.name2720 }2721 return strings.Join(pairs, ";")2722}27232724// MetalToneHex reports metalName's hi/lo gradient tones as "hi;lo", or ""2725// if unrecognized. Implemented here for the same reason as2726// ColorPaletteCSV -- needs metalTone's own unexported hi/lo fields.2727func MetalToneHex(metalName string) string {2728 t, ok := metals[metalName]2729 if !ok {2730 return ""2731 }2732 return t.hi + ";" + t.lo2733}27342735// StoneForChain: only gno.land testnet generations that were actually2736// given a gemstone codename (Topaz-1, Sapphire-1 at launch) get one —2737// every other/future chain is "Not Set" rather than a guessed mineral2738// name, unless the owner explicitly assigns one via SetStoneForChain.2739func StoneForChain(chain string) string {2740 if s, ok := stoneByChain[chain]; ok {2741 return s2742 }2743 return "Quartz"2744}27452746// colorForStone looks up colorIdx in the palette matching stone,2747// clamping out-of-range indices instead of panicking — AddTraitPreset2748// validates at write time, this is just defense in depth for read time.2749// A stone with no palette yet (registered via SetStoneForChain but2750// never given a color via AddStoneColor) falls back to "Not Set"'s2751// palette rather than indexing into an empty slice.2752func colorForStone(stone string, colorIdx int) namedColor {2753 palette := stonePalettes[stone]2754 if len(palette) == 0 {2755 palette = stonePalettes["Quartz"]2756 }2757 if colorIdx < 0 {2758 colorIdx = 02759 }2760 if colorIdx >= len(palette) {2761 colorIdx = len(palette) - 12762 }2763 return palette[colorIdx]2764}27652766// ColorPaletteSize reports how many colors are valid for stone, so2767// AddTraitPreset can validate a caller-supplied colorIdx. Same2768// empty-palette fallback as colorForStone, for the same reason.2769func ColorPaletteSize(stone string) int {2770 if n := len(stonePalettes[stone]); n > 0 {2771 return n2772 }2773 return len(stonePalettes["Quartz"])2774}27752776// FindShapeIndex reverse-maps a rendered "Shape" trait value (e.g.2777// "Oval") back to its index into ShapeNames. A source realm for a2778// migration only ever exposes the rendered trait STRING (via2779// CollectionSummary), never the raw index a migrated token would need2780// to regenerate its own art instead of storing a full copy of it — this2781// closes that gap for the migration tooling (see2782// AddMigrationSnapshotEntry). ok is false if name doesn't match any2783// current shape (not expected for anything this realm's own gems.gno2784// produced, but a caller should treat that as "unrecognized," not guess).2785// FindShapeIndex takes stone (from StoneForChain) since Pearl draws2786// shapeIdx from a completely different list (PearlShapeNames, not2787// ShapeNames) -- a rendered "Baroque" only resolves against the right2788// one.2789func FindShapeIndex(stone, name string) (idx int, ok bool) {2790 names := ShapeNames2791 if stone == "Pearl" {2792 names = PearlShapeNames2793 }2794 for i, s := range names {2795 if s == name {2796 return i, true2797 }2798 }2799 return -1, false2800}28012802// FindColorIndex reverse-maps a rendered "Color" trait value back to2803// its index in chain's own stone palette — colorIdx is only meaningful2804// relative to a specific stone (derived from chain via StoneForChain),2805// not a single global color list, so this needs the chain too. Same2806// migration-tooling purpose as FindShapeIndex.2807func FindColorIndex(chain, colorName string) (idx int, ok bool) {2808 stone := StoneForChain(chain)2809 palette := stonePalettes[stone]2810 if len(palette) == 0 {2811 palette = stonePalettes["Quartz"]2812 }2813 for i, c := range palette {2814 if c.name == colorName {2815 return i, true2816 }2817 }2818 return -1, false2819}28202821type metalTone struct{ hi, lo string }28222823// metals holds each metal's ring/label gradient tones. A var rather than2824// a const purely because Gno doesn't allow map literals as consts, not2825// because it's meant to be mutable -- no exported function reassigns it2826// (nftminter.gno's MetalToneHex is read-only), so a minted piece's ring2827// color is fixed the moment it's revealed, same as every other trait.2828// Gold/Silver/Bronze are two darkening2829// passes down from their original brighter values; Platinum stays at2830// ONE pass (not two) and Obsidian is unchanged. Verified byte-for-byte2831// against all 100 pieces of the confirmed "Latest iteration, all2832// hundred (mask applied)" reference batch -- every non-Platinum ring2833// matched the two-pass tone exactly, but every Platinum ring in that2834// same confirmed batch used the one-pass tone, not two; a later commit2835// had mistakenly carried Platinum through the second darkening pass2836// too. Reverted just that one entry to match the confirmed reference.2837var metals = map[string]metalTone{2838 "Gold": {"#a48b3f", "#6a5117"},2839 "Silver": {"#95989c", "#5e646c"},2840 "Bronze": {"#865b34", "#52391d"},2841 "Platinum": {"#c3c6c8", "#87909a"},2842 "Obsidian": {"#3a3d47", "#0d0e12"},2843}28442845// MetalNames is an ordered counterpart to metals -- Go/Gno map iteration2846// order is randomized, so on-the-fly random trait assembly (MintAssembled,2847// nftminter.gno) needs an indexable slice to pick a random metal from.2848var MetalNames = []string{"Gold", "Silver", "Bronze", "Platinum", "Obsidian"}28492850type metalLabelTone struct{ bg, text string }28512852var metalLabel = map[string]metalLabelTone{2853 "Gold": {"#f4d060", "#04121a"},2854 "Silver": {"#dfe4ea", "#04121a"},2855 "Bronze": {"#c9884f", "#04121a"},2856 "Platinum": {"#eef2f5", "#04121a"},2857 "Obsidian": {"#5a5f6b", "#e8e9f0"},2858}28592860/* --------------------------------- full assembly --------------------------------- */28612862// RenderTokenSVG assembles one token's complete SVG from its small trait2863// selection, mirroring badge() in the Python prototype exactly (same2864// element order, same layout constants). `salt` seeds every per-piece2865// animation timing and every gradient id — the token's own real ID2866// (tidStr), so nothing here is computed before mint assigns one.2867// RenderTokenThumbSVG is RenderTokenSVG for a list thumbnail (a gnoweb2868// collection table row): the same piece, minus the fire flashes, the rock2869// relighting and the table reflections, which do not read at thumbnail2870// size, for roughly a third less gas, so a page fits more rows under a2871// query's gas ceiling.2872func RenderTokenThumbSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) string {2873 svg, _, _, _ := renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, true)2874 return svg2875}28762877func RenderTokenSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) (svg string, stone string, colorHex string, colorName string) {2878 return renderToken(chain, metal, shapeIdx, colorIdx, active, tidStr, false)2879}28802881func renderToken(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string, thumb bool) (svg string, stone string, colorHex string, colorName string) {2882 var cascadeCSS string2883 stone = StoneForChain(chain)2884 if ChainAlwaysActive(chain) {2885 active = true2886 }2887 nc := colorForStone(stone, colorIdx)2888 colorHex, colorName = nc.hex, nc.name2889 mt := metals[metal]28902891 ringEls := renderRing(mt.hi, mt.lo, tidStr)2892 // colorHex is already the boosted value -- stonePalettes' own hex2893 // constants ARE the s=1.5 saturate output (see stonePalettes' comment2894 // for the formula, and for which entry is deliberately excluded: Red).2895 //2896 // Pearl draws from renderPearlShape (smooth luster gradient, no2897 // facets) instead of the faceted pipeline -- a real pearl is never2898 // cut. applyCascade still runs on it rather than being skipped:2899 // scanFacetSpans finds no class="f" facets in pearl markup and2900 // returns nothing, so it stays a no-op there without its own branch.2901 //2902 // Faceted stones stack, bottom to top: body glow (silhouette), the2903 // facets themselves (translucent solid fills at varying opacity, so2904 // the glow shows through some more than others), the cascade's flash2905 // overlays, then one faint rim stroke. A caustic pool under the stone2906 // and a pavilion silhouette behind it were tried and dropped: on a2907 // flat dark ground they read as a second layer, not as depth. All static gradients and paths -- no <filter>, no mask.2908 // An earlier iteration also drew hairline facet edges and two white2909 // specular hot spots; both read as cartoonish and were dropped.2910 var gemMarkup string2911 if stone == "Pearl" {2912 pearl := renderPearlShape(shapeIdx, colorHex, mt.hi, tidStr)2913 overlay, css := applyCascade(pearl, tidStr, nil)2914 gemMarkup, cascadeCSS = pearl+overlay, css2915 } else {2916 fs := &facetStyle{prof: fireProfileFor(stone, colorName), zone: newPRNG("zone" + tidStr), stone: stone, thumb: thumb}2917 under, facets, over := renderGem3D(shapeIdx, colorHex, tidStr, fs)2918 overlay, css := applyCascade(facets, tidStr, fs)2919 cascadeCSS = css2920 gemMarkup = under + `<g fill-opacity=".9">` + facets + `</g>` + overlay + over + fs.glints2921 }2922 floatingGem := applyMotion(gemMarkup, tidStr)2923 status := renderStatusBadge(active)2924 serial := renderSerialText(tidStr, mt.hi)2925 labelText := metalLabel[metal].text2926 labelRect := renderMetalLabelRect(mt.hi, mt.lo, 24, 134, 122, 15, 3, tidStr)29272928 inner := `<rect width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" fill="#0a0b10"/>` +2929 renderStars(tidStr) + setRing(ringEls) + setStone(floatingGem) + setBadge(status) + serial + labelRect +2930 renderPlateText(chain+" CHAIN", labelText, mt.hi)29312932 // width/height on the root <svg>, not just viewBox -- without them the2933 // image has no definite intrinsic size as an <img> resource, and2934 // gnoweb's own CSS (anchors wrapping embedded thumbnails are2935 // `display: inline-block; text-wrap: balance`) collapses an2936 // ambiguous-size image down to 2x2px. Confirmed live against a real2937 // deployed collection (gno-tools' nft-minter tool hit the exact same2938 // bug); see ~/gno-land-dev-notes.md.2939 // cascadeCSS is a second, separate <style> block rather than folded2940 // into styleBlock() -- every rule in it (keyframe names, timings) is2941 // unique to THIS token, unlike styleBlock()'s shared, fixed rules2942 // reused across every token that's ever rendered.2943 svg = `<svg xmlns="http://www.w3.org/2000/svg" width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" viewBox="0 0 ` + fnum(canvasW) + ` ` + fnum(canvasH) + `">` + styleBlock() +2944 `<style>` + cascadeCSS + `</style>` + inner + `</svg>`2945 return svg, stone, colorHex, colorName2946}29472948// styleBlock is embedded once per token's own SVG (a real NFT image is a2949// fully self-contained document -- this can't live in a page-level2950// stylesheet). Drives every animated piece via CSS @keyframes instead2951// of SMIL <animate>/<animateTransform>: each token's own randomized2952// timing/angles are set as custom properties in a small inline style=""2953// per element, against these shared, fixed keyframe rules.2954func styleBlock() string {2955 const easeOut = "cubic-bezier(0,0,.3,1)"2956 // Compact form (gemsart3d2): shared selectors, animation shorthand with2957 // the delay inline, ease-in-out in place of the near-identical2958 // cubic-bezier(.45,0,.55,1). Same motion, ~40% fewer bytes. The2959 // will-change hint stays: it mattered for Safari.2960 return `<style>.gt-v,.gt-h,.gt-r,.gt-s{will-change:transform}` +29612962 `.gt-v{animation:gt-v var(--v-dur) ease-in-out var(--v-delay) infinite}` +2963 `.gt-h{animation:gt-h var(--h-dur) ease-in-out var(--h-delay) infinite}` +2964 `.gt-r{animation:gt-r var(--r-dur) ease-in-out var(--r-delay) infinite}` +2965 `.gt-s{animation:gt-s var(--s-dur) ease-in-out var(--s-delay) infinite}` +2966 `.gt-glow{animation:gt-glow var(--glow-dur) ease-in-out var(--glow-delay) infinite}` +2967 // rock-synced lighting: same duration, delay and easing as .gt-r,2968 // peaking at its two extremes (25% and 75%)2969 `.gt-la{animation:gt-la var(--r-dur) ease-in-out var(--r-delay) infinite}` +2970 `.gt-lb{animation:gt-lb var(--r-dur) ease-in-out var(--r-delay) infinite}` +2971 `@keyframes gt-la{0%,50%,100%{opacity:0}25%{opacity:1}}` +2972 `@keyframes gt-lb{0%,50%,100%{opacity:0}75%{opacity:1}}` +2973 `.gt-badge-pulse{animation:gt-bp 1.8s ` + easeOut + ` infinite}` +2974 // the float is balanced around the ring centre (+2.5 to -2.5); it2975 // used to rise 0 to -5 only, which sat every stone 2.5 high on average2976 `@keyframes gt-v{0%,100%{transform:translateY(2.5px)}50%{transform:translateY(-2.5px)}}` +2977 `@keyframes gt-h{50%{transform:translateX(1.8px)}}` +2978 `@keyframes gt-r{25%{transform:rotate(calc(-1*var(--rot-deg)))}75%{transform:rotate(var(--rot-deg))}}` +2979 `@keyframes gt-s{50%{transform:scale(` + fnum(motionScale) + `)}}` +2980 // a twinkle is a brief flare from the star's resting brightness,2981 // once per its own period (see renderStars)2982 `.gt-tw{animation:gt-tw var(--d) ease-in-out var(--dl) infinite}@keyframes gt-tw{0%,84%,100%{opacity:.4}90%{opacity:1}94%{opacity:.2}}` +2983 `@keyframes gt-glow{50%{opacity:1}}` +2984 `@keyframes gt-bp{from{transform:scale(1);opacity:.8}to{transform:scale(2.25);opacity:0}}` +2985 `</style>`2986}2987Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.