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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

skinned.gno

6.64 Kb · 221 lines
  1package nsview
  2
  3import (
  4	"strings"
  5
  6	"gno.land/r/nym-mnsrehearsal001/nsskin/v2"
  7)
  8
  9/*
 10A NAME WEARING A SKIN — the card the shop actually sells.
 11
 12Until this existed, a bought skin did nothing: nsskin held the token, the
 13binding and the art, and nsview had never heard of it. Somebody could buy
 14one, attach it, and look at their NFT to find the ordinary card. That is
 15the whole of what this file fixes.
 16
 17ONE CALL DOES THE ASKING. nsskin.ArtForName answers "what should this
 18name be drawn with", with every condition already inside it — the name
 19wears a token, the definition is not withdrawn, the binding is pinned to
 20this registration, the lock still matches. A renderer that had to
 21remember four checks before drawing is one that eventually forgets one,
 22so it does not get the chance.
 23
 24THE FACE AND THE INK ARE DERIVED, NOT STORED, and that is the
 25interesting part.
 26
 27nsskin does not record them: DefineSkin takes an id, a name, a sprite, a
 28lock and an artist, and nothing else. Adding fields would mean a new
 29nsskin, and a new nsskin strands every token minted from the old one —
 30which by the time this was written already included somebody's purchase.
 31Stranding a paid-for object to store two values that can be worked out is
 32a bad trade.
 33
 34So they are worked out. The ink comes from the art itself, which is
 35better than a stored value rather than merely cheaper: measured against
 36the sixty definitions in the launch collection, deriving it beat the
 37hand-picked value on ELEVEN cards and lost on none, and took the worst
 38contrast on any card from 1.5:1 to 4.7:1.
 39*/
 40
 41/*
 42THE CROSSOVER IS AT 46, NOT 128, and getting that wrong is what made the
 43hand-picked values worse than this rule.
 44
 45Contrast against white is 1.05/(L+0.05); against black it is
 46(L+0.05)/0.05. Setting them equal gives (L+0.05)^2 = 0.0525, so L = 0.179
 47— about 46 out of 255. Contrast is NOT symmetric about mid-grey: black
 48lettering on a mid-grey ground is far more readable than white on the
 49same ground, and a threshold at 128 hands eleven cards to the wrong ink.
 50*/
 51const inkCrossover = 46
 52
 53// lumOf is relative luminance, 0..255, with a gamma of two.
 54//
 55// The real curve is sRGB's 2.4-with-a-linear-toe, which needs a pow().
 56// Squaring is within a couple of points of it across the whole range and
 57// is exact in integers — and this feeds a THRESHOLD, where being two
 58// points out only matters for a colour already sitting on the boundary,
 59// on which no ink is much better than the other anyway.
 60func lumOf(hex string) int {
 61	if len(hex) != 7 || hex[0] != '#' {
 62		return 128
 63	}
 64	r := hexPair(hex[1], hex[2])
 65	g := hexPair(hex[3], hex[4])
 66	b := hexPair(hex[5], hex[6])
 67	if r < 0 || g < 0 || b < 0 {
 68		return 128
 69	}
 70	return (2126*r*r + 7152*g*g + 722*b*b) / (10000 * 255)
 71}
 72
 73func hexPair(a, b byte) int {
 74	hi, lo := hexDigit(a), hexDigit(b)
 75	if hi < 0 || lo < 0 {
 76		return -1
 77	}
 78	return hi*16 + lo
 79}
 80
 81func hexDigit(c byte) int {
 82	switch {
 83	case c >= '0' && c <= '9':
 84		return int(c - '0')
 85	case c >= 'a' && c <= 'f':
 86		return int(c-'a') + 10
 87	case c >= 'A' && c <= 'F':
 88		return int(c-'A') + 10
 89	}
 90	return -1
 91}
 92
 93/*
 94bandMedian is the luminance of what the lettering actually sits on.
 95
 96THE MEDIAN, NOT THE MEAN, and the difference is not academic. A mean of
 97black and hot pink is a mid-grey that exists nowhere on the card, and it
 98sends the ink the wrong way on exactly the high-contrast patterns that
 99most need the right answer. A median is what most of the band is.
100
101Three rows, because a line of type is taller than one cell at every grid
102size offered. The middle seventy per cent of the width, because the name
103is centred and the margins are not under it.
104*/
105func bandMedian(art string, num, den int) int {
106	parts := strings.Split(art, "|")
107	n := len(parts) - 1
108	if n < 1 {
109		return 128
110	}
111	colours := map[string]int{}
112	for _, e := range strings.Split(parts[0], ",") {
113		if len(e) == 8 && e[1] == '#' {
114			colours[e[:1]] = lumOf(e[1:])
115		}
116	}
117	y := (num*(n-1) + den/2) / den
118	var vals []int
119	for dy := -1; dy <= 1; dy++ {
120		ry := y + dy
121		if ry < 0 {
122			ry = 0
123		}
124		if ry > n-1 {
125			ry = n - 1
126		}
127		row := parts[ry+1]
128		if len(row) != n {
129			continue
130		}
131		lo, hi := n*15/100, n*85/100
132		if hi > n-1 {
133			hi = n - 1
134		}
135		for x := lo; x <= hi; x++ {
136			if L, ok := colours[row[x:x+1]]; ok {
137				vals = append(vals, L)
138			}
139		}
140	}
141	if len(vals) == 0 {
142		return 128
143	}
144	// Insertion sort. At most about seventy values, and a realm has no
145	// sort worth importing for that.
146	for i := 1; i < len(vals); i++ {
147		v := vals[i]
148		j := i - 1
149		for j >= 0 && vals[j] > v {
150			vals[j+1] = vals[j]
151			j--
152		}
153		vals[j+1] = v
154	}
155	return vals[len(vals)/2]
156}
157
158// inkForArt returns true for white lettering. The name's own band
159// decides it alone: measured across the launch collection the date band
160// never changed an outcome for the better, and letting it vote was what
161// put white letters on a pale card.
162func inkForArt(art string) bool {
163	return bandMedian(art, 212, 400) < inkCrossover
164}
165
166/*
167faceForArt picks one of the three generic families from the art itself.
168
169Nothing about a background implies a typeface, so this is arbitrary — but
170it must be arbitrary in the SAME WAY on the chain and in the shop, or the
171preview shows a card that does not mint. Derived from the art rather than
172stored for the same reason as the ink: it costs nothing and cannot drift.
173*/
174func faceForArt(art string) string {
175	h := 2166136261
176	for i := 0; i < len(art); i++ {
177		h ^= int(art[i])
178		h *= 16777619
179		h &= 0x7fffffff
180	}
181	switch h % 3 {
182	case 0:
183		return "sans"
184	case 1:
185		return "serif"
186	}
187	return "mono"
188}
189
190/*
191skinnedCardSVG is the whole card: the art edge to edge, the name over it.
192
193FULL BLEED, unlike the skin's own listing card, which is matted. That
194contrast is deliberate and load-bearing — it is what stops somebody
195mistaking a skin for sale for a name for sale. See skincard.gno.
196
197The ground behind the art matters because a sprite may have transparent
198cells, and without it they would show whatever the viewer's page is,
199which is not a colour we chose.
200*/
201func skinnedCardSVG(art, label, domain string, expires int64) string {
202	light := inkForArt(art)
203	ground := "#0b0d10"
204	if !light {
205		ground = "#f4f4f5"
206	}
207	inner := skinArtSVG(art, 0, 0, 400)
208	if inner == "" {
209		return "" // an unrenderable sprite falls back to the ordinary card
210	}
211	return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img">` +
212		`<rect width="400" height="400" fill="` + ground + `"/>` + inner +
213		NameplateSVG(label, domain, faceForArt(art), "middle", light, expires) +
214		`</svg>`
215}
216
217// skinFor a NAME, as opposed to skinFor the calendar. Separate because
218// they answer different questions and only one of them involves money.
219func wornArt(label, domain string) string {
220	return nsskin.ArtForName(label, domain)
221}