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}