Pure package detail
gemsart2
gno.land/p/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gemsart2
Indexed deployment identity with independently loaded latest RPC source. Functions and Render are realm-only RPC capabilities.
Indexed deployment
Identity
- Package path
- gno.land/p/g17cjym5e9hhws46lt6329pv2gtx2ay0503hgems/gemsart2
- Block
- 200823
- Deployed (UTC)
- Transaction
- 19nPFedILytrg0h+oM7FL4JNWmPMGMU6PnohslvUyFs=
Latest RPC state
Source
// Package gemsart: on-chain SVG generator for the "gem in a ring"
// collection, ported from the Python prototype used to design and
// approve the final look. Every function here is pure — no state reads
// beyond its own stone/color tables — so a token's full image can be
// recomputed on any read (TokenURI/Render) instead of being stored as a
// precomputed blob.
//
// A separate deploy from the nftminter realm that imports it (not one
// combined package as originally designed) -- gnomcp's own gno_addpkg
// tool hardcodes a 10,000,000 ugnot max-deposit ceiling per deploy call
// with no override, and the combined source (~175KB) needs ~17.6M. Each
// half fits under that ceiling deployed separately. The public surface
// below (ShapeNames/MetalNames/RenderTokenSVG/StoneForChain/
// ColorPaletteSize, plus SetStoneForChain/AddStoneColor/StoneNamesCSV/
// ColorPaletteCSV/MetalToneHex) exists specifically so nftminter.gno can
// still reach everything it needs across that package boundary.
//
// This is the architecture change requested explicitly, back when this
// was still one file: instead of the owner precomputing and storing a
// full ~7.6KB image string per token, this package's shared generator
// code is stored ONCE, and each token only needs a handful of small
// trait selections (see traitPreset in nftminter.gno) — the image is
// assembled from those on demand. Storage cost stops scaling with token
// count; it's dominated by this package's own one-time deployment cost
// instead.
package gemsart2
import (
"crypto/sha256"
"math"
"strings"
"gno.land/p/nt/ufmt/v0"
)
const (
canvasW, canvasH = 170.0, 170.0
subjX, subjY = 85.0, 78.0
)
/* ------------------------------ number/color formatting ------------------------------ */
// fnum formats a float to 2 decimal places without depending on ufmt
// supporting %f/%g (uncertain in this environment) — plain integer math
// and %d instead. Zero-pads the fraction manually rather than via
// "%02d": ufmt.Sprintf does NOT implement width/zero-pad flags (confirmed
// empirically -- Sprintf("%d.%02d", 1, 9) returns "1.9", not "1.09"), so
// relying on "%02d" silently corrupted any value whose hundredths digit
// rounded to 1-9 (e.g. 1.09 rendered as "1.9", a ~10x error) throughout
// this whole file, since every coordinate/radius/angle goes through this
// one function.
func fnum(v float64) string {
neg := v < 0
if neg {
v = -v
}
scaled := int64(v*100 + 0.5)
whole := scaled / 100
frac := scaled % 100
fracStr := ufmt.Sprintf("%d", frac)
if frac < 10 {
fracStr = "0" + fracStr
}
s := ufmt.Sprintf("%d.%s", whole, fracStr)
if neg {
return "-" + s
}
return s
}
// pctnum formats a float already meant as a percentage (2 decimals, no
// clamping — callers clamp first where it matters).
func pctnum(v float64) string { return fnum(v) }
func hexToRGB(hex string) (int, int, int) {
h := hex
if len(h) > 0 && h[0] == '#' {
h = h[1:]
}
r := hexByte(h[0:2])
g := hexByte(h[2:4])
b := hexByte(h[4:6])
return r, g, b
}
func hexByte(s string) int {
v := 0
for i := 0; i < len(s); i++ {
c := s[i]
var d int
switch {
case c >= '0' && c <= '9':
d = int(c - '0')
case c >= 'a' && c <= 'f':
d = int(c-'a') + 10
case c >= 'A' && c <= 'F':
d = int(c-'A') + 10
}
v = v*16 + d
}
return v
}
func rgbToHex(r, g, b int) string {
return "#" + hexDigit2(r) + hexDigit2(g) + hexDigit2(b)
}
func hexDigit2(v int) string {
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
const digits = "0123456789abcdef"
return string(digits[v/16]) + string(digits[v%16])
}
func clampByte(v float64) int {
iv := int(v)
if iv < 0 {
return 0
}
if iv > 255 {
return 255
}
return iv
}
// lighten blends hex toward white by amt (0-1) — used so a neon glow's
// core reads as genuinely brighter than its own base color, not just a
// higher-opacity version of the same tone.
func lighten(hex string, amt float64) string {
r, g, b := hexToRGB(hex)
nr := clampByte(float64(r) + (255-float64(r))*amt)
ng := clampByte(float64(g) + (255-float64(g))*amt)
nb := clampByte(float64(b) + (255-float64(b))*amt)
return rgbToHex(nr, ng, nb)
}
// mixHex blends hex toward target by amt (0-1) — used to soften a hard
// two-color gradient stop into an in-between tone, e.g. the label bar's
// muted echo of the ring's own multi-band highlight/shadow swing.
func mixHex(hex, target string, amt float64) string {
r1, g1, b1 := hexToRGB(hex)
r2, g2, b2 := hexToRGB(target)
nr := clampByte(float64(r1) + float64(r2-r1)*amt)
ng := clampByte(float64(g1) + float64(g2-g1)*amt)
nb := clampByte(float64(b1) + float64(b2-b1)*amt)
return rgbToHex(nr, ng, nb)
}
// shadeSeries returns n shades of hex scaled between lo and hi brightness
// — the palette a faceted gem's individual facets pick from so adjacent
// triangles/quads read as catching light differently.
func shadeSeries(hex string, n int, lo, hi float64) []string {
r, g, b := hexToRGB(hex)
out := make([]string, n)
for i := 0; i < n; i++ {
t := lo
if n > 1 {
t = lo + (hi-lo)*float64(i)/float64(n-1)
}
out[i] = rgbToHex(clampByte(float64(r)*t), clampByte(float64(g)*t), clampByte(float64(b)*t))
}
return out
}
/* --------------------------------- tiny deterministic PRNG --------------------------------- */
// prng is a small deterministic generator seeded from a string, used only
// for per-piece COSMETIC animation timing (glow phase/duration, motion
// phases/rotation degree) — never for which trait combination a piece
// has (that's traitPreset, randomly assembled by nftminter.gno's own
// separate PRNG in randomTraitPreset, not curated and not this one).
// Determinism here doesn't weaken the anti-sniping design at all: knowing
// in advance that a glow pulses at 2.1s instead of 1.9s isn't a trait
// anyone would snipe for.
type prng struct {
seed string
n int
}
func newPRNG(seed string) *prng { return &prng{seed: seed} }
func (p *prng) next() uint64 {
p.n++
material := ufmt.Sprintf("%s:%d", p.seed, p.n)
sum := sha256.Sum256([]byte(material))
var v uint64
for i := 0; i < 8; i++ {
v = v<<8 | uint64(sum[i])
}
return v
}
func (p *prng) floatIn(lo, hi float64) float64 {
frac := float64(p.next()%1000000) / 1000000.0
return lo + frac*(hi-lo)
}
func (p *prng) intn(n int) int {
if n <= 1 {
return 0
}
return int(p.next() % uint64(n))
}
/* ------------------------------------ path helpers ------------------------------------ */
type pt struct{ x, y float64 }
func polyPath(pts []pt) string {
s := "M"
for i, p := range pts {
if i > 0 {
s += " L"
}
s += fnum(p.x) + "," + fnum(p.y)
}
return s + " Z"
}
func polygonPoints(pts []pt) string {
s := ""
for i, p := range pts {
if i > 0 {
s += " "
}
s += fnum(p.x) + "," + fnum(p.y)
}
return s
}
/* -------------------------------- facet opacity constant -------------------------------- */
const facetOp = 0.55 // "Option B: translucent facets" — light shows through
const facetOpCenter = 0.5 // facetOp * 0.9, rounded
/* ------------------------------------ gem shapes ------------------------------------ */
// Each takes a base hex color (+ a salt string, used only by the one shape
// that needs a unique gradient id) and returns SVG fragment markup. Ported
// directly from the approved Python generator — same anatomy, same shade
// ratios, same facet counts.
// roundSpiralN/roundSpiralTwist parameterize gemRound's "spiral twist" cut
// -- the outer crown ring is rotated against the inner table-to-crown ring
// by roundSpiralTwist of a facet-step, so the two rings read as a pinwheel
// instead of sitting in static radial alignment. Picked after a live
// side-by-side comparison of six alternative Round Brilliant facet
// layouts (the previous cut's 8-facet ring pair, offset by a plain static
// half-step, read as an odd seam at this size).
const (
roundSpiralN = 10
roundSpiralTwist = 0.35
)
// roundOutline returns the roundSpiralN outer-girdle vertices of gemRound's
// twisted crown ring at radius r -- the SAME points gemRound itself
// computes inline for its outer facet loop. Shared with shapeClip case 0
// so the shimmer's mask can never silently drift away from the actual
// rendered outline.
func roundOutline(r float64) []pt {
n := roundSpiralN
pts := make([]pt, n)
for k := 0; k < n; k++ {
a := 2*math.Pi*float64(k)/float64(n) - math.Pi/2 + math.Pi/float64(n)*(1+roundSpiralTwist)
pts[k] = pt{subjX + r*math.Cos(a), subjY + r*math.Sin(a)}
}
return pts
}
func gemRound(base string) string {
shades := shadeSeries(base, 12, 0.55, 1.35)
tableR, crownR, r := 20.0*0.3, 20.0*0.62, 20.0
n := roundSpiralN
twistStep := roundSpiralTwist * math.Pi / float64(n)
outerPts := roundOutline(r)
out := ""
for k := 0; k < n; k++ {
a1 := 2*math.Pi*float64(k)/float64(n) - math.Pi/2
a2 := 2*math.Pi*float64(k+1)/float64(n) - math.Pi/2 + twistStep
p1i := pt{subjX + tableR*math.Cos(a1), subjY + tableR*math.Sin(a1)}
p2i := pt{subjX + tableR*math.Cos(a2), subjY + tableR*math.Sin(a2)}
p1o := pt{subjX + crownR*math.Cos(a1+twistStep*0.5), subjY + crownR*math.Sin(a1+twistStep*0.5)}
p2o := pt{subjX + crownR*math.Cos(a2+twistStep*0.5), subjY + crownR*math.Sin(a2+twistStep*0.5)}
out += `<path d="` + polyPath([]pt{p1i, p1o, p2o, p2i}) + `" fill="` + shades[k%len(shades)] + `" opacity="0.55"/>`
}
for k := 0; k < n; k++ {
a1 := 2*math.Pi*float64(k)/float64(n) - math.Pi/2 + math.Pi/float64(n)
p1i := pt{subjX + crownR*math.Cos(a1), subjY + crownR*math.Sin(a1)}
p2i := pt{subjX + crownR*math.Cos(a1+2*math.Pi/float64(n)), subjY + crownR*math.Sin(a1+2*math.Pi/float64(n))}
p1o := outerPts[k]
p2o := outerPts[(k+1)%n]
out += `<path d="` + polyPath([]pt{p1i, p1o, p2o, p2i}) + `" fill="` + shades[(k+5)%len(shades)] + `" opacity="0.55"/>`
}
out += `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(tableR) + `" fill="` + shades[len(shades)-1] + `" opacity="0.5"/>`
return out
}
func gemEmerald(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
hw, hh := 17.0, 22.0
outer := []pt{
{subjX - hw, subjY - hh + 7}, {subjX - hw + 7, subjY - hh}, {subjX + hw - 7, subjY - hh}, {subjX + hw, subjY - hh + 7},
{subjX + hw, subjY + hh - 7}, {subjX + hw - 7, subjY + hh}, {subjX - hw + 7, subjY + hh}, {subjX - hw, subjY + hh - 7},
}
inner := make([]pt, len(outer))
for i, p := range outer {
inner[i] = pt{p.x*0.55 + subjX*0.45, p.y*0.55 + subjY*0.45}
}
out := `<path d="` + polyPath(outer) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := range outer {
a, b := outer[i], outer[(i+1)%len(outer)]
ia, ib := inner[i], inner[(i+1)%len(inner)]
out += `<path d="` + polyPath([]pt{a, b, ib, ia}) + `" fill="` + shades[i%len(shades)] + `" opacity="0.55"/>`
}
out += `<path d="` + polyPath(inner) + `" fill="` + shades[len(shades)-1] + `" opacity="0.5"/>`
return out
}
func gemPrincess(base string) string {
shades := shadeSeries(base, 8, 0.55, 1.35)
half := 19.0
pts := []pt{{subjX - half, subjY - half}, {subjX + half, subjY - half}, {subjX + half, subjY + half}, {subjX - half, subjY + half}}
mid := pt{subjX, subjY}
out := ""
for i := 0; i < 4; i++ {
a, b := pts[i], pts[(i+1)%4]
m := pt{(a.x + b.x) / 2, (a.y + b.y) / 2}
out += `<path d="` + polyPath([]pt{mid, a, m}) + `" fill="` + shades[i*2] + `" opacity="0.55"/>`
out += `<path d="` + polyPath([]pt{mid, m, b}) + `" fill="` + shades[i*2+1] + `" opacity="0.55"/>`
}
return out
}
// gemRaw's jitter uses a FIXED seed (not per-piece) — same as the Python
// original, where every Raw Crystal piece shares the identical silhouette
// (just a different base color), rather than each token getting its own
// random outline.
func rawCrystalPoints(r float64) []pt {
rnd := newPRNG("gem-raw-fixed-seed-3")
n := 9
pts := make([]pt, n)
for k := 0; k < n; k++ {
a := 2 * math.Pi * float64(k) / float64(n)
rr := r * (0.7 + 0.5*rnd.floatIn(0, 1))
pts[k] = pt{subjX + rr*math.Cos(a), subjY + rr*math.Sin(a)}
}
return pts
}
func gemRaw(base string) string {
shades := shadeSeries(base, 7, 0.4, 1.1)
pts := rawCrystalPoints(22)
mid := pt{subjX, subjY}
out := ""
for i := range pts {
a, b := pts[i], pts[(i+1)%len(pts)]
out += `<path d="` + polyPath([]pt{mid, a, b}) + `" fill="` + shades[i%len(shades)] + `" opacity="0.55"/>`
}
return out
}
func gemPear(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
w, h := 19.0, 29.0
pts := []pt{
{subjX, subjY - h*0.5}, {subjX - w*0.55, subjY - h*0.1}, {subjX - w*0.35, subjY + h*0.35},
{subjX, subjY + h*0.55}, {subjX + w*0.35, subjY + h*0.35}, {subjX + w*0.55, subjY - h*0.1},
}
mid := pt{subjX, subjY}
out := `<path d="` + polyPath(pts) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := range pts {
a, b := pts[i], pts[(i+1)%len(pts)]
out += `<path d="` + polyPath([]pt{mid, a, b}) + `" fill="` + shades[(i*2)%len(shades)] + `" opacity="0.55"/>`
}
return out
}
func gemHeart(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
s := 24.0
pts := []pt{
{subjX, subjY + s*0.6}, {subjX - s*0.55, subjY - s*0.05}, {subjX - s*0.28, subjY - s*0.5},
{subjX, subjY - s*0.15}, {subjX + s*0.28, subjY - s*0.5}, {subjX + s*0.55, subjY - s*0.05},
}
mid := pt{subjX, subjY}
out := `<path d="` + polyPath(pts) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := range pts {
a, b := pts[i], pts[(i+1)%len(pts)]
out += `<path d="` + polyPath([]pt{mid, a, b}) + `" fill="` + shades[(i*2+1)%len(shades)] + `" opacity="0.55"/>`
}
return out
}
func trillionOutline(r float64) []pt {
angles := []float64{0, 2 * math.Pi / 3, 4 * math.Pi / 3}
pts := make([]pt, 3)
for i, a := range angles {
pts[i] = pt{subjX + r*math.Cos(a-math.Pi/2), subjY + r*math.Sin(a-math.Pi/2)}
}
return pts
}
func gemTrillion(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
pts := trillionOutline(23)
mid := pt{subjX, subjY}
out := `<path d="` + polyPath(pts) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := 0; i < 3; i++ {
a, b := pts[i], pts[(i+1)%3]
out += `<path d="` + polyPath([]pt{mid, a, b}) + `" fill="` + shades[(i*2)%len(shades)] + `" opacity="0.55"/>`
}
return out
}
// ellipseArcAngles returns n angles (starting straight up, going
// clockwise) spaced at equal ARC-LENGTH steps around an rx,ry ellipse,
// found by numeric integration over a fine sample of the curve. Equal
// ANGLE steps (the simpler approach) bunch up unevenly on a true ellipse
// -- narrower near the pointed ends, wider near the sides -- which is
// what made the old Oval cut's facets look lopsided.
func ellipseArcAngles(rx, ry float64, n int) []float64 {
const steps = 720
cum := make([]float64, steps+1)
prevX, prevY := rx*math.Cos(-math.Pi/2), ry*math.Sin(-math.Pi/2)
for i := 1; i <= steps; i++ {
a := -math.Pi/2 + 2*math.Pi*float64(i)/float64(steps)
x, y := rx*math.Cos(a), ry*math.Sin(a)
dx, dy := x-prevX, y-prevY
cum[i] = cum[i-1] + math.Sqrt(dx*dx+dy*dy)
prevX, prevY = x, y
}
total := cum[steps]
angles := make([]float64, n)
ti := 0
for k := 0; k < n; k++ {
target := total * float64(k) / float64(n)
for ti < steps && cum[ti+1] < target {
ti++
}
angles[k] = -math.Pi/2 + 2*math.Pi*float64(ti)/float64(steps)
}
return angles
}
// ovalOutline returns the 10 points gemOval's girdle actually sits on --
// arc-length spaced (see ellipseArcAngles), so the rendered silhouette is
// a true evenly-spaced polygon, not one with wider facets near the sides
// and narrower ones near the tips. Shared with shapeClip case 7 for the
// same reason as roundOutline: a smooth-ellipse clip is visibly larger
// than this polygon between vertices.
func ovalOutline(rx, ry float64) []pt {
n := 10
angles := ellipseArcAngles(rx, ry, n)
pts := make([]pt, n)
for k, a := range angles {
pts[k] = pt{subjX + rx*math.Cos(a), subjY + ry*math.Sin(a)}
}
return pts
}
// gemOval draws a "cross-girdle" cut -- an outer and inner arc-length-
// spaced oval ring with straight cross-facets between them, plus a
// filled center -- borrowed from the Radiant cut's cleaner anatomy
// instead of the old cut's pie slices converging on a single center
// point (which is what read as odd once the ellipse stopped being a
// circle).
func gemOval(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
rx, ry := 17.0, 23.0
innerRx, innerRy := rx*0.5, ry*0.5
n := 10
angles := ellipseArcAngles(rx, ry, n)
outerPts := make([]pt, n)
innerPts := make([]pt, n)
for k, a := range angles {
outerPts[k] = pt{subjX + rx*math.Cos(a), subjY + ry*math.Sin(a)}
innerPts[k] = pt{subjX + innerRx*math.Cos(a), subjY + innerRy*math.Sin(a)}
}
out := `<path d="` + polyPath(outerPts) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for k := 0; k < n; k++ {
a, b := outerPts[k], outerPts[(k+1)%n]
ia, ib := innerPts[k], innerPts[(k+1)%n]
out += `<path d="` + polyPath([]pt{a, b, ib, ia}) + `" fill="` + shades[k%len(shades)] + `" opacity="0.55"/>`
}
out += `<path d="` + polyPath(innerPts) + `" fill="` + shades[len(shades)-1] + `" opacity="0.5"/>`
return out
}
func asscherOutline(half float64) []pt {
c := half * 0.4
return []pt{
{subjX - half + c, subjY - half}, {subjX + half - c, subjY - half}, {subjX + half, subjY - half + c}, {subjX + half, subjY + half - c},
{subjX + half - c, subjY + half}, {subjX - half + c, subjY + half}, {subjX - half, subjY + half - c}, {subjX - half, subjY - half + c},
}
}
func gemAsscher(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
outer := asscherOutline(19)
inner := make([]pt, len(outer))
for i, p := range outer {
inner[i] = pt{p.x*0.5 + subjX*0.5, p.y*0.5 + subjY*0.5}
}
out := `<path d="` + polyPath(outer) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := range outer {
a, b := outer[i], outer[(i+1)%len(outer)]
ia, ib := inner[i], inner[(i+1)%len(inner)]
out += `<path d="` + polyPath([]pt{a, b, ib, ia}) + `" fill="` + shades[i%len(shades)] + `" opacity="0.55"/>`
}
out += `<path d="` + polyPath(inner) + `" fill="` + shades[len(shades)-1] + `" opacity="0.5"/>`
return out
}
func radiantOutline(hw, hh, c float64) []pt {
return []pt{
{subjX - hw + c, subjY - hh}, {subjX + hw - c, subjY - hh}, {subjX + hw, subjY - hh + c}, {subjX + hw, subjY + hh - c},
{subjX + hw - c, subjY + hh}, {subjX - hw + c, subjY + hh}, {subjX - hw, subjY + hh - c}, {subjX - hw, subjY - hh + c},
}
}
func gemRadiant(base string) string {
shades := shadeSeries(base, 6, 0.55, 1.35)
outer := radiantOutline(21, 17, 6)
inner := make([]pt, len(outer))
for i, p := range outer {
inner[i] = pt{p.x*0.5 + subjX*0.5, p.y*0.5 + subjY*0.5}
}
out := `<path d="` + polyPath(outer) + `" fill="` + shades[1] + `" opacity="0.55"/>`
for i := range outer {
a, b := outer[i], outer[(i+1)%len(outer)]
ia, ib := inner[i], inner[(i+1)%len(inner)]
out += `<path d="` + polyPath([]pt{a, b, ib, ia}) + `" fill="` + shades[(i+2)%len(shades)] + `" opacity="0.55"/>`
}
out += `<path d="` + polyPath(inner) + `" fill="` + shades[len(shades)-1] + `" opacity="0.5"/>`
return out
}
// roseOutline returns gemRose's 6 hexagon vertices at radius r -- the
// actual rendered silhouette of the "hexagonal" cut, matching a real
// antique hexagonal rose cut's flat-edged outline instead of a round
// girdle. Shared with shapeClip's default case for the same reason as
// roundOutline/ovalOutline.
func roseOutline(r float64) []pt {
n := 6
pts := make([]pt, n)
for k := 0; k < n; k++ {
a := 2*math.Pi*float64(k)/float64(n) - math.Pi/2
pts[k] = pt{subjX + r*math.Cos(a), subjY + r*math.Sin(a)}
}
return pts
}
// gemRose draws a hexagonal-silhouette rose cut -- a flat hexagon base
// with 6 facets converging on an off-center apex -- picked over the
// previous round cut's mismatched 6-inner/12-outer facet rings (their
// edges never lined up, reading as uneven).
func gemRose(base string) string {
shades := shadeSeries(base, 8, 0.5, 1.4)
r := 23.0
hexPts := roseOutline(r)
apex := pt{subjX, subjY - r*0.1}
out := `<polygon points="` + polygonPoints(hexPts) + `" fill="` + shades[0] + `" opacity="0.5"/>`
for k := 0; k < 6; k++ {
p1, p2 := hexPts[k], hexPts[(k+1)%6]
out += `<path d="` + polyPath([]pt{apex, p1, p2}) + `" fill="` + shades[2+k] + `" opacity="0.6"/>`
}
return out
}
// ShapeNames mirrors the Python SHAPE_NAMES list exactly, same order, so
// a stored ShapeIdx means the same cut on both sides.
// Cabochon (a smooth, unfaceted dome cut) was removed from the trait
// pool per request -- indices above it are unchanged (Rose Cut still
// falls through to the switch's default case, same as before).
var ShapeNames = []string{
"Round Brilliant", "Emerald Cut", "Princess Cut", "Raw Crystal", "Pear", "Heart",
"Trillion", "Oval", "Asscher", "Radiant", "Rose Cut",
}
// renderGem dispatches to the right gem_* function by index.
func renderGem(shapeIdx int, base string) string {
switch shapeIdx {
case 0:
return gemRound(base)
case 1:
return gemEmerald(base)
case 2:
return gemPrincess(base)
case 3:
return gemRaw(base)
case 4:
return gemPear(base)
case 5:
return gemHeart(base)
case 6:
return gemTrillion(base)
case 7:
return gemOval(base)
case 8:
return gemAsscher(base)
case 9:
return gemRadiant(base)
default:
return gemRose(base)
}
}
/* ------------------------------------ pearl shapes ------------------------------------ */
// Pearls are organic gems -- they grow into a form, they are never cut or
// faceted the way a crystal is. So unlike the cut vocabulary above
// (ShapeNames/renderGem), everything here draws a smooth, continuous
// surface: one radial luster gradient plus a soft diagonal highlight band
// (a pearl's real "orient" sheen), no straight-edged facet triangles.
// pearlLusterGradient returns a radial gradient (id=gid) approximating a
// pearl's nacre sheen: a bright off-center highlight fading through the
// body color to a slightly deeper rim tone.
func pearlLusterGradient(gid, base string) string {
hi := lighten(base, 0.55)
rim := mixHex(base, "#000000", 0.18)
return `<radialGradient id="` + gid + `" cx="38%" cy="32%" r="75%">` +
`<stop offset="0%" stop-color="` + hi + `"/>` +
`<stop offset="55%" stop-color="` + base + `"/>` +
`<stop offset="100%" stop-color="` + rim + `"/></radialGradient>`
}
// pearlSweepColorIntensity picks the sweep gradient's opacity multiplier
// off the base color's own luminance: a dark body (e.g. Black) already
// reads a screen-blended color band far more strongly than a light body
// does at the same opacity, so light bodies get a bigger boost off the
// shared baseline than dark ones do. Threshold/multipliers tuned by eye
// against the full 8-color palette, not derived from anything physical.
func pearlSweepColorIntensity(base string) float64 {
r, g, b := hexToRGB(base)
lum := 0.2126*float64(r)/255 + 0.7152*float64(g)/255 + 0.0722*float64(b)/255
if lum < 0.4 {
return 1.2
}
return 2.0
}
// pearlSweepGradient is the traveling band's own color: transparent at
// both ends, a soft pink/blue/green/gold spectrum in the middle -- an
// approximation of thin-film interference (the real source of nacre's
// iridescence) rather than a single flat highlight tone.
func pearlSweepGradient(gid, base string) string {
mult := pearlSweepColorIntensity(base)
o1, o2, o3 := fnum(0.3*mult), fnum(0.35*mult), fnum(0.3*mult)
return `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="0%">` +
`<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>` +
`<stop offset="18%" stop-color="#ffd2e6" stop-opacity="` + o1 + `"/>` +
`<stop offset="38%" stop-color="#cfe8ff" stop-opacity="` + o2 + `"/>` +
`<stop offset="58%" stop-color="#d4ffe4" stop-opacity="` + o3 + `"/>` +
`<stop offset="80%" stop-color="#fff0c2" stop-opacity="` + o3 + `"/>` +
`<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>` +
`</linearGradient>`
}
// pearlSweepOverlay replaces the old fixed pearlOrientSheen ellipse: a
// handful of slow, wide, translucent color bands that each cross the
// pearl's own silhouette once from a random angle, then wait, then cross
// again from a new random angle, on repeat. shapeGeom is that pearl's own
// outline (no fill needed -- only used inside a clipPath) so the color
// never spills past the gem's edge, the same way applyCascade's facet
// flashes are seeded per token off tidStr rather than being identical
// across every piece.
func pearlSweepOverlay(salt, base, shapeGeom string, w, h float64) string {
rnd := newPRNG(salt + "-sweep")
const passes = 3
total := 15.0 + rnd.floatIn(0, 9)
durs := make([]float64, passes)
sumDur := 0.0
for i := 0; i < passes; i++ {
durs[i] = 2.4 + rnd.floatIn(0, 2.2)
sumDur += durs[i]
}
gapTotal := total - sumDur
if gapTotal < float64(passes)*0.5 {
gapTotal = float64(passes) * 0.5
}
weights := make([]float64, passes)
sumW := 0.0
for i := 0; i < passes; i++ {
weights[i] = 0.4 + rnd.floatIn(0, 1)
sumW += weights[i]
}
gaps := make([]float64, passes)
for i := 0; i < passes; i++ {
gaps[i] = weights[i] / sumW * gapTotal
}
starts := make([]float64, passes)
t := gaps[0]
for i := 0; i < passes; i++ {
starts[i] = t
t += durs[i]
if i < passes-1 {
t += gaps[i+1]
}
}
travel := w * 1.05
clipID := "clp" + salt
gradID := "swp" + salt
off, on1, on2, out := fnum(-travel), fnum(-travel*0.5), fnum(travel*0.5), fnum(travel)
var defs strings.Builder
defs.WriteString(`<defs><clipPath id="` + clipID + `">` + shapeGeom + `</clipPath>`)
defs.WriteString(pearlSweepGradient(gradID, base))
var marks strings.Builder
marks.WriteString(`<g clip-path="url(#` + clipID + `)">`)
for i := 0; i < passes; i++ {
angle := fnum(rnd.floatIn(0, 360))
startPct := starts[i] / total * 100
durPct := durs[i] / total * 100
rampPct := durPct * 0.32
if rampPct > 6 {
rampPct = 6
}
p0, p1, p2, p3 := startPct, startPct+rampPct, startPct+durPct-rampPct, startPct+durPct
kf := "swp" + salt + "_" + ufmt.Sprintf("%d", i)
closing := ""
if p3 < 99.999 {
closing = `100%{transform:translate(` + off + `px,0);opacity:0}`
}
defs.WriteString(`<style>@keyframes ` + kf + `{` +
`0%{transform:translate(` + off + `px,0);opacity:0}` +
fnum(p0) + `%{transform:translate(` + off + `px,0);opacity:0}` +
fnum(p1) + `%{transform:translate(` + on1 + `px,0);opacity:1}` +
fnum(p2) + `%{transform:translate(` + on2 + `px,0);opacity:1}` +
fnum(p3) + `%{transform:translate(` + out + `px,0);opacity:0}` +
closing +
`}</style>`)
marks.WriteString(`<g transform="rotate(` + angle + ` ` + fnum(subjX) + ` ` + fnum(subjY) + `)">` +
`<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 + `)" style="mix-blend-mode:screen;animation:` + kf + ` ` + fnum(total) + `s linear infinite"/>` +
`</g>`)
}
marks.WriteString(`</g>`)
defs.WriteString(`</defs>`)
return defs.String() + marks.String()
}
func pearlRound(base, salt string) string {
gid := "prl" + salt
r := 24.0
body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + sweep
}
func pearlOval(base, salt string) string {
gid := "prl" + salt
rx, ry := 18.0, 25.0
body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + sweep
}
// pearlButton is flattened rather than round -- wider than tall, the
// real "button pearl" silhouette (as if gently pressed), popular in
// jewelry specifically because the flat back sits well against skin.
func pearlButton(base, salt string) string {
gid := "prl" + salt
rx, ry := 25.0, 18.0
body := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `" fill="url(#` + gid + `)"/>`
clipGeom := `<ellipse cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" rx="` + fnum(rx) + `" ry="` + fnum(ry) + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, rx*2, ry*2)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + sweep
}
// pearlDrop is a smooth teardrop built from two Bezier arcs -- unlike
// gemPear's faceted polygon, a real drop pearl tapers continuously, with
// no straight edges anywhere on its surface.
func pearlDrop(base, salt string) string {
gid := "prl" + salt
w, h := 20.0, 30.0
path := `M ` + fnum(subjX) + ` ` + fnum(subjY-h*0.55) +
` 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) +
` 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`
body := `<path d="` + path + `" fill="url(#` + gid + `)"/>`
clipGeom := `<path d="` + path + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, w*1.6, h*0.9)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + sweep
}
// pearlBaroqueOutline draws an irregular, asymmetric silhouette -- a real
// baroque pearl has no symmetry at all, which is exactly what makes it a
// baroque pearl rather than a round one.
func pearlBaroqueOutline(seed string) []pt {
rnd := newPRNG(seed)
n := 10
pts := make([]pt, n)
for k := 0; k < n; k++ {
a := 2 * math.Pi * float64(k) / float64(n)
rr := 21.0 * (0.75 + 0.4*rnd.floatIn(0, 1))
pts[k] = pt{subjX + rr*math.Cos(a)*1.1, subjY + rr*math.Sin(a)*0.9}
}
return pts
}
// pearlBaroqueSmoothPath threads a quadratic Bezier through the midpoint
// of every outline segment, so the irregular outline still reads as one
// continuous organic surface -- a straight-edged polygon here would read
// as a cut facet, which a baroque pearl's surface never has.
func pearlBaroqueSmoothPath(pts []pt) string {
n := len(pts)
mid := func(a, b pt) pt { return pt{(a.x + b.x) / 2, (a.y + b.y) / 2} }
start := mid(pts[n-1], pts[0])
path := "M " + fnum(start.x) + " " + fnum(start.y)
for k := 0; k < n; k++ {
next := pts[(k+1)%n]
m := mid(pts[k], next)
path += " Q " + fnum(pts[k].x) + " " + fnum(pts[k].y) + " " + fnum(m.x) + " " + fnum(m.y)
}
return path + " Z"
}
func pearlBaroque(base, salt string) string {
gid := "prl" + salt
pathD := pearlBaroqueSmoothPath(pearlBaroqueOutline("baroque" + salt))
body := `<path d="` + pathD + `" fill="url(#` + gid + `)"/>`
clipGeom := `<path d="` + pathD + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, 40, 30)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + sweep
}
// pearlCircled draws thin concentric growth rings over a round body --
// the defining feature of a real "circled" (or "ringed") pearl, visible
// grooves left by its own growth, not a decorative pattern.
func pearlCircled(base, salt string) string {
gid := "prl" + salt
r := 24.0
body := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `" fill="url(#` + gid + `)"/>`
ringTone := mixHex(base, "#000000", 0.12)
rings := ""
for _, f := range []float64{0.42, 0.58, 0.74, 0.9} {
rings += `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r*f) + `" fill="none" stroke="` + ringTone + `" stroke-width="0.9" opacity="0.35"/>`
}
clipGeom := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(r) + `"/>`
sweep := pearlSweepOverlay(salt, base, clipGeom, r*2, r*2)
return `<defs>` + pearlLusterGradient(gid, base) + `</defs>` + body + rings + sweep
}
// PearlShapeNames lists the real jewelry-trade pearl shape categories --
// deliberately NOT the faceted-cut vocabulary above (ShapeNames). Round
// is the rarest/most prized (perfectly spherical); Baroque is the least
// regular (freeform, no symmetry); Circled pearls show visible
// concentric growth grooves.
var PearlShapeNames = []string{"Round", "Oval", "Button", "Drop", "Baroque", "Circled"}
// renderPearlShape is PearlShapeNames' own counterpart to renderGem.
func renderPearlShape(shapeIdx int, base, salt string) string {
switch shapeIdx {
case 0:
return pearlRound(base, salt)
case 1:
return pearlOval(base, salt)
case 2:
return pearlButton(base, salt)
case 3:
return pearlDrop(base, salt)
case 4:
return pearlBaroque(base, salt)
default:
return pearlCircled(base, salt)
}
}
/* -------------------------------------- ring -------------------------------------- */
// renderRing draws the metal band (with bevel) plus the neon energy-field
// glow — fixed outer edge (never animates position), two static-geometry
// layers crossfaded via opacity only. No blur filter: an SVG <filter>
// wrapping an opacity-animated child is expensive to composite in
// WebKit specifically (confirmed via a live Safari test against a mock
// page with it removed — Chrome never showed the problem, masking it).
// The gradient's own multi-stop falloff carries the glow instead.
func renderRing(metalHi, metalLo, salt string) string {
// Light-from-top-left tilt: instead of one smooth diagonal fade, the
// gradient alternates hi/lo twice along the same diagonal axis, so
// the band reads as catching more than one reflected highlight as it
// curves — a real polished ring's "multi-band Fresnel" look, chosen
// over a plain 2-stop fade after a live side-by-side comparison of
// six alternative ring treatments. All three strokes (outer/main/
// inner) reference this one gradient so the light direction is
// consistent across the whole band, not just its center.
gid := "ring" + salt
defs := `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="100%">` +
`<stop offset="0%" stop-color="` + metalHi + `"/><stop offset="22%" stop-color="` + metalLo + `"/>` +
`<stop offset="50%" stop-color="` + metalHi + `"/><stop offset="78%" stop-color="` + metalLo + `"/>` +
`<stop offset="100%" stop-color="` + metalHi + `"/></linearGradient>`
outer := `<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="52.3" fill="none" stroke="url(#` + gid + `)" stroke-width="1" opacity="0.7"/>` +
`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="50" fill="none" stroke="url(#` + gid + `)" stroke-width="4"/>` +
`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="47.7" fill="none" stroke="url(#` + gid + `)" stroke-width="1" opacity="0.85"/>`
const rOut = 46.0
const coreHalf = 0.6
const outwardBloom = 0.9
glowR := rOut + outwardBloom + 3
pct := func(v float64) float64 {
p := v / glowR * 100
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return p
}
coreColor := lighten(metalHi, 0.55)
donutStops := func(inwardBloom, peakOp float64, gid string) string {
bloomOp := peakOp * 0.35
midOp := peakOp * 0.7
return `<radialGradient id="` + gid + `" cx="50%" cy="50%" r="50%">` +
`<stop offset="0%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
`<stop offset="` + pctnum(pct(rOut-inwardBloom-1.5)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
`<stop offset="` + pctnum(pct(rOut-inwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
`<stop offset="` + pctnum(pct(rOut-coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
`<stop offset="` + pctnum(pct(rOut)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(peakOp) + `"/>` +
`<stop offset="` + pctnum(pct(rOut+coreHalf)) + `%" stop-color="` + coreColor + `" stop-opacity="` + fnum(midOp) + `"/>` +
`<stop offset="` + pctnum(pct(rOut+outwardBloom)) + `%" stop-color="` + metalHi + `" stop-opacity="` + fnum(bloomOp) + `"/>` +
`<stop offset="` + pctnum(pct(rOut+outwardBloom+1)) + `%" stop-color="` + metalHi + `" stop-opacity="0"/>` +
`<stop offset="100%" stop-color="` + metalHi + `" stop-opacity="0"/></radialGradient>`
}
rnd := newPRNG("energy" + salt)
gidA, gidB := "glowA"+salt, "glowB"+salt
dur := rnd.floatIn(1.6, 2.4)
phase := rnd.floatIn(0, dur)
stopsA := donutStops(2.5*2.0/3.0*0.5, 0.4, gidA)
stopsB := donutStops(8.0*2.0/3.0*0.5, 1.0, gidB)
energy := `<defs>` + stopsA + stopsB + `</defs>` +
`<g>` +
`<circle cx="` + fnum(subjX) + `" cy="` + fnum(subjY) + `" r="` + fnum(glowR) + `" fill="url(#` + gidA + `)"/>` +
`<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"/>` +
`</g>`
return `<defs>` + defs + `</defs>` + outer + energy
}
/* ------------------------------------- motion ------------------------------------- */
const (
motionVDur, motionHDur, motionRDur, motionSDur = 3.4, 4.66, 5.81, 6.56
motionRotMin, motionRotMax = 4.0, 8.0
motionScale = 1.09
)
// applyMotion wraps content in the floating-holder animation (vertical
// float + sideways drift + left/right rock + scale pulse), each axis on
// its own independently-phased period so pieces never move in sync.
// CSS @keyframes (see styleBlock), not SMIL -- WebKit's SMIL engine
// scales badly with many concurrently-animated elements on one page,
// confirmed via a live cross-browser test. transform-origin:view-box
// on gt-r/gt-s replaces SMIL's manual translate-to-pivot/scale/
// translate-back trick, so this nests in fewer elements than before,
// not more.
func applyMotion(content, salt string) string {
rnd := newPRNG("motion" + salt)
vphase := rnd.floatIn(0, motionVDur)
hphase := rnd.floatIn(0, motionHDur)
rphase := rnd.floatIn(0, motionRDur)
sphase := rnd.floatIn(0, motionSDur)
rotDeg := rnd.floatIn(motionRotMin, motionRotMax)
return `<g class="gt-v" style="--v-dur:` + fnum(motionVDur) + `s;--v-delay:-` + fnum(vphase) + `s">` +
`<g class="gt-h" style="--h-dur:` + fnum(motionHDur) + `s;--h-delay:-` + fnum(hphase) + `s">` +
`<g class="gt-r" style="--r-dur:` + fnum(motionRDur) + `s;--r-delay:-` + fnum(rphase) + `s;--rot-deg:` + fnum(rotDeg) + `deg">` +
`<g class="gt-s" style="--s-dur:` + fnum(motionSDur) + `s;--s-delay:-` + fnum(sphase) + `s">` +
content + `</g></g></g></g>`
}
/* ---------------------------------- status badge ---------------------------------- */
const dotX, dotY = subjX + 50, subjY + 42
func renderStatusBadge(active bool) string {
if active {
return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#7fd88f"/>` +
// r starts at 4 (the solid dot's own radius, so the ring
// begins flush with its edge) and scales up via transform --
// see styleBlock's gt-kf-badge-pulse comment for why not r.
`<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="none" stroke="#7fd88f" stroke-width="1.2" class="gt-badge-pulse" opacity="0.8"/>`
}
return `<circle cx="` + fnum(dotX) + `" cy="` + fnum(dotY) + `" r="4" fill="#5a5f6b"/>`
}
/* ------------------------------------ facet cascade ------------------------------------ */
// facetSpan is one self-closing facet element found in renderGem's own
// markup, in the same left-to-right order it was drawn -- which is also
// each facet's real adjacency order around its ring, since every gem_*
// function draws its ring in one continuous angular sweep. ringMember
// distinguishes a real facet (opacity 0.55, every gem_* function's own
// constant for them -- see facetOp) from the one non-ring "table"/
// center/backing element each shape draws at opacity 0.5 instead (see
// facetOpCenter) -- that one stays static, outside the cascade.
type facetSpan struct {
start, end int
fill string
ringMember bool
}
// scanFacetSpans walks renderGem's own markup once, left to right, with
// plain string scanning rather than regexp -- this runs on every
// TokenURI/Render call, and CollectionSummary/WalletSummary's own
// pagination fix already established that anything unbounded or
// unnecessarily expensive in a per-token read path becomes a real "out
// of gas" failure at real collection sizes, not a theoretical concern.
func scanFacetSpans(markup string) []facetSpan {
var spans []facetSpan
i := 0
for {
rel := strings.Index(markup[i:], "<")
if rel < 0 {
break
}
start := i + rel
relEnd := strings.Index(markup[start:], "/>")
if relEnd < 0 {
break
}
end := start + relEnd + 2
tag := markup[start:end]
i = end
isFacetTag := strings.HasPrefix(tag, "<path") || strings.HasPrefix(tag, "<circle") || strings.HasPrefix(tag, "<polygon")
if !isFacetTag {
continue
}
fill := attrValue(tag, `fill="`)
if fill == "" {
continue
}
spans = append(spans, facetSpan{start: start, end: end, fill: fill, ringMember: strings.Contains(tag, `opacity="0.55"`)})
}
return spans
}
func attrValue(tag, marker string) string {
idx := strings.Index(tag, marker)
if idx < 0 {
return ""
}
rest := tag[idx+len(marker):]
endIdx := strings.Index(rest, `"`)
if endIdx < 0 {
return ""
}
return rest[:endIdx]
}
// facetRings groups ring-member spans into one or more contiguous,
// physically-touching loops. Every gem_* function draws one continuous
// ring except Round Brilliant, which draws two back to back (an inner
// ring, then the outer crown) -- splitting those into two independent
// loops keeps the cascade from ever jumping between facets that don't
// actually share an edge. 20 is Round Brilliant's own signature (two
// roundSpiralN=10 loops); nothing else this file draws produces that
// exact count.
func facetRings(spans []facetSpan) [][]int {
var members []int
for i, s := range spans {
if s.ringMember {
members = append(members, i)
}
}
if len(members) == 20 {
return [][]int{members[0:10], members[10:20]}
}
return [][]int{members}
}
// Cascade tuning -- picked after comparing a synchronized flash, a
// non-overlapping relay, and this overlapping version live: a
// contiguous ~40%-of-the-ring arc appears at a random position and
// direction, its own facets overlapping in a staggered handoff (the
// stagger is shorter than a single facet's own ramp, so several are
// always mid-transition at once) rather than firing together or waiting
// for each other to finish, then the gem goes fully quiet for a few
// seconds before another random arc takes its turn.
const (
cascadeEventCount = 8
cascadeCoverage = 0.40
cascadeFacetDurMin = 0.7
cascadeFacetDurMax = 1.1
cascadeStaggerMin = 0.15
cascadeStaggerMax = 0.30
cascadePauseMin = 3.0
cascadePauseMax = 5.0
cascadePeakLightenMin = 0.55
cascadePeakLightenMax = 0.9
)
// cascadeEvent is one turn of the chase.
type cascadeEvent struct {
phase float64 // 0..1, start position around whichever ring it's applied to
dir float64 // +1 (clockwise) or -1 (counter-clockwise)
facetDur float64 // seconds, one facet's own full ramp up and back down
stagger float64 // seconds between consecutive facets starting
}
// buildCascadeSchedule draws cascadeEventCount turns off one shared prng
// stream, plus each turn's own start time (relative to the whole
// sequence) and the sequence's total period -- shared across every ring
// on the same gem, so Round Brilliant's inner ring and outer crown chase
// the same relative wedge together instead of drifting apart.
// refRingSize (one ring's own facet count) only sizes how many facets an
// event's arc covers and therefore how long that turn takes; a
// differently-sized ring just maps the same turns onto its own facet
// count (see applyCascade) -- close enough not to read as unsynchronized.
func buildCascadeSchedule(rnd *prng, refRingSize int) (events []cascadeEvent, starts []float64, period float64) {
groupN := int(float64(refRingSize)*cascadeCoverage + 0.5)
if groupN < 1 {
groupN = 1
}
events = make([]cascadeEvent, cascadeEventCount)
starts = make([]float64, cascadeEventCount)
t := rnd.floatIn(cascadePauseMin, cascadePauseMax)
for i := 0; i < cascadeEventCount; i++ {
dir := 1.0
if rnd.floatIn(0, 1) < 0.5 {
dir = -1.0
}
facetDur := rnd.floatIn(cascadeFacetDurMin, cascadeFacetDurMax)
stagger := rnd.floatIn(cascadeStaggerMin, cascadeStaggerMax)
events[i] = cascadeEvent{phase: rnd.floatIn(0, 1), dir: dir, facetDur: facetDur, stagger: stagger}
starts[i] = t
eventTotal := float64(groupN-1)*stagger + facetDur
t += eventTotal + rnd.floatIn(cascadePauseMin, cascadePauseMax)
}
period = t
return events, starts, period
}
// wrapInt wraps a into [0,n) -- Go's own % can return negative for a
// negative dividend, which a plain a%n would otherwise produce whenever
// an event's counter-clockwise direction steps past index 0.
func wrapInt(a, n int) int {
r := a % n
if r < 0 {
r += n
}
return r
}
// facetHit is one occurrence of a facet catching the cascade -- a facet
// can be hit more than once per loop if two turns happen to land on it.
type facetHit struct {
enter, mid1, peak, mid2, exit float64 // seconds, relative to the shared period
}
type cascadeStop struct {
pct float64
val string
}
// sortCascadeStops insertion-sorts by pct ascending -- always a handful
// of stops (2 base + up to a few hits x5), so this stays cheap without
// needing a general sort.
func sortCascadeStops(s []cascadeStop) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j].pct < s[j-1].pct; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
// modPct wraps a fraction into [0,1) and scales it to a CSS keyframe
// percentage.
func modPct(frac float64) float64 {
for frac < 0 {
frac += 1
}
for frac >= 1 {
frac -= 1
}
return frac * 100
}
// applyCascade replaces gems.gno's old single rotating glint with the
// approved facet cascade (see the tuning block above). Returns the gem
// markup with each hit facet's own animation added, plus the CSS this
// needs appended to the token's own <style> block -- every facet's
// keyframe is unique (its own timing, its own color), so this is
// per-facet inline animations, not a shared class the way the old glint
// or the motion/glow effects are.
func applyCascade(markup, tidStr string) (string, string) {
spans := scanFacetSpans(markup)
rings := facetRings(spans)
if len(rings) == 0 || len(rings[0]) == 0 {
return markup, ""
}
rnd := newPRNG("cascade" + tidStr)
events, starts, period := buildCascadeSchedule(rnd, len(rings[0]))
hits := make([][]facetHit, len(spans))
for _, ring := range rings {
m := len(ring)
if m == 0 {
continue
}
groupN := int(float64(m)*cascadeCoverage + 0.5)
if groupN < 1 {
groupN = 1
}
for ei, ev := range events {
startIdx := wrapInt(int(ev.phase*float64(m)+0.5), m)
localStart := starts[ei]
for step := 0; step < groupN; step++ {
ridx := wrapInt(startIdx+int(float64(step)*ev.dir), m)
spanIdx := ring[ridx]
enter := localStart
exit := enter + ev.facetDur
peak := (enter + exit) / 2
mid1 := enter + (peak-enter)*0.5
mid2 := peak + (exit-peak)*0.5
hits[spanIdx] = append(hits[spanIdx], facetHit{enter: enter, mid1: mid1, peak: peak, mid2: mid2, exit: exit})
localStart += ev.stagger
}
}
}
var keyframes strings.Builder
var out strings.Builder
cursor := 0
for i, span := range spans {
windows := hits[i]
if len(windows) == 0 {
continue
}
out.WriteString(markup[cursor:span.start])
peakAmt := rnd.floatIn(cascadePeakLightenMin, cascadePeakLightenMax)
lit := lighten(span.fill, peakAmt)
mi := mixHex(span.fill, lit, 0.5)
stops := []cascadeStop{{0, span.fill}, {100, span.fill}}
for _, w := range windows {
stops = append(stops,
cascadeStop{modPct(w.enter / period), span.fill},
cascadeStop{modPct(w.mid1 / period), mi},
cascadeStop{modPct(w.peak / period), lit},
cascadeStop{modPct(w.mid2 / period), mi},
cascadeStop{modPct(w.exit / period), span.fill},
)
}
sortCascadeStops(stops)
kfName := ufmt.Sprintf("fc%s_%d", tidStr, i)
keyframes.WriteString("@keyframes " + kfName + "{")
for _, s := range stops {
keyframes.WriteString(fnum(s.pct) + `%{fill:` + s.val + `}`)
}
keyframes.WriteString("}")
tag := markup[span.start:span.end]
out.WriteString(tag[:len(tag)-2] + ` style="animation:` + kfName + ` ` + fnum(period) + `s ease-in-out infinite"/>`)
cursor = span.end
}
out.WriteString(markup[cursor:])
return out.String(), keyframes.String()
}
/* ---------------------------------- label + serial ---------------------------------- */
func renderMetalLabelRect(metalHi, metalLo string, x, y, w, h, rx float64, salt string) string {
// Echoes the ring's multi-band tilt but heavily muted — a soft
// two-step blend instead of the ring's hard alternating stops, so the
// bar doesn't visually fight the chain-name text sitting on top of it.
gid := "labelgrad" + salt
defs := `<linearGradient id="` + gid + `" x1="0%" y1="0%" x2="100%" y2="100%">` +
`<stop offset="0%" stop-color="` + metalHi + `"/>` +
`<stop offset="30%" stop-color="` + mixHex(metalHi, metalLo, 0.3) + `"/>` +
`<stop offset="55%" stop-color="` + mixHex(metalLo, metalHi, 0.3) + `"/>` +
`<stop offset="100%" stop-color="` + metalLo + `"/></linearGradient>`
rect := `<rect x="` + fnum(x) + `" y="` + fnum(y) + `" width="` + fnum(w) + `" height="` + fnum(h) + `" rx="` + fnum(rx) + `" fill="url(#` + gid + `)"/>`
bevelTop := `<rect x="` + fnum(x+1.5) + `" y="` + fnum(y+0.6) + `" width="` + fnum(w-3) + `" height="1.1" rx="0.55" fill="url(#` + gid + `)" opacity="0.85"/>`
bevelBottom := `<rect x="` + fnum(x+1.5) + `" y="` + fnum(y+h-1.7) + `" width="` + fnum(w-3) + `" height="1.1" rx="0.55" fill="url(#` + gid + `)" opacity="0.85"/>`
return `<defs>` + defs + `</defs>` + rect + bevelTop + bevelBottom
}
// renderSerialText uses the token's REAL assigned ID (already the exact
// Crockford-base32 seqid format gno.land itself produces) — no lookup
// table or reimplemented encoding needed, unlike the Python prototype
// which had to hardcode a verified sample of real IDs since it couldn't
// call the real package directly.
func renderSerialText(tidStr, color string) string {
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>`
}
/* ------------------------------- stone / color / metal tables ------------------------------- */
type namedColor struct{ hex, name string }
// stoneByChain, stonePalettes, and stoneNames are mutable, not const --
// a deliberate departure from this file's "pure functions, no state"
// design (see the package doc comment). Originally hardcoded switch
// statements covering only Topaz-1/Sapphire-1, seeded here with the
// same real gemology research, but now editable via SetStoneForChain/
// AddStoneColor (nftminter.gno) so a FUTURE gno.land testnet generation
// that gets a real gem codename can be recognized without a contract
// redeploy — the whole point of putting this data in state instead of
// source.
var stoneByChain = map[string]string{
"TOPAZ-1": "Topaz",
"SAPPHIRE-1": "Sapphire",
"PEARL-1": "Pearl",
}
// All hex values here (except Red, see "Not Set" below) are the original
// palette run through the exact CSS/SVG feColorMatrix type="saturate"
// formula at s=1.5 (150%) -- confirmed against a live side-by-side
// comparison (three saturation levels, same six real minted pieces)
// before choosing this one, "the enhanced saturation" over both the
// original 100% and a more extreme 200% option. Not a hand-picked
// repaint -- every value is the deterministic output of that one matrix,
// so hues stay true, only intensity increases.
//
// Baked directly into these constants rather than applied via a live
// <filter> (or an equivalent Go function called on every render): a
// live filter under several CSS-animated ancestors, times potentially
// dozens of gems rendered on one page at once, was a real, confirmed
// Safari motion-jitter source in this project (same category of fix
// already applied once before, to the rings' glow effect). Precomputing
// the target color once, here, and shipping it as an ordinary static
// fill value removes that cost entirely -- nothing left for any browser
// to re-rasterize. The matrix itself, if a future palette addition ever
// needs to reproduce it by hand:
// a=.213+.787s b=.715-.715s c=.072-.072s
// d=.213-.213s e=.715+.285s f=.072-.072s
// g=.213-.213s h=.715-.715s i=.072+.928s
// 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)
var stonePalettes = map[string][]namedColor{
"Sapphire": {
{"#00b6ff", "Blue"}, {"#ff6ea4", "Pink"}, {"#ff8501", "Orange"}, {"#ffd500", "Yellow"},
{"#02d66e", "Green"}, {"#a658ff", "Violet"}, {"#e6e9f8", "White"}, {"#ff732f", "Padparadscha"},
},
"Topaz": {
{"#f5edd1", "Colorless"}, {"#ffa300", "Imperial"}, {"#ffd100", "Golden"},
{"#ff6900", "Sherry Brown"}, {"#33b2ff", "Sky Blue"}, {"#ff96ba", "Pink"},
},
// Not Set was originally a muted, deliberately non-gem palette (dull
// grays), reasoning that a chain with no assigned stone shouldn't get
// gem-like colors. Replaced per request -- these read as dull/boring
// in practice, and this stone accounts for the large majority of
// chains (every testnet except Topaz-1/Sapphire-1), so it now gets
// the full generic gem-color spectrum instead: every hue already used
// by Sapphire/Topaz (this is intentional reuse, not new hex values --
// a "Not Set" piece can land on any color a real gem might), plus Red,
// which neither Sapphire nor Topaz can use (Sapphire excludes it
// because red corundum is classified as ruby, a separate gem name).
// Red is the one deliberate exception to this file's "every hex is
// the s=1.5 matrix output" rule above -- the matrix-derived #ff4b4b
// read too saturated in practice, so it's hand-tuned ~12.5% less
// saturated (#f45656) instead, per request.
"Not Set": {
{"#f45656", "Red"}, {"#00b6ff", "Blue"}, {"#02d66e", "Green"}, {"#ff6ea4", "Pink"},
{"#ff8501", "Orange"}, {"#ffd500", "Yellow"}, {"#a658ff", "Violet"}, {"#e6e9f8", "White"},
{"#ffd100", "Golden"}, {"#f5edd1", "Colorless"},
},
// Pearl (PEARL-1, added for the Pearl testnet generation) is the one
// deliberate exception to this file's "every hex is the s=1.5 matrix
// output" rule above, for a real gemological reason rather than a
// stylistic one: pearls are organic gems with a soft nacre sheen, not
// faceted crystals -- the same saturation boost that makes Sapphire/
// Topaz read as vivid gemstones would misrepresent what a pearl
// actually looks like. These 8 are real naturally-occurring pearl
// body colors (not dyed), sourced from gemological references, hand-
// tuned to a muted/pearly range instead of the boosted-saturation one:
// white (Akoya), cream, black (Tahitian -- reads as a very dark grey/
// green, never true black), silver, gold (South Sea), pink and peach
// (freshwater), and lavender (freshwater, one of the rarer natural
// overtones).
"Pearl": {
{"#f8f6f0", "White"}, {"#f0e4c8", "Cream"}, {"#3a3a3c", "Black"}, {"#c8cdd4", "Silver"},
{"#d4af6a", "Gold"}, {"#f0d4dc", "Pink"}, {"#f2c9a8", "Peach"}, {"#d8cbe0", "Lavender"},
},
}
// stoneNames tracks insertion order for deterministic listing (Go/Gno
// map iteration order is randomized) -- kept in sync with stonePalettes
// by registerStoneName, called from both AddStoneColor and
// SetStoneForChain (nftminter.gno).
var stoneNames = []string{"Sapphire", "Topaz", "Pearl", "Not Set"}
func registerStoneName(stone string) {
for _, s := range stoneNames {
if s == stone {
return
}
}
stoneNames = append(stoneNames, stone)
}
// SetStoneForChain and AddStoneColor live here (not in nftminter.gno's own
// wrapper functions of the same purpose) because they mutate this
// package's own private maps -- stoneByChain/stonePalettes/stoneNames
// aren't visible outside this package once gemsart is its own separate
// deploy, unlike when this was one combined realm. nftminter.gno's
// SetStoneForChain/AddStoneColor now just assertOwner() then delegate here.
func SetStoneForChain(chainName, stone string) {
stoneByChain[chainName] = stone
registerStoneName(stone)
}
func AddStoneColor(stone, hex, name string) {
stonePalettes[stone] = append(stonePalettes[stone], namedColor{hex: hex, name: name})
registerStoneName(stone)
}
// StoneNamesCSV lists every registered stone name, semicolon-separated,
// in the order each was first introduced.
func StoneNamesCSV() string {
return strings.Join(stoneNames, ";")
}
// ColorPaletteCSV lists stone's full color palette as "hex:name;...", in
// colorIdx order. Implemented here rather than in nftminter.gno since it
// needs namedColor's own unexported hex/name fields.
func ColorPaletteCSV(stone string) string {
palette := stonePalettes[stone]
pairs := make([]string, len(palette))
for i, c := range palette {
pairs[i] = c.hex + ":" + c.name
}
return strings.Join(pairs, ";")
}
// MetalToneHex reports metalName's hi/lo gradient tones as "hi;lo", or ""
// if unrecognized. Implemented here for the same reason as
// ColorPaletteCSV -- needs metalTone's own unexported hi/lo fields.
func MetalToneHex(metalName string) string {
t, ok := metals[metalName]
if !ok {
return ""
}
return t.hi + ";" + t.lo
}
// StoneForChain: only gno.land testnet generations that were actually
// given a gemstone codename (Topaz-1, Sapphire-1 at launch) get one —
// every other/future chain is "Not Set" rather than a guessed mineral
// name, unless the owner explicitly assigns one via SetStoneForChain.
func StoneForChain(chain string) string {
if s, ok := stoneByChain[chain]; ok {
return s
}
return "Not Set"
}
// colorForStone looks up colorIdx in the palette matching stone,
// clamping out-of-range indices instead of panicking — AddTraitPreset
// validates at write time, this is just defense in depth for read time.
// A stone with no palette yet (registered via SetStoneForChain but
// never given a color via AddStoneColor) falls back to "Not Set"'s
// palette rather than indexing into an empty slice.
func colorForStone(stone string, colorIdx int) namedColor {
palette := stonePalettes[stone]
if len(palette) == 0 {
palette = stonePalettes["Not Set"]
}
if colorIdx < 0 {
colorIdx = 0
}
if colorIdx >= len(palette) {
colorIdx = len(palette) - 1
}
return palette[colorIdx]
}
// ColorPaletteSize reports how many colors are valid for stone, so
// AddTraitPreset can validate a caller-supplied colorIdx. Same
// empty-palette fallback as colorForStone, for the same reason.
func ColorPaletteSize(stone string) int {
if n := len(stonePalettes[stone]); n > 0 {
return n
}
return len(stonePalettes["Not Set"])
}
// FindShapeIndex reverse-maps a rendered "Shape" trait value (e.g.
// "Oval") back to its index into ShapeNames. A source realm for a
// migration only ever exposes the rendered trait STRING (via
// CollectionSummary), never the raw index a migrated token would need
// to regenerate its own art instead of storing a full copy of it — this
// closes that gap for the migration tooling (see
// AddMigrationSnapshotEntry). ok is false if name doesn't match any
// current shape (not expected for anything this realm's own gems.gno
// produced, but a caller should treat that as "unrecognized," not guess).
// FindShapeIndex takes stone (from StoneForChain) since Pearl draws
// shapeIdx from a completely different list (PearlShapeNames, not
// ShapeNames) -- a rendered "Baroque" only resolves against the right
// one.
func FindShapeIndex(stone, name string) (idx int, ok bool) {
names := ShapeNames
if stone == "Pearl" {
names = PearlShapeNames
}
for i, s := range names {
if s == name {
return i, true
}
}
return -1, false
}
// FindColorIndex reverse-maps a rendered "Color" trait value back to
// its index in chain's own stone palette — colorIdx is only meaningful
// relative to a specific stone (derived from chain via StoneForChain),
// not a single global color list, so this needs the chain too. Same
// migration-tooling purpose as FindShapeIndex.
func FindColorIndex(chain, colorName string) (idx int, ok bool) {
stone := StoneForChain(chain)
palette := stonePalettes[stone]
if len(palette) == 0 {
palette = stonePalettes["Not Set"]
}
for i, c := range palette {
if c.name == colorName {
return i, true
}
}
return -1, false
}
type metalTone struct{ hi, lo string }
// metals holds each metal's ring/label gradient tones. A var rather than
// a const purely because Gno doesn't allow map literals as consts, not
// because it's meant to be mutable -- no exported function reassigns it
// (nftminter.gno's MetalToneHex is read-only), so a minted piece's ring
// color is fixed the moment it's revealed, same as every other trait.
// Gold/Silver/Bronze are two darkening
// passes down from their original brighter values; Platinum stays at
// ONE pass (not two) and Obsidian is unchanged. Verified byte-for-byte
// against all 100 pieces of the confirmed "Latest iteration, all
// hundred (mask applied)" reference batch -- every non-Platinum ring
// matched the two-pass tone exactly, but every Platinum ring in that
// same confirmed batch used the one-pass tone, not two; a later commit
// had mistakenly carried Platinum through the second darkening pass
// too. Reverted just that one entry to match the confirmed reference.
var metals = map[string]metalTone{
"Gold": {"#a48b3f", "#6a5117"},
"Silver": {"#95989c", "#5e646c"},
"Bronze": {"#865b34", "#52391d"},
"Platinum": {"#c3c6c8", "#87909a"},
"Obsidian": {"#3a3d47", "#0d0e12"},
}
// MetalNames is an ordered counterpart to metals -- Go/Gno map iteration
// order is randomized, so on-the-fly random trait assembly (MintAssembled,
// nftminter.gno) needs an indexable slice to pick a random metal from.
var MetalNames = []string{"Gold", "Silver", "Bronze", "Platinum", "Obsidian"}
type metalLabelTone struct{ bg, text string }
var metalLabel = map[string]metalLabelTone{
"Gold": {"#f4d060", "#04121a"},
"Silver": {"#dfe4ea", "#04121a"},
"Bronze": {"#c9884f", "#04121a"},
"Platinum": {"#eef2f5", "#04121a"},
"Obsidian": {"#5a5f6b", "#e8e9f0"},
}
/* --------------------------------- full assembly --------------------------------- */
// RenderTokenSVG assembles one token's complete SVG from its small trait
// selection, mirroring badge() in the Python prototype exactly (same
// element order, same layout constants). `salt` seeds every per-piece
// animation timing and every gradient id — the token's own real ID
// (tidStr), so nothing here is computed before mint assigns one.
func RenderTokenSVG(chain, metal string, shapeIdx, colorIdx int, active bool, tidStr string) (svg string, stone string, colorHex string, colorName string) {
stone = StoneForChain(chain)
nc := colorForStone(stone, colorIdx)
colorHex, colorName = nc.hex, nc.name
mt := metals[metal]
ringEls := renderRing(mt.hi, mt.lo, tidStr)
// colorHex is already the boosted value -- stonePalettes' own hex
// constants ARE the s=1.5 saturate output now (see saturate's doc
// comment for the formula, and stonePalettes' own comment for which
// entry is deliberately excluded: Red). No runtime call needed here
// at all, so applyCascade's own lightened peaks, which start from
// each facet's own already-boosted base color, inherit the same
// saturation rather than fighting it, for free.
//
// Pearl draws from renderPearlShape (smooth luster gradient, no
// facets) instead of renderGem (faceted triangles) -- a real pearl is
// never cut, so it has nothing for applyCascade's facet-shimmer to
// find. applyCascade still runs unconditionally on the result rather
// than being skipped for Pearl: scanFacetSpans finds zero
// opacity="0.55" facet markers in pearl markup and returns it
// unchanged, so this stays a no-op there rather than needing its own
// branch.
var rawGem string
if stone == "Pearl" {
rawGem = renderPearlShape(shapeIdx, colorHex, tidStr)
} else {
rawGem = renderGem(shapeIdx, colorHex)
}
gemMarkup, cascadeCSS := applyCascade(rawGem, tidStr)
floatingGem := applyMotion(gemMarkup, tidStr)
status := renderStatusBadge(active)
serial := renderSerialText(tidStr, mt.hi)
labelText := metalLabel[metal].text
labelRect := renderMetalLabelRect(mt.hi, mt.lo, 24, 134, 122, 15, 3, tidStr)
inner := `<rect width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" fill="#0a0b10"/>` +
ringEls + floatingGem + status + serial + labelRect +
`<text x="` + fnum(subjX) + `" y="144.5" font-size="9.5" text-anchor="middle" fill="` + labelText + `" font-family="ui-monospace,monospace" font-weight="700">` + chain + ` CHAIN</text>`
// width/height on the root <svg>, not just viewBox -- without them the
// image has no definite intrinsic size as an <img> resource, and
// gnoweb's own CSS (anchors wrapping embedded thumbnails are
// `display: inline-block; text-wrap: balance`) collapses an
// ambiguous-size image down to 2x2px. Confirmed live against a real
// deployed collection (gno-tools' nft-minter tool hit the exact same
// bug); see ~/gno-land-dev-notes.md.
// cascadeCSS is a second, separate <style> block rather than folded
// into styleBlock() -- every rule in it (keyframe names, timings) is
// unique to THIS token, unlike styleBlock()'s shared, fixed rules
// reused across every token that's ever rendered.
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="` + fnum(canvasW) + `" height="` + fnum(canvasH) + `" viewBox="0 0 ` + fnum(canvasW) + ` ` + fnum(canvasH) + `">` + styleBlock() +
`<style>` + cascadeCSS + `</style>` + inner + `</svg>`
return svg, stone, colorHex, colorName
}
// styleBlock is embedded once per token's own SVG (a real NFT image is a
// fully self-contained document -- this can't live in a page-level
// stylesheet). Drives every animated piece via CSS @keyframes instead
// of SMIL <animate>/<animateTransform>: each token's own randomized
// timing/angles are set as custom properties in a small inline style=""
// per element, against these shared, fixed keyframe rules.
func styleBlock() string {
const easeInOut = "cubic-bezier(.45,0,.55,1)"
const easeOut = "cubic-bezier(0,0,.3,1)"
px, py := fnum(subjX)+"px", fnum(subjY)+"px"
dpx, dpy := fnum(dotX)+"px", fnum(dotY)+"px"
return `<style>` +
// will-change:transform on the motion chain is a compositing hint,
// not a cosmetic tweak -- confirmed to matter for Safari
// specifically. No live SVG <filter> sits under these anymore
// (see saturate's own doc comment for why one used to, and why
// that was the real cost -- a rendered gem is now plain
// path/circle elements with their final fill colors already
// baked in, nothing left for a filter to re-rasterize).
`.gt-v{will-change:transform;animation:gt-kf-v var(--v-dur) ` + easeInOut + ` infinite;animation-delay:var(--v-delay)}` +
`.gt-h{will-change:transform;animation:gt-kf-h var(--h-dur) ` + easeInOut + ` infinite;animation-delay:var(--h-delay)}` +
`.gt-r{will-change:transform;animation:gt-kf-r var(--r-dur) ` + easeInOut + ` infinite;animation-delay:var(--r-delay);transform-box:view-box;transform-origin:` + px + ` ` + py + `}` +
`.gt-s{will-change:transform;animation:gt-kf-s var(--s-dur) ` + easeInOut + ` infinite;animation-delay:var(--s-delay);transform-box:view-box;transform-origin:` + px + ` ` + py + `}` +
`.gt-glow{animation:gt-kf-glow var(--glow-dur) ` + easeInOut + ` infinite;animation-delay:var(--glow-delay)}` +
// Animates transform:scale()+opacity, not r -- r/cx/cy are SVG2
// geometry properties promoted to CSS-animatable status much more
// recently, and less consistently, than transform/opacity (which
// every engine has animated reliably for years); the earlier
// r-based version is the likely reason this pulse wasn't visibly
// animating at all. transform-origin pinned to the badge's own
// (fixed, non-per-token) center so it scales outward in place
// instead of toward the SVG's default (0,0) origin.
`.gt-badge-pulse{transform-box:view-box;transform-origin:` + dpx + ` ` + dpy + `;animation:gt-kf-badge-pulse 1.8s ` + easeOut + ` infinite}` +
`@keyframes gt-kf-v{0%,100%{transform:translate(0,0)}50%{transform:translate(0,-5px)}}` +
`@keyframes gt-kf-h{0%,100%{transform:translate(0,0)}50%{transform:translate(1.8px,0)}}` +
`@keyframes gt-kf-r{0%,100%{transform:rotate(0deg)}25%{transform:rotate(calc(-1 * var(--rot-deg)))}50%{transform:rotate(0deg)}75%{transform:rotate(var(--rot-deg))}}` +
`@keyframes gt-kf-s{0%,100%{transform:scale(1)}50%{transform:scale(` + fnum(motionScale) + `)}}` +
`@keyframes gt-kf-glow{0%,100%{opacity:0}50%{opacity:1}}` +
`@keyframes gt-kf-badge-pulse{from{transform:scale(1);opacity:.8}to{transform:scale(2.25);opacity:0}}` +
`</style>`
}
The verified vm/qfuncs operation accepts realm paths only.
Pure packages expose source files but do not have Realm Render.