solfaces 1.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +489 -97
  2. package/SKILL.md +171 -0
  3. package/dist/agent/index.cjs +15 -14
  4. package/dist/agent/index.js +5 -4
  5. package/dist/agent/mcp-server.cjs +2956 -287
  6. package/dist/chunk-6QRDULAO.cjs +191 -0
  7. package/dist/chunk-6QRDULAO.cjs.map +1 -0
  8. package/dist/{chunk-RX6D5FGH.js → chunk-77SPWQU5.js} +69 -28
  9. package/dist/chunk-77SPWQU5.js.map +1 -0
  10. package/dist/chunk-CQWXUU7P.js +239 -0
  11. package/dist/chunk-CQWXUU7P.js.map +1 -0
  12. package/dist/chunk-CXRVPOTI.cjs +244 -0
  13. package/dist/chunk-CXRVPOTI.cjs.map +1 -0
  14. package/dist/chunk-DRUSCLEF.js +177 -0
  15. package/dist/chunk-DRUSCLEF.js.map +1 -0
  16. package/dist/{chunk-VMNATBH3.cjs → chunk-F244Q4KC.cjs} +74 -33
  17. package/dist/chunk-F244Q4KC.cjs.map +1 -0
  18. package/dist/chunk-HVPGR6G5.cjs +647 -0
  19. package/dist/chunk-HVPGR6G5.cjs.map +1 -0
  20. package/dist/{chunk-SNJABBAT.js → chunk-MGP7F65H.js} +3 -3
  21. package/dist/{chunk-SNJABBAT.js.map → chunk-MGP7F65H.js.map} +1 -1
  22. package/dist/chunk-R3MC2AJZ.cjs +2247 -0
  23. package/dist/chunk-R3MC2AJZ.cjs.map +1 -0
  24. package/dist/chunk-SWML743U.js +625 -0
  25. package/dist/chunk-SWML743U.js.map +1 -0
  26. package/dist/chunk-SX3FQDKM.js +2238 -0
  27. package/dist/chunk-SX3FQDKM.js.map +1 -0
  28. package/dist/{chunk-A6N3RPEA.cjs → chunk-WTCXTXTV.cjs} +6 -6
  29. package/dist/{chunk-A6N3RPEA.cjs.map → chunk-WTCXTXTV.cjs.map} +1 -1
  30. package/dist/constants-Bi5uTRp5.d.cts +17 -0
  31. package/dist/constants-Bi5uTRp5.d.ts +17 -0
  32. package/dist/core/index.cjs +69 -29
  33. package/dist/core/index.d.cts +29 -47
  34. package/dist/core/index.d.ts +29 -47
  35. package/dist/core/index.js +3 -3
  36. package/dist/index.cjs +104 -35
  37. package/dist/index.d.cts +4 -2
  38. package/dist/index.d.ts +4 -2
  39. package/dist/index.js +6 -5
  40. package/dist/names/index.cjs +40 -0
  41. package/dist/names/index.cjs.map +1 -0
  42. package/dist/names/index.d.cts +36 -0
  43. package/dist/names/index.d.ts +36 -0
  44. package/dist/names/index.js +3 -0
  45. package/dist/names/index.js.map +1 -0
  46. package/dist/react/index.cjs +454 -397
  47. package/dist/react/index.cjs.map +1 -1
  48. package/dist/react/index.d.cts +17 -3
  49. package/dist/react/index.d.ts +17 -3
  50. package/dist/react/index.js +450 -394
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/solfaces.cdn.global.js +2 -2
  53. package/dist/solfaces.cdn.global.js.map +1 -1
  54. package/dist/themes/index.cjs +29 -17
  55. package/dist/themes/index.d.cts +10 -7
  56. package/dist/themes/index.d.ts +10 -7
  57. package/dist/themes/index.js +1 -1
  58. package/dist/{traits-DAFZnXeS.d.cts → traits-QlWuxZDD.d.cts} +45 -1
  59. package/dist/{traits-DAFZnXeS.d.ts → traits-QlWuxZDD.d.ts} +45 -1
  60. package/dist/vanilla/index.cjs +20 -8
  61. package/dist/vanilla/index.cjs.map +1 -1
  62. package/dist/vanilla/index.d.cts +1 -1
  63. package/dist/vanilla/index.d.ts +1 -1
  64. package/dist/vanilla/index.js +17 -5
  65. package/dist/vanilla/index.js.map +1 -1
  66. package/package.json +22 -5
  67. package/python/solfaces.py +830 -237
  68. package/reference/integrations.md +166 -0
  69. package/reference/react.md +108 -0
  70. package/reference/themes.md +124 -0
  71. package/dist/chunk-2DIKGLXZ.cjs +0 -126
  72. package/dist/chunk-2DIKGLXZ.cjs.map +0 -1
  73. package/dist/chunk-CVFO7YHY.cjs +0 -97
  74. package/dist/chunk-CVFO7YHY.cjs.map +0 -1
  75. package/dist/chunk-H3SK3MNX.cjs +0 -409
  76. package/dist/chunk-H3SK3MNX.cjs.map +0 -1
  77. package/dist/chunk-KSGFMW33.js +0 -401
  78. package/dist/chunk-KSGFMW33.js.map +0 -1
  79. package/dist/chunk-LQWJRHGC.js +0 -86
  80. package/dist/chunk-LQWJRHGC.js.map +0 -1
  81. package/dist/chunk-RX6D5FGH.js.map +0 -1
  82. package/dist/chunk-VMNATBH3.cjs.map +0 -1
  83. package/dist/chunk-WURY4QGH.js +0 -117
  84. package/dist/chunk-WURY4QGH.js.map +0 -1
  85. package/skill.md +0 -463
@@ -1,10 +1,10 @@
1
1
  """
2
- SOLFACES — Python Port
2
+ SOLFACES v2 — Python Port
3
3
  Deterministic wallet avatar generation for Python backends, bots, and scripts.
4
- Zero dependencies. Generates identical traits to the JavaScript version.
4
+ Zero dependencies. Generates identical traits and SVG output to the TypeScript version.
5
5
 
6
6
  Usage:
7
- from solfaces import generate_traits, render_svg, describe_appearance
7
+ from solfaces import generate_traits, render_svg, describe_appearance, derive_name
8
8
 
9
9
  traits = generate_traits("7xKXq...")
10
10
  svg = render_svg("7xKXq...", size=256)
@@ -13,25 +13,28 @@ Usage:
13
13
 
14
14
  from __future__ import annotations
15
15
  from dataclasses import dataclass
16
- from typing import Optional, Dict, Any
16
+ from typing import Optional, Dict
17
17
  import ctypes
18
+ import hashlib
19
+ import math
20
+ import struct
18
21
 
19
22
 
20
23
  # ─── Types ────────────────────────────────────────────────────
21
24
 
22
25
  @dataclass
23
26
  class SolFaceTraits:
24
- face_shape: int # 0-3
25
- skin_color: int # 0-5
27
+ face_shape: int # 0-3 (consumed for PRNG ordering, all render as squircle)
28
+ skin_color: int # 0-9
26
29
  eye_style: int # 0-7
27
30
  eye_color: int # 0-4
28
31
  eyebrows: int # 0-4
29
32
  nose: int # 0-3
30
- mouth: int # 0-5
31
- hair_style: int # 0-7
32
- hair_color: int # 0-7
33
- accessory: int # 0-5
34
- bg_color: int # 0-4
33
+ mouth: int # 0-7
34
+ hair_style: int # 0-9
35
+ hair_color: int # 0-9
36
+ accessory: int # 0-9
37
+ bg_color: int # 0-9
35
38
 
36
39
  def to_dict(self) -> Dict[str, int]:
37
40
  return {
@@ -51,46 +54,142 @@ class SolFaceTraits:
51
54
 
52
55
  # ─── Color Palettes ──────────────────────────────────────────
53
56
 
54
- SKIN_COLORS = ["#ffd5b0", "#f4c794", "#e0a370", "#c68642", "#8d5524", "#4a2c17"]
55
- EYE_COLORS = ["#3d2b1f", "#4a80c4", "#5a9a5a", "#c89430", "#8a8a8a"]
56
- HAIR_COLORS = ["#1a1a1a", "#6b3a2a", "#d4a844", "#c44a20", "#c8e64a", "#6090e0", "#14F195", "#e040c0"]
57
- BG_COLORS = ["#c8e64a", "#6090e0", "#14F195", "#e8dcc8", "#f85149"]
57
+ SKIN_COLORS = [
58
+ "#faeae5", "#efd6c8", "#e4c5aa", "#d5b590", "#c59e77",
59
+ "#b4875f", "#9d6d4d", "#805742", "#654134", "#4b2d25",
60
+ ]
61
+ EYE_COLORS = ["#382414", "#3868A8", "#38784C", "#808838", "#586878"]
62
+ HAIR_COLORS = [
63
+ "#1A1A24", "#4C3428", "#887058", "#D4B868", "#A84830",
64
+ "#C0C0CC", "#484858", "#783850", "#D8B0A0", "#C08048",
65
+ ]
66
+ BG_COLORS = [
67
+ "#b98387", "#a9a360", "#9eb785", "#69ab79", "#81bbb0",
68
+ "#6499af", "#7f8bbd", "#8869ab", "#b785b3", "#ab6984",
69
+ ]
70
+
71
+
72
+ # ─── Color Math ──────────────────────────────────────────────
73
+
74
+ def hex_to_rgb(h: str) -> tuple[int, int, int]:
75
+ h = h.lstrip("#")
76
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
77
+
78
+
79
+ def rgb_to_hex(r: float, g: float, b: float) -> str:
80
+ return "#{:02x}{:02x}{:02x}".format(
81
+ max(0, min(255, round(r))),
82
+ max(0, min(255, round(g))),
83
+ max(0, min(255, round(b))),
84
+ )
85
+
86
+
87
+ def darken(h: str, pct: float = 0.12) -> str:
88
+ r, g, b = hex_to_rgb(h)
89
+ return rgb_to_hex(r * (1 - pct), g * (1 - pct), b * (1 - pct))
90
+
91
+
92
+ def lighten(h: str, pct: float = 0.15) -> str:
93
+ r, g, b = hex_to_rgb(h)
94
+ return rgb_to_hex(r + (255 - r) * pct, g + (255 - g) * pct, b + (255 - b) * pct)
95
+
96
+
97
+ def blend(a: str, b: str, t: float = 0.5) -> str:
98
+ r1, g1, b1 = hex_to_rgb(a)
99
+ r2, g2, b2 = hex_to_rgb(b)
100
+ return rgb_to_hex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
101
+
102
+
103
+ def luminance(h: str) -> float:
104
+ r, g, b = hex_to_rgb(h)
105
+ return (r + g + b) / 3
106
+
107
+
108
+ def derive_skin_colors(skin: str) -> dict:
109
+ sL = luminance(skin)
110
+ is_dark = sL < 100
111
+
112
+ skin_hi = lighten(skin, 0.10)
113
+ skin_lo = darken(skin, 0.22)
114
+ skin_mid = darken(skin, 0.05)
115
+
116
+ sr, sg, sb = hex_to_rgb(skin)
117
+ if sL > 120:
118
+ r_b = 0.03 if sL > 180 else 0.06
119
+ g_d = 0.30 if sL > 180 else 0.28
120
+ b_d = 0.25 if sL > 180 else 0.22
121
+ cheek_color = rgb_to_hex(
122
+ min(255, sr + sr * r_b),
123
+ max(0, sg - sg * g_d),
124
+ max(0, sb - sb * b_d),
125
+ )
126
+ else:
127
+ cheek_color = rgb_to_hex(min(255, sr + 50), max(0, sg - 10), max(0, sb - 5))
128
+
129
+ cheek_opacity = 0.15 + 0.18 * (1 - min(1, sL / 240))
130
+
131
+ lip_t = max(0, min(1, (sL - 60) / 180))
132
+ lip_base = blend("#D89090", "#A83848", lip_t)
133
+ mid_boost = 1 - abs(sL - 140) / 80
134
+ lip_blend = (0.70 if is_dark else 0.62) + max(0, mid_boost) * 0.12
135
+ lip_raw = blend(skin, lip_base, min(0.82, lip_blend))
136
+ lr, lg, lb = hex_to_rgb(lip_raw)
137
+ lip_d = abs(sr - lr) + abs(sg - lg) + abs(sb - lb)
138
+ lip_color = blend(skin, lip_base, 0.78) if lip_d < 60 else lip_raw
139
+
140
+ brow_color = lighten(skin, 0.35 if sL < 80 else 0.25) if is_dark else darken(skin, 0.55)
141
+ nose_fill = lighten(skin, 0.20) if is_dark else darken(skin, 0.20)
142
+
143
+ ear_t = max(0, min(1, (sL - 100) / 60))
144
+ ear_fill = blend(lighten(skin, 0.08), skin_mid, ear_t)
145
+ ear_shadow = darken(skin, 0.10 + 0.06 * (1 - min(1, sL / 160)))
146
+ lid_color = lighten(skin, 0.18) if is_dark else darken(skin, 0.15)
147
+
148
+ ew_t = max(0, min(1, (sL - 60) / 180))
149
+ eye_white = blend("#EDE8E0", "#FBF8F2", ew_t)
150
+
151
+ warmth = 0.3 if sL > 140 else (0.5 if sL > 100 else 0.7)
152
+ acc_color = blend("#808890", blend(skin, "#B0A898", 0.3), warmth)
153
+
154
+ return {
155
+ "skin_hi": skin_hi, "skin_lo": skin_lo, "skin_mid": skin_mid,
156
+ "is_dark": is_dark, "cheek_color": cheek_color, "cheek_opacity": cheek_opacity,
157
+ "lip_color": lip_color, "nose_fill": nose_fill, "brow_color": brow_color,
158
+ "ear_fill": ear_fill, "ear_shadow": ear_shadow,
159
+ "eye_white": eye_white, "lid_color": lid_color, "acc_color": acc_color,
160
+ }
161
+
162
+
163
+ def buzz_opacity(hair_col: str, skin: str) -> float:
164
+ hr, hg, hb = hex_to_rgb(hair_col)
165
+ sr, sg, sb = hex_to_rgb(skin)
166
+ return 0.70 if abs(hr - sr) + abs(hg - sg) + abs(hb - sb) < 80 else 0.50
58
167
 
59
168
 
60
169
  # ─── Hashing (djb2) — exact JS parity ────────────────────────
61
170
 
62
171
  def _djb2(s: str) -> int:
63
- """DJB2 hash — matches JavaScript implementation exactly."""
64
172
  hash_val = 5381
65
173
  for ch in s:
66
- # Replicate JS: ((hash << 5) + hash + charCode) | 0
67
174
  hash_val = ctypes.c_int32((hash_val << 5) + hash_val + ord(ch)).value
68
- # Return unsigned 32-bit (>>> 0 in JS)
69
175
  return hash_val & 0xFFFFFFFF
70
176
 
71
177
 
72
178
  # ─── PRNG (mulberry32) — exact JS parity ─────────────────────
73
179
 
74
180
  def _mulberry32(seed: int):
75
- """Mulberry32 PRNG — matches JavaScript implementation exactly."""
76
181
  s = ctypes.c_int32(seed).value
77
182
 
78
183
  def next_val() -> float:
79
184
  nonlocal s
80
185
  s = ctypes.c_int32(s + 0x6D2B79F5).value
81
-
82
- # Math.imul(s ^ (s >>> 15), 1 | s)
83
186
  a = (s ^ ((s & 0xFFFFFFFF) >> 15)) & 0xFFFFFFFF
84
187
  b = (1 | s) & 0xFFFFFFFF
85
188
  t = ctypes.c_int32(_imul(a, b)).value
86
-
87
- # (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
88
189
  c = (t ^ ((t & 0xFFFFFFFF) >> 7)) & 0xFFFFFFFF
89
190
  d = (61 | t) & 0xFFFFFFFF
90
191
  old_t = t
91
192
  t = (ctypes.c_int32(old_t + _imul(c, d)).value) ^ old_t
92
-
93
- # ((t ^ (t >>> 14)) >>> 0) / 4294967296
94
193
  result = ((t ^ ((t & 0xFFFFFFFF) >> 14)) & 0xFFFFFFFF)
95
194
  return result / 4294967296
96
195
 
@@ -98,7 +197,6 @@ def _mulberry32(seed: int):
98
197
 
99
198
 
100
199
  def _imul(a: int, b: int) -> int:
101
- """Emulate Math.imul — 32-bit integer multiply."""
102
200
  a = a & 0xFFFFFFFF
103
201
  b = b & 0xFFFFFFFF
104
202
  result = (a * b) & 0xFFFFFFFF
@@ -110,197 +208,383 @@ def _imul(a: int, b: int) -> int:
110
208
  # ─── Trait Generation ─────────────────────────────────────────
111
209
 
112
210
  def generate_traits(wallet_address: str) -> SolFaceTraits:
113
- """
114
- Generate deterministic avatar traits from a Solana wallet address.
115
- Produces identical output to the JavaScript version.
116
- """
117
211
  seed = _djb2(wallet_address)
118
212
  rand = _mulberry32(seed)
119
213
 
214
+ # IMPORTANT: Order must NEVER change — shifts all downstream values.
120
215
  return SolFaceTraits(
121
216
  face_shape=int(rand() * 4),
122
- skin_color=int(rand() * 6),
217
+ skin_color=int(rand() * 10),
123
218
  eye_style=int(rand() * 8),
124
219
  eye_color=int(rand() * 5),
125
220
  eyebrows=int(rand() * 5),
126
221
  nose=int(rand() * 4),
127
- mouth=int(rand() * 6),
128
- hair_style=int(rand() * 8),
129
- hair_color=int(rand() * 8),
130
- accessory=int(rand() * 6),
131
- bg_color=int(rand() * 5),
222
+ mouth=int(rand() * 8),
223
+ hair_style=int(rand() * 10),
224
+ hair_color=int(rand() * 10),
225
+ accessory=int(rand() * 10),
226
+ bg_color=int(rand() * 10),
132
227
  )
133
228
 
134
229
 
230
+ def effective_accessory(t: SolFaceTraits) -> int:
231
+ ai = t.accessory % 10
232
+ hi = t.hair_style % 10
233
+ if (ai == 4 or ai == 7) and (hi == 5 or hi == 6):
234
+ return 0
235
+ return ai
236
+
237
+
135
238
  def trait_hash(wallet_address: str) -> str:
136
- """Return 8-char hex hash for cache keys."""
137
239
  return f"{_djb2(wallet_address):08x}"
138
240
 
139
241
 
140
242
  # ─── Trait Labels ─────────────────────────────────────────────
141
243
 
142
- FACE_LABELS = ["Round", "Square", "Oval", "Hexagon"]
143
- EYE_LABELS = ["Round", "Dots", "Almond", "Wide", "Sleepy", "Winking", "Lashes", "Narrow"]
144
- BROW_LABELS = ["None", "Thin", "Thick", "Arched", "Angled"]
145
- NOSE_LABELS = ["None", "Dot", "Triangle", "Button"]
146
- MOUTH_LABELS = ["Smile", "Neutral", "Grin", "Open", "Smirk", "Wide Smile"]
147
- HAIR_LABELS = ["Bald", "Short", "Spiky", "Swept", "Mohawk", "Long", "Bob", "Buzz"]
148
- ACC_LABELS = ["None", "None", "Round Glasses", "Square Glasses", "Earring", "Bandana"]
149
- HAIR_COLOR_LABELS = ["Black", "Brown", "Blonde", "Ginger", "Neon Lime", "Neon Blue", "Solana Mint", "Neon Magenta"]
150
- SKIN_LABELS = ["Light Peach", "Warm Tan", "Golden Brown", "Medium Brown", "Deep Brown", "Rich Dark Brown"]
151
- EYE_COLOR_LABELS = ["Dark Brown", "Blue", "Green", "Amber", "Gray"]
152
- BG_COLOR_LABELS = ["Lime", "Blue", "Mint", "Sand", "Red"]
153
-
154
- def get_trait_labels(traits: SolFaceTraits) -> Dict[str, str]:
155
- """Human-readable labels for all traits."""
244
+ SKIN_LABELS = ["Porcelain", "Ivory", "Fair", "Light", "Sand",
245
+ "Golden", "Warm", "Caramel", "Brown", "Deep"]
246
+ EYE_STYLE_LABELS = ["Round", "Minimal", "Almond", "Wide", "Relaxed", "Joyful", "Bright", "Gentle"]
247
+ EYE_COLOR_LABELS = ["Chocolate", "Sky", "Emerald", "Hazel", "Storm"]
248
+ BROW_LABELS = ["Wispy", "Straight", "Natural", "Arched", "Angled"]
249
+ NOSE_LABELS = ["Shadow", "Button", "Soft", "Nostrils"]
250
+ MOUTH_LABELS = ["Smile", "Calm", "Happy", "Oh", "Smirk", "Grin", "Flat", "Pout"]
251
+ HAIR_STYLE_LABELS = ["Bald", "Short", "Curly", "Side Sweep", "Puff",
252
+ "Long", "Bob", "Buzz", "Wavy", "Topknot"]
253
+ HAIR_COLOR_LABELS = ["Black", "Espresso", "Walnut", "Honey", "Copper",
254
+ "Silver", "Charcoal", "Burgundy", "Strawberry", "Ginger"]
255
+ ACC_LABELS = ["None", "Beauty Mark", "Round Glasses", "Rect Glasses", "Earring",
256
+ "Headband", "Freckles", "Stud Earrings", "Aviators", "Band-Aid"]
257
+ BG_COLOR_LABELS = ["Rose", "Olive", "Sage", "Fern", "Mint",
258
+ "Ocean", "Sky", "Lavender", "Orchid", "Blush"]
259
+
260
+
261
+ def get_trait_labels(t: SolFaceTraits) -> Dict[str, str]:
262
+ ai = effective_accessory(t)
156
263
  return {
157
- "faceShape": FACE_LABELS[traits.face_shape],
158
- "skinColor": SKIN_LABELS[traits.skin_color] if traits.skin_color < len(SKIN_LABELS) else "Warm Tan",
159
- "eyeStyle": EYE_LABELS[traits.eye_style],
160
- "eyeColor": EYE_COLOR_LABELS[traits.eye_color] if traits.eye_color < len(EYE_COLOR_LABELS) else "Dark Brown",
161
- "eyebrows": BROW_LABELS[traits.eyebrows],
162
- "nose": NOSE_LABELS[traits.nose],
163
- "mouth": MOUTH_LABELS[traits.mouth],
164
- "hairStyle": HAIR_LABELS[traits.hair_style],
165
- "hairColor": HAIR_COLOR_LABELS[traits.hair_color],
166
- "accessory": ACC_LABELS[traits.accessory],
167
- "bgColor": BG_COLOR_LABELS[traits.bg_color] if traits.bg_color < len(BG_COLOR_LABELS) else "Lime",
264
+ "faceShape": "Squircle",
265
+ "skinColor": SKIN_LABELS[t.skin_color] if t.skin_color < len(SKIN_LABELS) else "Fair",
266
+ "eyeStyle": EYE_STYLE_LABELS[t.eye_style] if t.eye_style < len(EYE_STYLE_LABELS) else "Round",
267
+ "eyeColor": EYE_COLOR_LABELS[t.eye_color] if t.eye_color < len(EYE_COLOR_LABELS) else "Chocolate",
268
+ "eyebrows": BROW_LABELS[t.eyebrows] if t.eyebrows < len(BROW_LABELS) else "Wispy",
269
+ "nose": NOSE_LABELS[t.nose] if t.nose < len(NOSE_LABELS) else "Shadow",
270
+ "mouth": MOUTH_LABELS[t.mouth] if t.mouth < len(MOUTH_LABELS) else "Smile",
271
+ "hairStyle": HAIR_STYLE_LABELS[t.hair_style] if t.hair_style < len(HAIR_STYLE_LABELS) else "Bald",
272
+ "hairColor": HAIR_COLOR_LABELS[t.hair_color] if t.hair_color < len(HAIR_COLOR_LABELS) else "Black",
273
+ "accessory": ACC_LABELS[ai] if ai < len(ACC_LABELS) else "None",
274
+ "bgColor": BG_COLOR_LABELS[t.bg_color] if t.bg_color < len(BG_COLOR_LABELS) else "Rose",
168
275
  }
169
276
 
170
277
 
171
278
  # ─── SVG Rendering ────────────────────────────────────────────
172
279
 
173
- def _render_face(t: SolFaceTraits, skin: str) -> str:
174
- if t.face_shape == 0: return f'<circle cx="32" cy="34" r="20" fill="{skin}"/>'
175
- if t.face_shape == 1: return f'<rect x="12" y="14" width="40" height="40" rx="8" ry="8" fill="{skin}"/>'
176
- if t.face_shape == 2: return f'<ellipse cx="32" cy="34" rx="18" ry="22" fill="{skin}"/>'
177
- if t.face_shape == 3: return f'<path d="M32 12 L50 24 L50 44 L32 56 L14 44 L14 24 Z" fill="{skin}" stroke-linejoin="round"/>'
178
- return f'<circle cx="32" cy="34" r="20" fill="{skin}"/>'
179
-
180
-
181
- def _render_eyes(t: SolFaceTraits, c: str, w: str = "white") -> str:
182
- l, r, y = 24, 40, 30
183
- if t.eye_style == 0:
184
- return f'<circle cx="{l}" cy="{y}" r="3.5" fill="{w}"/><circle cx="{l+1}" cy="{y}" r="2" fill="{c}"/><circle cx="{r}" cy="{y}" r="3.5" fill="{w}"/><circle cx="{r+1}" cy="{y}" r="2" fill="{c}"/>'
185
- if t.eye_style == 1:
186
- return f'<circle cx="{l}" cy="{y}" r="2" fill="{c}"/><circle cx="{r}" cy="{y}" r="2" fill="{c}"/>'
187
- if t.eye_style == 2:
188
- return f'<ellipse cx="{l}" cy="{y}" rx="4" ry="2.5" fill="{w}"/><circle cx="{l+0.5}" cy="{y}" r="1.5" fill="{c}"/><ellipse cx="{r}" cy="{y}" rx="4" ry="2.5" fill="{w}"/><circle cx="{r+0.5}" cy="{y}" r="1.5" fill="{c}"/>'
189
- if t.eye_style == 3:
190
- return f'<circle cx="{l}" cy="{y}" r="4.5" fill="{w}"/><circle cx="{l}" cy="{y+0.5}" r="2.5" fill="{c}"/><circle cx="{r}" cy="{y}" r="4.5" fill="{w}"/><circle cx="{r}" cy="{y+0.5}" r="2.5" fill="{c}"/>'
191
- if t.eye_style == 4:
192
- return f'<ellipse cx="{l}" cy="{y+1}" rx="3.5" ry="2" fill="{w}"/><circle cx="{l}" cy="{y+1}" r="1.5" fill="{c}"/><line x1="{l-4}" y1="{y-0.5}" x2="{l+4}" y2="{y-0.5}" stroke="{c}" stroke-width="1" stroke-linecap="round"/><ellipse cx="{r}" cy="{y+1}" rx="3.5" ry="2" fill="{w}"/><circle cx="{r}" cy="{y+1}" r="1.5" fill="{c}"/><line x1="{r-4}" y1="{y-0.5}" x2="{r+4}" y2="{y-0.5}" stroke="{c}" stroke-width="1" stroke-linecap="round"/>'
193
- if t.eye_style == 5:
194
- return f'<path d="M{l-3} {y} Q{l} {y+3} {l+3} {y}" fill="none" stroke="{c}" stroke-width="1.5" stroke-linecap="round"/><circle cx="{r}" cy="{y}" r="3.5" fill="{w}"/><circle cx="{r+1}" cy="{y}" r="2" fill="{c}"/>'
195
- if t.eye_style == 6:
196
- return f'<circle cx="{l}" cy="{y}" r="3" fill="{w}"/><circle cx="{l+0.5}" cy="{y}" r="1.5" fill="{c}"/><line x1="{l+2}" y1="{y-3}" x2="{l+3.5}" y2="{y-4.5}" stroke="{c}" stroke-width="0.8" stroke-linecap="round"/><line x1="{l+3}" y1="{y-2}" x2="{l+4.5}" y2="{y-3}" stroke="{c}" stroke-width="0.8" stroke-linecap="round"/><circle cx="{r}" cy="{y}" r="3" fill="{w}"/><circle cx="{r+0.5}" cy="{y}" r="1.5" fill="{c}"/><line x1="{r+2}" y1="{y-3}" x2="{r+3.5}" y2="{y-4.5}" stroke="{c}" stroke-width="0.8" stroke-linecap="round"/><line x1="{r+3}" y1="{y-2}" x2="{r+4.5}" y2="{y-3}" stroke="{c}" stroke-width="0.8" stroke-linecap="round"/>'
197
- if t.eye_style == 7:
198
- return f'<ellipse cx="{l}" cy="{y}" rx="4" ry="1.2" fill="{w}"/><ellipse cx="{l+0.5}" cy="{y}" rx="2" ry="1" fill="{c}"/><ellipse cx="{r}" cy="{y}" rx="4" ry="1.2" fill="{w}"/><ellipse cx="{r+0.5}" cy="{y}" rx="2" ry="1" fill="{c}"/>'
199
- return f'<circle cx="{l}" cy="{y}" r="3" fill="{w}"/><circle cx="{l+1}" cy="{y}" r="2" fill="{c}"/><circle cx="{r}" cy="{y}" r="3" fill="{w}"/><circle cx="{r+1}" cy="{y}" r="2" fill="{c}"/>'
200
-
201
-
202
- def _render_eyebrows(t: SolFaceTraits, col: str = "#2a2020") -> str:
203
- l, r, y = 24, 40, 25
204
- if t.eyebrows == 0: return ""
205
- if t.eyebrows == 1: return f'<line x1="{l-3}" y1="{y}" x2="{l+3}" y2="{y}" stroke="{col}" stroke-width="0.8" stroke-linecap="round"/><line x1="{r-3}" y1="{y}" x2="{r+3}" y2="{y}" stroke="{col}" stroke-width="0.8" stroke-linecap="round"/>'
206
- if t.eyebrows == 2: return f'<line x1="{l-3.5}" y1="{y}" x2="{l+3.5}" y2="{y}" stroke="{col}" stroke-width="2" stroke-linecap="round"/><line x1="{r-3.5}" y1="{y}" x2="{r+3.5}" y2="{y}" stroke="{col}" stroke-width="2" stroke-linecap="round"/>'
207
- if t.eyebrows == 3: return f'<path d="M{l-3.5} {y+1} Q{l} {y-2} {l+3.5} {y+1}" fill="none" stroke="{col}" stroke-width="1" stroke-linecap="round"/><path d="M{r-3.5} {y+1} Q{r} {y-2} {r+3.5} {y+1}" fill="none" stroke="{col}" stroke-width="1" stroke-linecap="round"/>'
208
- if t.eyebrows == 4: return f'<line x1="{l-3}" y1="{y-1}" x2="{l+3}" y2="{y+1}" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/><line x1="{r-3}" y1="{y+1}" x2="{r+3}" y2="{y-1}" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>'
280
+ def _to_base36(n: int) -> str:
281
+ if n == 0:
282
+ return "0"
283
+ chars = "0123456789abcdefghijklmnopqrstuvwxyz"
284
+ result = ""
285
+ while n > 0:
286
+ result = chars[n % 36] + result
287
+ n //= 36
288
+ return result
289
+
290
+
291
+ def _build_defs(gid: str, skin: str, skin_hi: str, skin_lo: str,
292
+ hair_col: str, bg_col: str, cheek_color: str,
293
+ cheek_opacity: float, flat: bool, full: bool) -> str:
294
+ if flat:
295
+ return ""
296
+ d = "<defs>"
297
+ d += f'<linearGradient id="{gid}sg" x1="0" y1="0" x2="0" y2="1">'
298
+ d += f'<stop offset="0%" stop-color="{skin_hi}"/>'
299
+ d += f'<stop offset="100%" stop-color="{skin_lo}"/>'
300
+ d += "</linearGradient>"
301
+ d += f'<linearGradient id="{gid}hg" x1="0" y1="0" x2="0" y2="1">'
302
+ d += f'<stop offset="0%" stop-color="{lighten(hair_col, 0.15)}"/>'
303
+ d += f'<stop offset="100%" stop-color="{darken(hair_col, 0.15)}"/>'
304
+ d += "</linearGradient>"
305
+ d += f'<linearGradient id="{gid}bg" x1="0" y1="0" x2="1" y2="1">'
306
+ d += f'<stop offset="0%" stop-color="{lighten(bg_col, 0.12)}"/>'
307
+ d += f'<stop offset="100%" stop-color="{darken(bg_col, 0.12)}"/>'
308
+ d += "</linearGradient>"
309
+ if full:
310
+ d += f'<radialGradient id="{gid}glow" cx="0.5" cy="0.28" r="0.45">'
311
+ d += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.10"/>'
312
+ d += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>'
313
+ d += "</radialGradient>"
314
+ d += f'<radialGradient id="{gid}chin" cx="0.5" cy="0.85" r="0.35">'
315
+ d += f'<stop offset="0%" stop-color="{skin_lo}" stop-opacity="0.30"/>'
316
+ d += f'<stop offset="100%" stop-color="{skin_lo}" stop-opacity="0"/>'
317
+ d += "</radialGradient>"
318
+ d += f'<radialGradient id="{gid}cL" cx="0.5" cy="0.5" r="0.5">'
319
+ d += f'<stop offset="0%" stop-color="{cheek_color}" stop-opacity="{cheek_opacity:.2f}"/>'
320
+ d += f'<stop offset="100%" stop-color="{cheek_color}" stop-opacity="0"/>'
321
+ d += "</radialGradient>"
322
+ d += f'<radialGradient id="{gid}cR" cx="0.5" cy="0.5" r="0.5">'
323
+ d += f'<stop offset="0%" stop-color="{cheek_color}" stop-opacity="{cheek_opacity:.2f}"/>'
324
+ d += f'<stop offset="100%" stop-color="{cheek_color}" stop-opacity="0"/>'
325
+ d += "</radialGradient>"
326
+ d += "</defs>"
327
+ return d
328
+
329
+
330
+ def _render_hair_back(hi: int, gid: str, flat: bool) -> str:
331
+ fill = "currentColor" if flat else f"url(#{gid}hg)"
332
+ if hi == 5: return f'<rect x="10" y="14" width="44" height="42" rx="6" fill="{fill}"/>'
333
+ if hi == 6: return f'<rect x="12" y="14" width="40" height="32" rx="8" fill="{fill}"/>'
334
+ if hi == 8: return f'<rect x="11" y="14" width="42" height="38" rx="8" fill="{fill}"/>'
209
335
  return ""
210
336
 
211
337
 
212
- def _render_nose(t: SolFaceTraits, skin: str, nose_col: Optional[str] = None) -> str:
213
- cx, y = 32, 36
214
- sh = nose_col if nose_col else (skin + "aa")
215
- if t.nose == 0: return ""
216
- if t.nose == 1: return f'<circle cx="{cx}" cy="{y}" r="1.5" fill="{sh}"/>'
217
- if t.nose == 2: return f'<path d="M{cx} {y-1.5} L{cx+2.5} {y+2} L{cx-2.5} {y+2} Z" fill="{sh}"/>'
218
- if t.nose == 3: return f'<circle cx="{cx-1.5}" cy="{y}" r="1" fill="{sh}"/><circle cx="{cx+1.5}" cy="{y}" r="1" fill="{sh}"/>'
338
+ def _render_ears(ear_fill: str, ear_shadow: str) -> str:
339
+ return (
340
+ f'<ellipse cx="11" cy="34" rx="4" ry="5" fill="{ear_fill}"/>'
341
+ f'<ellipse cx="11" cy="34" rx="2.5" ry="3.5" fill="{ear_shadow}" opacity="0.3"/>'
342
+ f'<ellipse cx="53" cy="34" rx="4" ry="5" fill="{ear_fill}"/>'
343
+ f'<ellipse cx="53" cy="34" rx="2.5" ry="3.5" fill="{ear_shadow}" opacity="0.3"/>'
344
+ )
345
+
346
+
347
+ def _render_face(gid: str, skin: str, flat: bool) -> str:
348
+ fill = skin if flat else f"url(#{gid}sg)"
349
+ return f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="{fill}"/>'
350
+
351
+
352
+ def _render_face_overlays(gid: str) -> str:
353
+ return (
354
+ f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="url(#{gid}glow)"/>'
355
+ f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="url(#{gid}chin)"/>'
356
+ f'<ellipse cx="22" cy="42" rx="5" ry="3.5" fill="url(#{gid}cL)"/>'
357
+ f'<ellipse cx="42" cy="42" rx="5" ry="3.5" fill="url(#{gid}cR)"/>'
358
+ '<line x1="20" y1="50" x2="44" y2="50" stroke="currentColor" stroke-width="0.3" opacity="0.08" stroke-linecap="round"/>'
359
+ )
360
+
361
+
362
+ def _render_hair_front(hi: int, gid: str, hair_col: str, skin: str, flat: bool) -> str:
363
+ fill = hair_col if flat else f"url(#{gid}hg)"
364
+ if hi == 0: return ""
365
+ if hi == 1: return f'<path d="M14 28 Q14 14 32 12 Q50 14 50 28 L50 22 Q50 12 32 10 Q14 12 14 22 Z" fill="{fill}"/>'
366
+ if hi == 2:
367
+ return (f'<g fill="{fill}">'
368
+ '<circle cx="20" cy="14" r="5"/><circle cx="28" cy="11" r="5.5"/>'
369
+ '<circle cx="36" cy="11" r="5.5"/><circle cx="44" cy="14" r="5"/>'
370
+ '<circle cx="16" cy="20" r="4"/><circle cx="48" cy="20" r="4"/></g>')
371
+ if hi == 3:
372
+ return (f'<path d="M14 26 Q14 12 32 10 Q50 12 50 26 L50 20 Q50 10 32 8 Q14 10 14 20 Z" fill="{fill}"/>'
373
+ f'<path d="M14 20 Q8 16 10 8 Q14 10 20 16 Z" fill="{fill}"/>')
374
+ if hi == 4: return f'<ellipse cx="32" cy="10" rx="14" ry="8" fill="{fill}"/>'
375
+ if hi == 5: return f'<path d="M14 28 Q14 12 32 10 Q50 12 50 28 L50 20 Q50 10 32 8 Q14 10 14 20 Z" fill="{fill}"/>'
376
+ if hi == 6:
377
+ return (f'<path d="M14 28 Q14 12 32 10 Q50 12 50 28 L50 20 Q50 10 32 8 Q14 10 14 20 Z" fill="{fill}"/>'
378
+ f'<rect x="10" y="28" width="8" height="14" rx="4" fill="{fill}"/>'
379
+ f'<rect x="46" y="28" width="8" height="14" rx="4" fill="{fill}"/>')
380
+ if hi == 7:
381
+ bop = buzz_opacity(hair_col, skin)
382
+ return f'<rect x="15" y="13" width="34" height="16" rx="10" ry="8" fill="{hair_col}" opacity="{bop:.2f}"/>'
383
+ if hi == 8:
384
+ return (f'<path d="M14 28 Q14 12 32 10 Q50 12 50 28 L50 20 Q50 10 32 8 Q14 10 14 20 Z" fill="{fill}"/>'
385
+ f'<path d="M12 30 Q10 20 14 16" fill="none" stroke="{fill}" stroke-width="4" stroke-linecap="round"/>'
386
+ f'<path d="M52 30 Q54 20 50 16" fill="none" stroke="{fill}" stroke-width="4" stroke-linecap="round"/>')
387
+ if hi == 9:
388
+ return (f'<path d="M14 28 Q14 14 32 12 Q50 14 50 28 L50 22 Q50 12 32 10 Q14 12 14 22 Z" fill="{fill}"/>'
389
+ f'<ellipse cx="32" cy="6" rx="6" ry="5" fill="{fill}"/>')
219
390
  return ""
220
391
 
221
392
 
222
- def _render_mouth(t: SolFaceTraits, col: str = "#c05050", teeth_col: str = "white") -> str:
223
- cx, y = 32, 42
224
- if t.mouth == 0: return f'<path d="M{cx-4} {y} Q{cx} {y+4} {cx+4} {y}" fill="none" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>'
225
- if t.mouth == 1: return f'<line x1="{cx-3}" y1="{y+1}" x2="{cx+3}" y2="{y+1}" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>'
226
- if t.mouth == 2: return f'<path d="M{cx-6} {y} Q{cx} {y+5} {cx+6} {y}" fill="none" stroke="{col}" stroke-width="1.5" stroke-linecap="round"/>'
227
- if t.mouth == 3: return f'<ellipse cx="{cx}" cy="{y+1}" rx="3" ry="2.5" fill="{col}" opacity="0.8"/>'
228
- if t.mouth == 4: return f'<path d="M{cx-4} {y+1} Q{cx-1} {y+1} {cx+4} {y-1}" fill="none" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>'
229
- if t.mouth == 5: return f'<path d="M{cx-6} {y} Q{cx} {y+6} {cx+6} {y}" fill="{teeth_col}" stroke="{col}" stroke-width="1"/>'
230
- return f'<path d="M{cx-4} {y} Q{cx} {y+4} {cx+4} {y}" fill="none" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>'
231
-
232
-
233
- def _render_hair(t: SolFaceTraits, col: str) -> str:
234
- if t.hair_style == 0: return ""
235
- if t.hair_style == 1: return f'<rect x="14" y="12" width="36" height="12" rx="6" ry="6" fill="{col}"/>'
236
- if t.hair_style == 2: return f'<g fill="{col}"><rect x="14" y="16" width="36" height="8" rx="2"/><polygon points="18,16 22,6 26,16"/><polygon points="26,16 30,4 34,16"/><polygon points="34,16 38,6 42,16"/><polygon points="42,16 46,10 48,16"/></g>'
237
- if t.hair_style == 3: return f'<g fill="{col}"><rect x="14" y="14" width="36" height="10" rx="4"/><path d="M14 18 Q8 14 10 8 Q14 10 20 14 Z"/></g>'
238
- if t.hair_style == 4: return f'<rect x="26" y="4" width="12" height="20" rx="4" ry="2" fill="{col}"/>'
239
- if t.hair_style == 5: return f'<g fill="{col}"><rect x="14" y="12" width="36" height="10" rx="4"/><rect x="10" y="18" width="8" height="24" rx="3"/><rect x="46" y="18" width="8" height="24" rx="3"/></g>'
240
- if t.hair_style == 6: return f'<path d="M12 22 Q12 10 32 10 Q52 10 52 22 L52 38 Q52 42 48 42 L48 26 Q48 16 32 16 Q16 16 16 26 L16 42 Q12 42 12 38 Z" fill="{col}"/>'
241
- if t.hair_style == 7: return f'<rect x="15" y="13" width="34" height="9" rx="8" ry="4" fill="{col}" opacity="0.7"/>'
393
+ def _render_eyes(ei: int, eye_col: str, eye_white: str, lid_color: str, full: bool) -> str:
394
+ lx, rx, y = 25, 39, 33
395
+ s = ""
396
+ if ei == 0:
397
+ s += f'<circle cx="{lx}" cy="{y}" r="4" fill="{eye_white}"/><circle cx="{lx+0.8}" cy="{y}" r="2.2" fill="{eye_col}"/>'
398
+ if full: s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="0.7" fill="white" opacity="0.8"/>'
399
+ s += f'<circle cx="{rx}" cy="{y}" r="4" fill="{eye_white}"/><circle cx="{rx+0.8}" cy="{y}" r="2.2" fill="{eye_col}"/>'
400
+ if full: s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="0.7" fill="white" opacity="0.8"/>'
401
+ elif ei == 1:
402
+ s += f'<circle cx="{lx}" cy="{y}" r="2" fill="{eye_col}"/><circle cx="{rx}" cy="{y}" r="2" fill="{eye_col}"/>'
403
+ elif ei == 2:
404
+ s += f'<ellipse cx="{lx}" cy="{y}" rx="4.5" ry="2.8" fill="{eye_white}"/><circle cx="{lx+0.5}" cy="{y}" r="1.8" fill="{eye_col}"/>'
405
+ if full: s += f'<circle cx="{lx+1.2}" cy="{y-0.8}" r="0.6" fill="white" opacity="0.7"/>'
406
+ s += f'<ellipse cx="{rx}" cy="{y}" rx="4.5" ry="2.8" fill="{eye_white}"/><circle cx="{rx+0.5}" cy="{y}" r="1.8" fill="{eye_col}"/>'
407
+ if full: s += f'<circle cx="{rx+1.2}" cy="{y-0.8}" r="0.6" fill="white" opacity="0.7"/>'
408
+ elif ei == 3:
409
+ s += f'<circle cx="{lx}" cy="{y}" r="5" fill="{eye_white}"/><circle cx="{lx}" cy="{y+0.5}" r="2.8" fill="{eye_col}"/>'
410
+ if full: s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="0.8" fill="white" opacity="0.8"/>'
411
+ s += f'<circle cx="{rx}" cy="{y}" r="5" fill="{eye_white}"/><circle cx="{rx}" cy="{y+0.5}" r="2.8" fill="{eye_col}"/>'
412
+ if full: s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="0.8" fill="white" opacity="0.8"/>'
413
+ elif ei == 4:
414
+ s += f'<ellipse cx="{lx}" cy="{y+1}" rx="4" ry="2.2" fill="{eye_white}"/><circle cx="{lx}" cy="{y+1}" r="1.5" fill="{eye_col}"/>'
415
+ if full: s += f'<line x1="{lx-4.5}" y1="{y-0.5}" x2="{lx+4.5}" y2="{y-0.5}" stroke="{lid_color}" stroke-width="0.8" stroke-linecap="round"/>'
416
+ s += f'<ellipse cx="{rx}" cy="{y+1}" rx="4" ry="2.2" fill="{eye_white}"/><circle cx="{rx}" cy="{y+1}" r="1.5" fill="{eye_col}"/>'
417
+ if full: s += f'<line x1="{rx-4.5}" y1="{y-0.5}" x2="{rx+4.5}" y2="{y-0.5}" stroke="{lid_color}" stroke-width="0.8" stroke-linecap="round"/>'
418
+ elif ei == 5:
419
+ s += f'<path d="M{lx-4} {y} Q{lx} {y+4} {lx+4} {y}" fill="none" stroke="{eye_col}" stroke-width="1.8" stroke-linecap="round"/>'
420
+ s += f'<path d="M{rx-4} {y} Q{rx} {y+4} {rx+4} {y}" fill="none" stroke="{eye_col}" stroke-width="1.8" stroke-linecap="round"/>'
421
+ elif ei == 6:
422
+ s += f'<circle cx="{lx}" cy="{y}" r="3.5" fill="{eye_white}"/><circle cx="{lx+0.5}" cy="{y}" r="2" fill="{eye_col}"/>'
423
+ s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="1" fill="white" opacity="0.9"/>'
424
+ if full:
425
+ s += f'<line x1="{lx+2.5}" y1="{y-3.5}" x2="{lx+4}" y2="{y-5}" stroke="{eye_col}" stroke-width="0.8" stroke-linecap="round"/>'
426
+ s += f'<line x1="{lx+3.5}" y1="{y-2.5}" x2="{lx+5}" y2="{y-3.5}" stroke="{eye_col}" stroke-width="0.8" stroke-linecap="round"/>'
427
+ s += f'<circle cx="{rx}" cy="{y}" r="3.5" fill="{eye_white}"/><circle cx="{rx+0.5}" cy="{y}" r="2" fill="{eye_col}"/>'
428
+ s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="1" fill="white" opacity="0.9"/>'
429
+ if full:
430
+ s += f'<line x1="{rx+2.5}" y1="{y-3.5}" x2="{rx+4}" y2="{y-5}" stroke="{eye_col}" stroke-width="0.8" stroke-linecap="round"/>'
431
+ s += f'<line x1="{rx+3.5}" y1="{y-2.5}" x2="{rx+5}" y2="{y-3.5}" stroke="{eye_col}" stroke-width="0.8" stroke-linecap="round"/>'
432
+ elif ei == 7:
433
+ s += f'<ellipse cx="{lx}" cy="{y}" rx="4.5" ry="1.5" fill="{eye_white}"/><ellipse cx="{lx+0.5}" cy="{y}" rx="2.2" ry="1.2" fill="{eye_col}"/>'
434
+ s += f'<ellipse cx="{rx}" cy="{y}" rx="4.5" ry="1.5" fill="{eye_white}"/><ellipse cx="{rx+0.5}" cy="{y}" rx="2.2" ry="1.2" fill="{eye_col}"/>'
435
+ else:
436
+ s += f'<circle cx="{lx}" cy="{y}" r="3.5" fill="{eye_white}"/><circle cx="{lx+0.8}" cy="{y}" r="2" fill="{eye_col}"/>'
437
+ s += f'<circle cx="{rx}" cy="{y}" r="3.5" fill="{eye_white}"/><circle cx="{rx+0.8}" cy="{y}" r="2" fill="{eye_col}"/>'
438
+
439
+ if full and ei not in (1, 5):
440
+ s += f'<path d="M{lx-4} {y-1.5} Q{lx} {y-4} {lx+4} {y-1.5}" fill="none" stroke="{lid_color}" stroke-width="0.5" opacity="0.4"/>'
441
+ s += f'<path d="M{rx-4} {y-1.5} Q{rx} {y-4} {rx+4} {y-1.5}" fill="none" stroke="{lid_color}" stroke-width="0.5" opacity="0.4"/>'
442
+ return s
443
+
444
+
445
+ def _render_eyebrows(bi: int, brow_color: str) -> str:
446
+ lx, rx, y = 25, 39, 27
447
+ if bi == 0:
448
+ return (f'<line x1="{lx-3}" y1="{y}" x2="{lx+3}" y2="{y-0.5}" stroke="{brow_color}" stroke-width="0.7" stroke-linecap="round"/>'
449
+ f'<line x1="{rx-3}" y1="{y-0.5}" x2="{rx+3}" y2="{y}" stroke="{brow_color}" stroke-width="0.7" stroke-linecap="round"/>')
450
+ if bi == 1:
451
+ return (f'<line x1="{lx-3.5}" y1="{y}" x2="{lx+3.5}" y2="{y}" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round"/>'
452
+ f'<line x1="{rx-3.5}" y1="{y}" x2="{rx+3.5}" y2="{y}" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round"/>')
453
+ if bi == 2:
454
+ return (f'<path d="M{lx-3.5} {y+0.5} Q{lx} {y-1.5} {lx+3.5} {y+0.5}" fill="none" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round"/>'
455
+ f'<path d="M{rx-3.5} {y+0.5} Q{rx} {y-1.5} {rx+3.5} {y+0.5}" fill="none" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round"/>')
456
+ if bi == 3:
457
+ return (f'<path d="M{lx-4} {y+1} Q{lx} {y-3} {lx+4} {y+1}" fill="none" stroke="{brow_color}" stroke-width="1" stroke-linecap="round"/>'
458
+ f'<path d="M{rx-4} {y+1} Q{rx} {y-3} {rx+4} {y+1}" fill="none" stroke="{brow_color}" stroke-width="1" stroke-linecap="round"/>')
459
+ if bi == 4:
460
+ return (f'<polyline points="{lx-3},{y+1} {lx},{y-2} {lx+3},{y}" fill="none" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>'
461
+ f'<polyline points="{rx-3},{y} {rx},{y-2} {rx+3},{y+1}" fill="none" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>')
242
462
  return ""
243
463
 
244
464
 
245
- def _render_accessory(t: SolFaceTraits, col: str = "#444") -> str:
246
- if t.accessory <= 1: return ""
247
- if t.accessory == 2: return f'<g fill="none" stroke="{col}" stroke-width="1"><circle cx="24" cy="30" r="5"/><circle cx="40" cy="30" r="5"/><line x1="29" y1="30" x2="35" y2="30"/><line x1="19" y1="30" x2="14" y2="28"/><line x1="45" y1="30" x2="50" y2="28"/></g>'
248
- if t.accessory == 3: return f'<g fill="none" stroke="{col}" stroke-width="1"><rect x="19" y="26" width="10" height="8" rx="1"/><rect x="35" y="26" width="10" height="8" rx="1"/><line x1="29" y1="30" x2="35" y2="30"/><line x1="19" y1="30" x2="14" y2="28"/><line x1="45" y1="30" x2="50" y2="28"/></g>'
249
- if t.accessory == 4: return f'<circle cx="11" cy="36" r="2" fill="{col}" stroke="{col}" stroke-width="0.5"/>'
250
- if t.accessory == 5: return f'<g><rect x="12" y="20" width="40" height="4" rx="1" fill="{col}"/><path d="M12 22 L8 26 L12 24 Z" fill="{col}"/></g>'
465
+ def _render_nose(ni: int, nose_fill: str) -> str:
466
+ cx, y = 32, 39
467
+ if ni == 0: return f'<ellipse cx="{cx}" cy="{y}" rx="2" ry="1.2" fill="{nose_fill}" opacity="0.35"/>'
468
+ if ni == 1: return f'<circle cx="{cx}" cy="{y}" r="1.8" fill="{nose_fill}" opacity="0.5"/>'
469
+ if ni == 2: return f'<path d="M{cx-2} {y+1} Q{cx} {y-2} {cx+2} {y+1}" fill="none" stroke="{nose_fill}" stroke-width="1" stroke-linecap="round" opacity="0.5"/>'
470
+ if ni == 3: return f'<circle cx="{cx-1.8}" cy="{y}" r="1.2" fill="{nose_fill}" opacity="0.4"/><circle cx="{cx+1.8}" cy="{y}" r="1.2" fill="{nose_fill}" opacity="0.4"/>'
471
+ return f'<ellipse cx="{cx}" cy="{y}" rx="2" ry="1.2" fill="{nose_fill}" opacity="0.35"/>'
472
+
473
+
474
+ def _render_mouth(mi: int, lip_color: str, is_dark: bool) -> str:
475
+ cx, y = 32, 45
476
+ tc = "#e8e0d8" if is_dark else "#ffffff"
477
+ if mi == 0: return f'<path d="M{cx-4} {y} Q{cx} {y+4} {cx+4} {y}" fill="none" stroke="{lip_color}" stroke-width="1.4" stroke-linecap="round"/>'
478
+ if mi == 1: return f'<line x1="{cx-3}" y1="{y+1}" x2="{cx+3}" y2="{y+1}" stroke="{lip_color}" stroke-width="1.2" stroke-linecap="round"/>'
479
+ if mi == 2: return f'<path d="M{cx-5} {y} Q{cx} {y+5} {cx+5} {y}" fill="none" stroke="{lip_color}" stroke-width="1.5" stroke-linecap="round"/>'
480
+ if mi == 3: return f'<ellipse cx="{cx}" cy="{y+1}" rx="2.5" ry="3" fill="{lip_color}" opacity="0.7"/>'
481
+ if mi == 4: return f'<path d="M{cx-4} {y+1} Q{cx+1} {y+1} {cx+4} {y-1.5}" fill="none" stroke="{lip_color}" stroke-width="1.3" stroke-linecap="round"/>'
482
+ if mi == 5:
483
+ return (f'<path d="M{cx-5} {y} Q{cx} {y+6} {cx+5} {y}" fill="{tc}" stroke="{lip_color}" stroke-width="1"/>'
484
+ f'<line x1="{cx-4}" y1="{y+1.5}" x2="{cx+4}" y2="{y+1.5}" stroke="{lip_color}" stroke-width="0.3" opacity="0.3"/>')
485
+ if mi == 6: return f'<line x1="{cx-4}" y1="{y+1}" x2="{cx+4}" y2="{y+1}" stroke="{lip_color}" stroke-width="1.5" stroke-linecap="round"/>'
486
+ if mi == 7:
487
+ return (f'<ellipse cx="{cx}" cy="{y+1}" rx="3.5" ry="2" fill="{lip_color}" opacity="0.25"/>'
488
+ f'<path d="M{cx-3} {y} Q{cx} {y+2.5} {cx+3} {y}" fill="none" stroke="{lip_color}" stroke-width="1.2" stroke-linecap="round"/>')
489
+ return f'<path d="M{cx-4} {y} Q{cx} {y+4} {cx+4} {y}" fill="none" stroke="{lip_color}" stroke-width="1.4" stroke-linecap="round"/>'
490
+
491
+
492
+ def _render_accessory(ai: int, acc_color: str, glasses_color: str, earring_color: str, headband_color: str) -> str:
493
+ if ai == 0: return ""
494
+ if ai == 1: return '<circle cx="40" cy="44" r="0.8" fill="#3a2a2a"/>'
495
+ if ai == 2:
496
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1">'
497
+ '<circle cx="25" cy="33" r="5.5"/><circle cx="39" cy="33" r="5.5"/>'
498
+ '<line x1="30.5" y1="33" x2="33.5" y2="33"/>'
499
+ '<line x1="19.5" y1="33" x2="14" y2="31"/>'
500
+ '<line x1="44.5" y1="33" x2="50" y2="31"/></g>')
501
+ if ai == 3:
502
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1">'
503
+ '<rect x="19" y="29" width="12" height="8" rx="1.5"/>'
504
+ '<rect x="33" y="29" width="12" height="8" rx="1.5"/>'
505
+ '<line x1="31" y1="33" x2="33" y2="33"/>'
506
+ '<line x1="19" y1="33" x2="14" y2="31"/>'
507
+ '<line x1="45" y1="33" x2="50" y2="31"/></g>')
508
+ if ai == 4:
509
+ return (f'<circle cx="10" cy="38" r="1.5" fill="{earring_color}"/>'
510
+ f'<circle cx="10" cy="41" r="2" fill="{earring_color}" opacity="0.8"/>')
511
+ if ai == 5:
512
+ return f'<rect x="13" y="20" width="38" height="3.5" rx="1.5" fill="{headband_color}" opacity="0.85"/>'
513
+ if ai == 6:
514
+ return ('<g fill="#a0785a" opacity="0.35">'
515
+ '<circle cx="21" cy="40" r="0.6"/><circle cx="23" cy="42" r="0.5"/>'
516
+ '<circle cx="19" cy="41.5" r="0.5"/><circle cx="43" cy="40" r="0.6"/>'
517
+ '<circle cx="41" cy="42" r="0.5"/><circle cx="45" cy="41.5" r="0.5"/></g>')
518
+ if ai == 7:
519
+ return (f'<circle cx="10" cy="37" r="1.2" fill="{earring_color}"/>'
520
+ f'<circle cx="54" cy="37" r="1.2" fill="{earring_color}"/>')
521
+ if ai == 8:
522
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1.2">'
523
+ f'<path d="M19 30 Q19 28 25 28 Q31 28 31 33 Q31 38 25 38 Q19 38 19 33 Z" fill="{glasses_color}" fill-opacity="0.15"/>'
524
+ f'<path d="M33 30 Q33 28 39 28 Q45 28 45 33 Q45 38 39 38 Q33 38 33 33 Z" fill="{glasses_color}" fill-opacity="0.15"/>'
525
+ '<line x1="31" y1="32" x2="33" y2="32"/>'
526
+ '<line x1="19" y1="31" x2="14" y2="29"/>'
527
+ '<line x1="45" y1="31" x2="50" y2="29"/></g>')
528
+ if ai == 9:
529
+ return ('<g>'
530
+ '<rect x="38" y="38" width="8" height="4" rx="1" fill="#f0d0a0" transform="rotate(-15 42 40)"/>'
531
+ '<line x1="40" y1="39" x2="40" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/>'
532
+ '<line x1="42" y1="39" x2="42" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/>'
533
+ '<line x1="44" y1="39" x2="44" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/></g>')
251
534
  return ""
252
535
 
253
536
 
254
537
  def render_svg(
255
538
  wallet_address: str,
256
539
  size: int = 64,
257
- bg_opacity: float = 0.15,
258
- bg_radius: int = 4,
259
- mouth_color: str = "#c05050",
260
- eyebrow_color: str = "#2a2020",
261
- accessory_color: str = "#444",
262
- eye_white_color: str = "white",
263
- nose_color: Optional[str] = None,
264
- color_overrides: Optional[Dict[str, str]] = None,
540
+ flat: bool = False,
541
+ detail: str = "auto",
265
542
  ) -> str:
266
- """
267
- Render a SolFace as an SVG string.
268
- Produces identical output to the JavaScript version.
269
-
270
- Args:
271
- color_overrides: Optional dict with keys: skin, eyes, hair, bg,
272
- mouth, eyebrow, accessory, nose, eye_white
273
- """
274
543
  t = generate_traits(wallet_address)
275
- co = color_overrides or {}
276
-
277
- skin = co.get("skin") or SKIN_COLORS[t.skin_color % len(SKIN_COLORS)]
278
- eye_col = co.get("eyes") or EYE_COLORS[t.eye_color % len(EYE_COLORS)]
279
- hair_col = co.get("hair") or HAIR_COLORS[t.hair_color % len(HAIR_COLORS)]
280
- bg_col = co.get("bg") or BG_COLORS[t.bg_color % len(BG_COLORS)]
281
- mouth_col = co.get("mouth") or mouth_color
282
- brow_col = co.get("eyebrow") or eyebrow_color
283
- acc_col = co.get("accessory") or accessory_color
284
- eye_white = co.get("eye_white") or eye_white_color
285
- nose_col = co.get("nose") or nose_color
286
-
287
- parts = [
288
- f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="{size}" height="{size}">',
289
- f'<rect x="0" y="0" width="64" height="64" fill="{bg_col}" opacity="{bg_opacity}" rx="{bg_radius}"/>',
290
- _render_hair(t, hair_col),
291
- _render_face(t, skin),
292
- _render_eyes(t, eye_col, eye_white),
293
- _render_eyebrows(t, brow_col),
294
- _render_nose(t, skin, nose_col),
295
- _render_mouth(t, mouth_col, eye_white),
296
- _render_accessory(t, acc_col),
297
- "</svg>",
298
- ]
544
+
545
+ full = detail == "full" or (detail == "auto" and size >= 48)
546
+ skin = SKIN_COLORS[t.skin_color % len(SKIN_COLORS)]
547
+ eye_col = EYE_COLORS[t.eye_color % len(EYE_COLORS)]
548
+ hair_col = HAIR_COLORS[t.hair_color % len(HAIR_COLORS)]
549
+ bg_col = BG_COLORS[t.bg_color % len(BG_COLORS)]
550
+
551
+ derived = derive_skin_colors(skin)
552
+ gid = "sf" + _to_base36(_djb2(wallet_address))
553
+
554
+ hi = t.hair_style % 10
555
+ ai = effective_accessory(t)
556
+
557
+ glasses_color = "#4a4a5a"
558
+ earring_color = blend(skin, "#d4a840", 0.4)
559
+ headband_color = blend(hair_col, "#c04040", 0.5)
560
+
561
+ bg_fill = bg_col if flat else f"url(#{gid}bg)"
562
+
563
+ parts = []
564
+ parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="{size}" height="{size}">')
565
+ parts.append(_build_defs(gid, skin, derived["skin_hi"], derived["skin_lo"],
566
+ hair_col, bg_col, derived["cheek_color"],
567
+ derived["cheek_opacity"], flat, full))
568
+ parts.append(f'<rect x="0" y="0" width="64" height="64" fill="{bg_fill}" opacity="1" rx="4"/>')
569
+ parts.append(_render_hair_back(hi, gid, flat))
570
+ parts.append(_render_ears(derived["ear_fill"], derived["ear_shadow"]))
571
+ parts.append(_render_face(gid, skin, flat))
572
+ if full:
573
+ parts.append(_render_face_overlays(gid))
574
+ if ai == 5:
575
+ parts.append(_render_accessory(5, derived["acc_color"], glasses_color, earring_color, headband_color))
576
+ parts.append(_render_hair_front(hi, gid, hair_col, skin, flat))
577
+ parts.append(_render_eyes(t.eye_style % 8, eye_col, derived["eye_white"], derived["lid_color"], full))
578
+ parts.append(_render_eyebrows(t.eyebrows % 5, derived["brow_color"]))
579
+ parts.append(_render_nose(t.nose % 4, derived["nose_fill"]))
580
+ parts.append(_render_mouth(t.mouth % 8, derived["lip_color"], derived["is_dark"]))
581
+ if ai != 0 and ai != 5:
582
+ parts.append(_render_accessory(ai, derived["acc_color"], glasses_color, earring_color, headband_color))
583
+ parts.append("</svg>")
299
584
  return "".join(p for p in parts if p)
300
585
 
301
586
 
302
587
  def render_data_uri(wallet_address: str, **kwargs) -> str:
303
- """Render as a data URI for use in <img> tags or HTML emails."""
304
588
  from urllib.parse import quote
305
589
  svg = render_svg(wallet_address, **kwargs)
306
590
  return f"data:image/svg+xml;charset=utf-8,{quote(svg)}"
@@ -308,40 +592,38 @@ def render_data_uri(wallet_address: str, **kwargs) -> str:
308
592
 
309
593
  # ─── Description ──────────────────────────────────────────────
310
594
 
311
- _FACE_DESC = {0: "round", 1: "square with softly rounded corners", 2: "oval", 3: "angular, hexagonal"}
312
- _SKIN_DESC = {0: "light peach", 1: "warm tan", 2: "golden brown", 3: "medium brown", 4: "deep brown", 5: "rich dark brown"}
313
- _EYE_STYLE_DESC = {0: "round, wide-open", 1: "small and dot-like", 2: "almond-shaped", 3: "wide and expressive", 4: "sleepy, half-lidded", 5: "playfully winking", 6: "adorned with lashes", 7: "narrow and observant"}
314
- _EYE_COLOR_DESC = {0: "dark brown", 1: "blue", 2: "green", 3: "amber", 4: "gray"}
315
- _BROW_DESC = {0: "", 1: "thin", 2: "thick, prominent", 3: "elegantly arched", 4: "sharply angled"}
316
- _NOSE_DESC = {0: "", 1: "a small dot nose", 2: "a triangular nose", 3: "a button nose with visible nostrils"}
317
- _HAIR_STYLE_DESC = {0: "bald, with no hair", 1: "short, neatly cropped hair", 2: "tall, spiky hair", 3: "side-swept hair", 4: "a bold mohawk", 5: "long hair that falls past the shoulders", 6: "a clean bob cut", 7: "a close buzz cut"}
318
- _HAIR_COLOR_DESC = {0: "jet black", 1: "brown", 2: "blonde", 3: "ginger red", 4: "neon lime green", 5: "neon blue", 6: "Solana mint green", 7: "neon magenta"}
319
- _MOUTH_DESC = {0: "a gentle smile", 1: "a neutral, straight expression", 2: "a wide grin", 3: "a small, open mouth", 4: "a confident smirk", 5: "a broad, toothy smile"}
320
- _ACC_DESC = {0: "", 1: "", 2: "round glasses", 3: "square-framed glasses", 4: "a gold earring", 5: "a red bandana"}
321
- _BG_DESC = {0: "lime green", 1: "blue", 2: "Solana mint green", 3: "warm sand", 4: "red"}
322
-
323
-
324
- def describe_appearance(
325
- wallet_address: str,
595
+ _SKIN_DESC = {0: "porcelain", 1: "ivory", 2: "fair", 3: "light", 4: "sand",
596
+ 5: "golden", 6: "warm", 7: "caramel", 8: "brown", 9: "deep"}
597
+ _EYE_STYLE_DESC = {0: "round, wide-open", 1: "small and minimal", 2: "almond-shaped",
598
+ 3: "wide and expressive", 4: "relaxed, half-lidded", 5: "joyful, crescent-shaped",
599
+ 6: "bright and sparkling", 7: "gentle and narrow"}
600
+ _EYE_COLOR_DESC = {0: "dark brown", 1: "blue", 2: "green", 3: "hazel", 4: "gray"}
601
+ _BROW_DESC = {0: "wispy", 1: "straight", 2: "natural", 3: "elegantly arched", 4: "sharply angled"}
602
+ _NOSE_DESC = {0: "a subtle shadow nose", 1: "a small button nose", 2: "a soft curved nose",
603
+ 3: "a button nose with visible nostrils"}
604
+ _MOUTH_DESC = {0: "a gentle smile", 1: "a calm, neutral expression", 2: "a happy grin",
605
+ 3: "a surprised O-shaped mouth", 4: "a confident smirk", 5: "a wide, toothy grin",
606
+ 6: "a flat, straight expression", 7: "a soft pout"}
607
+ _HAIR_STYLE_DESC = {0: "bald, with no hair", 1: "short, neatly cropped hair", 2: "bouncy, curly hair",
608
+ 3: "side-swept hair", 4: "a voluminous puff",
609
+ 5: "long hair that falls past the shoulders", 6: "a clean bob cut",
610
+ 7: "a close buzz cut", 8: "flowing, wavy hair", 9: "a neat topknot"}
611
+ _HAIR_COLOR_DESC = {0: "jet black", 1: "espresso brown", 2: "walnut", 3: "honey blonde",
612
+ 4: "copper red", 5: "silver", 6: "charcoal", 7: "burgundy",
613
+ 8: "strawberry", 9: "ginger"}
614
+ _ACC_DESC = {0: "", 1: "a beauty mark", 2: "round glasses", 3: "rectangular glasses",
615
+ 4: "a dangling earring", 5: "a headband", 6: "freckles", 7: "stud earrings",
616
+ 8: "aviator sunglasses", 9: "a band-aid"}
617
+ _BG_DESC = {0: "rose", 1: "olive", 2: "sage", 3: "fern", 4: "mint",
618
+ 5: "ocean", 6: "sky", 7: "lavender", 8: "orchid", 9: "blush"}
619
+
620
+
621
+ def _build_paragraph(
622
+ t, ai: int,
326
623
  perspective: str = "third",
327
624
  name: Optional[str] = None,
328
625
  include_background: bool = True,
329
626
  ) -> str:
330
- """
331
- Generate natural language description of a SolFace.
332
-
333
- Args:
334
- wallet_address: Solana wallet address
335
- perspective: "first" for agent self-description, "third" for external
336
- name: Optional name (e.g., agent name)
337
- include_background: Include background color in description
338
-
339
- Returns:
340
- Human-readable appearance description
341
- """
342
- t = generate_traits(wallet_address)
343
-
344
- # Subject intro
345
627
  if perspective == "first":
346
628
  subj = f"I'm {name}. I have" if name else "I have"
347
629
  im = "I'm"
@@ -349,27 +631,19 @@ def describe_appearance(
349
631
  subj = f"{name} has" if name else "This SolFace has"
350
632
  im = "They're"
351
633
 
352
- # Build parts list
353
634
  parts = []
635
+ parts.append(f"{subj} a squircle face with {_SKIN_DESC.get(t.skin_color, 'warm')} skin")
354
636
 
355
- # Face + skin
356
- face = _FACE_DESC.get(t.face_shape, "round")
357
- skin = _SKIN_DESC.get(t.skin_color, "warm")
358
- parts.append(f"{subj} a {face} face with {skin} skin")
359
-
360
- # Eyes
361
637
  eye_s = _EYE_STYLE_DESC.get(t.eye_style, "round")
362
638
  eye_c = _EYE_COLOR_DESC.get(t.eye_color, "dark")
363
639
  parts.append(f"{eye_s} {eye_c} eyes")
364
640
 
365
- # Eyebrows
366
641
  brows = _BROW_DESC.get(t.eyebrows, "")
367
642
  if brows:
368
643
  parts.append(f"{brows} eyebrows")
369
644
 
370
- # Hair
371
645
  if t.hair_style == 0:
372
- parts.append("and is bald")
646
+ parts.append("and am bald" if perspective == "first" else "and is bald")
373
647
  else:
374
648
  hc = _HAIR_COLOR_DESC.get(t.hair_color, "")
375
649
  hs = _HAIR_STYLE_DESC.get(t.hair_style, "")
@@ -378,7 +652,6 @@ def describe_appearance(
378
652
  else:
379
653
  parts.append(f"and {hc} {hs}")
380
654
 
381
- # Assemble main sentence
382
655
  desc = parts[0]
383
656
  if len(parts) > 2:
384
657
  desc += ", " + ", ".join(parts[1:-1]) + ", " + parts[-1]
@@ -386,29 +659,19 @@ def describe_appearance(
386
659
  desc += " and " + parts[1]
387
660
  desc += "."
388
661
 
389
- # Nose
390
662
  nose = _NOSE_DESC.get(t.nose, "")
391
663
  if nose:
392
- if perspective == "first":
393
- nose_subj = "I have"
394
- else:
395
- nose_subj = f"{name} has" if name else "They have"
664
+ nose_subj = "I have" if perspective == "first" else (f"{name} has" if name else "They have")
396
665
  desc += f" {nose_subj} {nose}."
397
666
 
398
- # Accessory
399
- acc = _ACC_DESC.get(t.accessory, "")
667
+ acc = _ACC_DESC.get(ai, "")
400
668
  if acc:
401
669
  desc += f" {im} wearing {acc}."
402
670
 
403
- # Mouth
404
671
  mouth = _MOUTH_DESC.get(t.mouth, "a smile")
405
- if perspective == "first":
406
- mouth_subj = "I have"
407
- else:
408
- mouth_subj = f"{name} has" if name else "They have"
672
+ mouth_subj = "I have" if perspective == "first" else (f"{name} has" if name else "They have")
409
673
  desc += f" {mouth_subj} {mouth}."
410
674
 
411
- # Background
412
675
  if include_background:
413
676
  bg = _BG_DESC.get(t.bg_color, "colorful")
414
677
  desc += f" The background is {bg}."
@@ -416,26 +679,354 @@ def describe_appearance(
416
679
  return desc
417
680
 
418
681
 
419
- def agent_appearance_prompt(wallet_address: str, agent_name: Optional[str] = None) -> str:
420
- """
421
- Generate a system prompt snippet describing an AI agent's SolFace.
682
+ def _build_structured(t, ai: int, include_background: bool = True) -> str:
683
+ lines = [
684
+ "Face: squircle",
685
+ f"Skin: {_SKIN_DESC.get(t.skin_color, 'warm')}",
686
+ f"Eyes: {_EYE_STYLE_DESC.get(t.eye_style, 'round')}, {_EYE_COLOR_DESC.get(t.eye_color, 'dark')}",
687
+ f"Eyebrows: {_BROW_DESC.get(t.eyebrows, 'wispy')}",
688
+ ]
689
+ nose = _NOSE_DESC.get(t.nose, "")
690
+ if nose:
691
+ lines.append(f"Nose: {nose.lstrip('a ')}")
692
+ lines.append(f"Mouth: {_MOUTH_DESC.get(t.mouth, 'smile')}")
693
+ if t.hair_style == 0:
694
+ lines.append("Hair: bald")
695
+ else:
696
+ hs = _HAIR_STYLE_DESC.get(t.hair_style, "")
697
+ hc = _HAIR_COLOR_DESC.get(t.hair_color, "")
698
+ lines.append(f"Hair: {hc} {hs[2:] if hs.startswith('a ') else hs}")
699
+ acc = _ACC_DESC.get(ai, "")
700
+ if acc:
701
+ lines.append(f"Accessory: {acc}")
702
+ if include_background:
703
+ lines.append(f"Background: {_BG_DESC.get(t.bg_color, 'colorful')}")
704
+ return "\n".join(lines)
422
705
 
423
- Usage:
424
- prompt = agent_appearance_prompt("7xKXq...", "Atlas")
425
- system_prompt = f"You are Atlas, an AI agent. {prompt}"
426
- """
706
+
707
+ def _build_compact(t, ai: int) -> str:
708
+ parts = []
709
+ parts.append("squircle face")
710
+ parts.append(f"{_SKIN_DESC.get(t.skin_color, 'warm')} skin")
711
+ parts.append(f"{_EYE_COLOR_DESC.get(t.eye_color, 'dark')} {_EYE_STYLE_DESC.get(t.eye_style, 'round')} eyes")
712
+ if t.hair_style == 0:
713
+ parts.append("bald")
714
+ else:
715
+ raw = _HAIR_STYLE_DESC.get(t.hair_style, "hair")
716
+ hs = raw.split(",")[-1].strip() if "," in raw else raw
717
+ hc = _HAIR_COLOR_DESC.get(t.hair_color, "")
718
+ parts.append(f"{hc} {hs[2:] if hs.startswith('a ') else hs}")
719
+ acc = _ACC_DESC.get(ai, "")
720
+ if acc:
721
+ parts.append(acc)
722
+ mouth = _MOUTH_DESC.get(t.mouth, "smiling")
723
+ if mouth.startswith("a "):
724
+ mouth = mouth[2:]
725
+ parts.append(mouth)
726
+ return ", ".join(parts)
727
+
728
+
729
+ def describe_appearance(
730
+ wallet_address: str,
731
+ format: str = "paragraph",
732
+ perspective: str = "third",
733
+ name: Optional[str] = None,
734
+ include_background: bool = True,
735
+ ) -> str:
736
+ t = generate_traits(wallet_address)
737
+ ai = effective_accessory(t)
738
+ if format == "structured":
739
+ return _build_structured(t, ai, include_background)
740
+ if format == "compact":
741
+ return _build_compact(t, ai)
742
+ return _build_paragraph(t, ai, perspective, name, include_background)
743
+
744
+
745
+ def sol_face_alt_text(wallet_address: str) -> str:
746
+ return f"SolFace avatar: {describe_appearance(wallet_address, format='compact', include_background=False)}"
747
+
748
+
749
+ def agent_appearance_prompt(wallet_address: str, agent_name: Optional[str] = None) -> str:
427
750
  desc = describe_appearance(wallet_address, perspective="first", name=agent_name, include_background=False)
428
751
  h = trait_hash(wallet_address)
429
752
  return f"My visual identity is a SolFace avatar (ID: {h}) derived from my wallet address. {desc} This appearance is deterministic — anyone who looks up my wallet will see the same face."
430
753
 
431
754
 
755
+
756
+ # ─── SolNames v1 — Deterministic Name Derivation ────────────────
757
+
758
+ SOLNAMES_VERSION = "v1"
759
+
760
+ ADJECTIVES = [
761
+ "Frost","Solar","Coral","Amber","Mossy","Lunar","Misty","Alpine","Autumn","Breeze",
762
+ "Cerulean","Cloudy","Cosmic","Crystal","Dewy","Dusty","Earthy","Floral","Forest","Frozen",
763
+ "Glacial","Golden","Grassy","Harbor","Ivory","Jasmine","Lagoon","Leafy","Lush","Marine",
764
+ "Meadow","Mineral","Mosaic","Nectar","Nordic","Oceanic","Orchid","Pearly","Petal","Polar",
765
+ "Prairie","Rain","River","Rocky","Sandy","Savanna","Shore","Sierra","Snowy","Spring",
766
+ "Starry","Stone","Summer","Sunset","Tidal","Timber","Tropic","Tundra","Valley","Verdant",
767
+ "Winding","Winter","Woody","Bloom","Blossom","Brook","Canyon","Cedar","Cloud","Comet",
768
+ "Copper","Dawn","Delta","Dune","Eclipse","Fern","Flame","Flora","Glade","Azure",
769
+ "Cobalt","Crimson","Indigo","Scarlet","Sapphire","Emerald","Ruby","Garnet","Onyx","Jade",
770
+ "Turquoise","Magenta","Plum","Russet","Tawny","Burnt","Gilded","Platinum","Bronze","Chrome",
771
+ "Pearl","Opal","Blush","Rosy","Dusky","Inky","Ashen","Cream","Slate","Charcoal",
772
+ "Steel","Pewter","Honey","Saffron","Citrus","Lemon","Tangerine","Peach","Apricot","Vermilion",
773
+ "Mauve","Lilac","Periwinkle","Cerise","Maroon","Burgundy","Wine","Berry","Cherry","Mint",
774
+ "Olive","Teal","Navy","Cyan","Neon","Pastel","Muted","Lustrous","Gleaming","Glossy",
775
+ "Shining","Glowing","Luminous","Brilliant","Sparkling","Shimmering","Iridescent","Prismatic","Spectral","Twilight",
776
+ "Sunrise","Sunlit","Moonlit","Starlit","Candlelit","Firelit","Sunbeam","Afterglow","Halo","Flicker",
777
+ "Glow","Ray","Beam","Blaze","Glint","Spark","Arc","Prism","Spectrum","Rainbow",
778
+ "Aurora","Nebula","Stellar","Astral","Lucent","Frosted","Swift","Bold","Keen","Brave",
779
+ "Noble","Serene","Fierce","Gentle","Agile","Alert","Astute","Candid","Clever","Daring",
780
+ "Eager","Earnest","Fair","Gallant","Graceful","Hardy","Honest","Humble","Jovial","Joyful",
781
+ "Kind","Loyal","Merry","Mindful","Nimble","Patient","Plucky","Proud","Quick","Ready",
782
+ "Resilient","Savvy","Sincere","Skilled","Steady","Stout","Strong","Sure","Tender","True",
783
+ "Valiant","Willing","Wise","Witty","Worthy","Zealous","Active","Adept","Ardent","Avid",
784
+ "Benign","Bright","Civil","Clean","Clear","Composed","Content","Courtly","Crafty","Curious",
785
+ "Decent","Devout","Diligent","Direct","Driven","Dynamic","Elegant","Elite","Even","Exact",
786
+ "Famed","Fervent","Fine","Firm","Fit","Fluid","Focal","Fond","Frank","Free",
787
+ "Fresh","Friendly","Frugal","Gifted","Glad","Grand","Great","Green","Grounded","Hale",
788
+ "Happy","Hearty","Heroic","Ideal","Infinite","Inner","Intact","Intent","Just","Lively",
789
+ "Lucky","Major","Mellow","Mighty","Modest","Natural","Neat","Optimal","Original","Pacific",
790
+ "Peaceful","Pious","Pleased","Poised","Polite","Popular","Potent","Precious","Premier","Prime",
791
+ "Proper","Proven","Pure","Quiet","Rapid","Rare","Real","Regal","Rich","Rising",
792
+ "Robust","Royal","Sacred","Safe","Scenic","Secure","Senior","Sharp","Simple","Smart",
793
+ "Smooth","Snug","Social","Solid","Sound","Special","Stable","Stately","Still","Super",
794
+ "Supreme","Sweet","Thorough","Tidy","Top","Total","Tough","Trim","Trusted","Ultimate",
795
+ "Unique","United","Upper","Useful","Valid","Valued","Varied","Vital","Vivid","Vocal",
796
+ "Whole","Wide","Young","Zesty","Iron","Gold","Silver","Marble","Granite","Velvet",
797
+ "Satin","Silk","Linen","Cotton","Suede","Denim","Canvas","Leather","Wooden","Bamboo",
798
+ "Wicker","Woven","Braided","Knit","Lace","Mesh","Fiber","Glass","Mirror","Diamond",
799
+ "Flint","Basalt","Obsidian","Chalk","Clay","Gravel","Pebble","Cobble","Brick","Tile",
800
+ "Resin","Fossil","Shell","Bone","Feather","Plush","Brushed","Polished","Hammered","Forged",
801
+ "Cast","Molten","Tempered","Etched","Carved","Sculpted","Spun","Pressed","Rolled","Folded",
802
+ "Layered","Stacked","Ribbed","Matte","Lacquered","Enameled","Glazed","Painted","Dyed","Stained",
803
+ "Refined","Distilled","Purified","Filtered","Alloyed","Plated","Coated","Sealed","Bonded","Fused",
804
+ "Welded","Riveted","Textured","Grained","Veined","Flecked","Speckled","Dappled","Streaked","Banded",
805
+ "Striped","Checkered","Waxed","Oiled","Cured","Tanned","Smoked","Burnished","Antiqued","Patina",
806
+ "Weathered","Aged","Rustic","Hewn","Vast","Broad","Deep","High","Tall","Giant",
807
+ "Colossal","Massive","Immense","Boundless","Endless","Sweeping","Spanning","Extended","Towering","Soaring",
808
+ "Lofty","Elevated","Raised","Summit","Apex","Crest","Crown","Pinnacle","Zenith","Ridge",
809
+ "Ledge","Level","Hollow","Compact","Dense","Thick","Narrow","Slender","Micro","Mini",
810
+ "Small","Ample","Hefty","Jumbo","Mega","Ultra","Macro","Central","Core","Outer",
811
+ "Lateral","Radial","Spiral","Orbital","Linear","Planar","Spherical","Rounded","Angular","Pointed",
812
+ "Tapered","Curved","Arced","Twisted","Coiled","Fractal","Cellular","Quantum","Photon","Plasma",
813
+ "Kinetic","Static","Charged","Maximal","Atomic","Nano","Tiered","Strata","Nested","Stepped",
814
+ "Graded","Scaled","Proportioned","Modular","Symmetric","Parallel","Flowing","Drifting","Gliding","Sailing",
815
+ "Floating","Climbing","Leaping","Bounding","Sprinting","Dashing","Rushing","Surging","Cascading","Rolling",
816
+ "Tumbling","Spinning","Whirling","Twisting","Swirling","Pulsing","Beating","Humming","Buzzing","Ringing",
817
+ "Chiming","Singing","Dancing","Swaying","Waving","Rippling","Bubbling","Fizzing","Crackling","Snapping",
818
+ "Clicking","Tapping","Drumming","Thrumming","Vibrant","Magnetic","Electric","Blazing","Burning","Fiery",
819
+ "Volcanic","Thermal","Warming","Cooling","Chilling","Brisk","Crisp","Breezy","Gusty","Windy",
820
+ "Airy","Light","Buoyant","Weightless","Fleet","Peppy","Zippy","Snappy","Speedy","Sonic",
821
+ "Turbo","Warp","Express","Instant","Darting","Flying","Jetting","Cruising","Coasting","Rhythmic",
822
+ "Cycling","Pacing","Striding","Marching","Trotting","Galloping","Bouncing","Vaulting","Arching","Launched",
823
+ "Propelled","Hovering","Orbiting","Revolving","Rotating","Pivoting","Swinging","Rocking","Tilting","Shifting",
824
+ "Sliding","Skating","Surfing","Diving","Plunging","Dipping","Wading","Celestial","Ethereal","Mystic",
825
+ "Arcane","Ancient","Eternal","Timeless","Ageless","Enduring","Lasting","Perpetual","Legendary","Mythic",
826
+ "Epic","Fabled","Storied","Historic","Classic","Vintage","Retro","Modern","Future","Digital",
827
+ "Cyber","Virtual","Pixel","Binary","Omega","Alpha","Beta","Gamma","Sigma","Theta",
828
+ "Lambda","Kappa","Zeta","Epsilon","Omni","Dual","Triple","Nexus","Vertex","Vector",
829
+ "Matrix","Cipher","Enigma","Phantom","Mirage","Dream","Vision","Oracle","Prophet","Sage",
830
+ "Shaman","Druid","Artisan","Maestro","Virtuoso","Savant","Prodigy","Maven","Guru","Mentor",
831
+ "Guide","Scout","Pioneer","Voyager","Wanderer","Nomad","Rover","Ranger","Sentinel","Guardian",
832
+ "Keeper","Warden","Champion","Vanguard","Herald","Emissary","Envoy","Ambassador","Steward","Curator",
833
+ "Patron","Benefactor","Founder","Architect","Builder","Maker","Crafter","Weaver","Forger","Smith",
834
+ "Wright","Mason","Brewer","Baker","Tanner","Dyer","Scribe","Bard","Minstrel","Troubadour",
835
+ "Storyteller","Chronicler","Lorekeeper","Archivist","Tranquil","Placid","Hushed","Muffled","Soft","Subtle",
836
+ "Faint","Dim","Pale","Warm","Cool","Taut","Tense","Cozy","Homey","Pastoral",
837
+ "Sylvan","Bucolic","Idyllic","Quaint","Charming","Lovely","Pretty","Dapper","Natty","Chic",
838
+ "Posh","Classy","Fancy","Ornate","Lavish","Opulent","Majestic","Dignified","August","Solemn",
839
+ "Sober","Heartfelt","Genuine","Authentic","Novel","Singular","Distinct","Chosen","Select","Premium",
840
+ "Deluxe","Superior","Exquisite","Superb","Splendid","Glorious","Sublime","Resonant","Harmonic","Melodic",
841
+ "Lyrical","Poetic","Seamless","Effortless","Organic","Primal","Devoted","Dedicated","Committed","Focused",
842
+ "Determined","Resolute","Steadfast","Unwavering","Constant","Faithful","Reliable","Dependable","Trusty","Anchored",
843
+ "Moored","Sheltered","Protected","Guarded","Shielded","Fortified","Reinforced","Braced","Bolstered","Supported",
844
+ "Upheld","Sustained","Preserved","Conserved","Stored","Vaulted","Locked","Secured","Fastened","Linked",
845
+ "Joined","Connected","Paired","Matched","Balanced","Aligned","Centered","Aimed","Directed","Guided",
846
+ "Steered","Piloted","Navigated","Charted","Mapped","Tracked","Pursued","Discovered","Unveiled","Displayed",
847
+ "Presented","Granted","Bestowed","Conferred","Sapient","Sentient","Radiant","Fertile","Abundant","Bountiful",
848
+ "Plentiful","Prolific","Thriving","Flourishing","Blooming","Budding","Growing","Expanding","Advancing","Progressing",
849
+ "Evolving","Maturing","Ripening","Developing","Emerging","Nascent","Incipient","Dawning","Unfolding","Awakening",
850
+ "Stirring","Kindling","Igniting","Sparking","Triggering","Launching","Initiating","Pioneering","Trailblazing","Groundbreaking",
851
+ "Innovative","Inventive","Creative","Imaginative","Inspired","Visionary","Prophetic","Prescient","Insightful","Perceptive",
852
+ "Observant","Watchful","Vigilant","Attentive","Thoughtful","Considerate","Caring","Nurturing","Fostering","Cultivating",
853
+ "Tending","Pruning","Trimming","Shaping","Forming","Molding","Fashioning","Designing","Drafting","Sketching",
854
+ "Drawing","Painting","Coloring","Tinting","Shading","Blending","Mixing","Merging","Combining","Uniting",
855
+ "Fusing","Melding","Weaving","Knitting","Stitching","Sewing","Quilting","Patching","Mending","Healing",
856
+ "Restoring","Renewing","Reviving","Refreshing","Rejuvenating","Invigorating","Energizing","Empowering","Strengthening","Fortifying",
857
+ "Hardening","Tempering","Seasoning","Curing","Aging","Mellowing","Softening","Smoothing","Leveling","Planing",
858
+ "Sanding","Buffing","Burnishing","Radiating","Beaming","Lighting","Illuminating","Brightening","Clarifying","Purifying",
859
+ "Filtering","Distilling","Condensing","Concentrating","Focusing","Directing","Channeling","Terraced","Ascending","Streaming",
860
+ "Umber","Careful","Perfecting","Glittering","Twinkling","Verdure","Auroral","Boreal","Austral","Temperate",
861
+ ]
862
+
863
+ NOUNS = [
864
+ "Falcon","Hawk","Eagle","Owl","Heron","Crane","Swan","Dove","Raven","Finch",
865
+ "Robin","Wren","Lark","Jay","Ibis","Kite","Osprey","Condor","Pelican","Stork",
866
+ "Sparrow","Tern","Puffin","Parrot","Toucan","Kingfisher","Flamingo","Quail","Pheasant","Grouse",
867
+ "Oriole","Warbler","Thrush","Starling","Magpie","Swallow","Martin","Plover","Curlew","Sandpiper",
868
+ "Wolf","Fox","Bear","Stag","Elk","Moose","Bison","Lynx","Cougar","Panther",
869
+ "Jaguar","Leopard","Tiger","Lion","Cheetah","Gazelle","Antelope","Impala","Zebra","Giraffe",
870
+ "Rhino","Hippo","Otter","Beaver","Badger","Marten","Ferret","Mink","Hare","Rabbit",
871
+ "Squirrel","Chipmunk","Porcupine","Hedgehog","Armadillo","Pangolin","Lemur","Gibbon","Tamarin","Capybara",
872
+ "Chinchilla","Ocelot","Margay","Coati","Kinkajou","Tapir","Okapi","Kudu","Oryx","Chamois",
873
+ "Orca","Dolphin","Whale","Narwhal","Walrus","Seal","Manatee","Turtle","Iguana","Gecko",
874
+ "Chameleon","Newt","Salamander","Crab","Lobster","Seahorse","Starfish","Octopus","Squid","Jellyfish",
875
+ "Stingray","Barracuda","Marlin","Sailfish","Trout","Salmon","Mantis","Cricket","Firefly","Dragonfly",
876
+ "Cedar","Oak","Pine","Birch","Maple","Elm","Ash","Willow","Cypress","Redwood",
877
+ "Sequoia","Spruce","Fir","Larch","Yew","Beech","Alder","Poplar","Aspen","Walnut",
878
+ "Hickory","Teak","Mahogany","Ebony","Bamboo","Palm","Acacia","Baobab","Banyan","Olive",
879
+ "Laurel","Magnolia","Lotus","Orchid","Iris","Lily","Rose","Tulip","Daisy","Aster",
880
+ "Dahlia","Peony","Jasmine","Violet","Clover","Fern","Moss","Lichen","Ivy","Vine",
881
+ "Reed","Sage","Basil","Thyme","Mint","Dill","Fennel","Sorrel","Yarrow","Thistle",
882
+ "Heather","Bluebell","Primrose","Marigold","Sunflower","Zinnia","Pansy","Poppy","Crocus","Snowdrop",
883
+ "Foxglove","Honeysuckle","Wisteria","Hibiscus","Plumeria","Gardenia","Camellia","Begonia","Azalea","Oleander",
884
+ "Canyon","Ridge","Peak","Summit","Cliff","Bluff","Mesa","Butte","Plateau","Terrace",
885
+ "Valley","Basin","Gorge","Ravine","Gulch","Fjord","Inlet","Bay","Cove","Harbor",
886
+ "Lagoon","Reef","Atoll","Isle","Peninsula","Cape","Shoal","Bank","Ledge","Shelf",
887
+ "Dune","Desert","Steppe","Tundra","Glacier","Moraine","Geyser","Oasis","Delta","Estuary",
888
+ "Marsh","Bog","Swamp","Fen","Moor","Heath","Meadow","Prairie","Savanna","Glen",
889
+ "Dale","Dell","Vale","Hollow","Grove","Copse","Thicket","Taiga","Mangrove","Wetland",
890
+ "Cascade","Brook","Creek","Stream","River","Lake","Pond","Pool","Tarn","Loch",
891
+ "Falls","Fountain","Grotto","Cavern","Cave","Tunnel","Arch","Bridge","Ford","Pass",
892
+ "Trail","Path","Route","Caldera","Crater","Vent","Spire","Needle","Dome","Gateway",
893
+ "Portal","Threshold","Verge","Brink","Edge","Rim","Brow","Crest","Promontory","Headland",
894
+ "Spit","Tombolo","Isthmus","Strait","Narrows","Sound","Bight","Reach","Stretch","Expanse",
895
+ "Clearing","Glade","Knoll","Hillock","Mound","Tor","Crag","Scarp","Escarpment","Comet",
896
+ "Meteor","Asteroid","Nebula","Galaxy","Quasar","Pulsar","Star","Sun","Moon","Planet",
897
+ "Orbit","Eclipse","Corona","Flare","Nova","Cosmos","Void","Abyss","Ether","Zenith",
898
+ "Nadir","Horizon","Aurora","Meridian","Equinox","Solstice","Phase","Cycle","Epoch","Eon",
899
+ "Era","Dawn","Dusk","Twilight","Night","Noon","Morning","Evening","Sunset","Sunrise",
900
+ "Daybreak","Nightfall","Starlight","Moonbeam","Sunray","Skyline","Firmament","Canopy","Infinity","Vega",
901
+ "Rigel","Altair","Deneb","Sirius","Polaris","Arcturus","Antares","Lyra","Orion","Draco",
902
+ "Phoenix","Hydra","Corvus","Cygnus","Aquila","Andromeda","Pegasus","Perseus","Gemini","Centauri",
903
+ "Cassiopeia","Scorpius","Sagittarius","Capella","Procyon","Aldebaran","Betelgeuse","Spica","Prism","Quill",
904
+ "Forge","Anvil","Helm","Rune","Atlas","Compass","Anchor","Beacon","Bell","Bugle",
905
+ "Drum","Flute","Harp","Horn","Lyre","Pipe","Gong","Chime","Cymbal","Fiddle",
906
+ "Trumpet","Viola","Cello","Oboe","Lute","Mandolin","Zither","Sitar","Banjo","Lantern",
907
+ "Torch","Candle","Lamp","Globe","Lens","Scope","Mirror","Frame","Easel","Palette",
908
+ "Brush","Chisel","Mallet","Hammer","Tongs","Ladle","Kettle","Crucible","Mortar","Pestle",
909
+ "Flask","Vial","Beaker","Alembic","Furnace","Kiln","Loom","Shuttle","Bobbin","Spool",
910
+ "Thimble","Pin","Clasp","Buckle","Brooch","Pendant","Amulet","Talisman","Charm","Token",
911
+ "Coin","Medal","Badge","Shield","Banner","Flag","Pennant","Sigil","Stamp","Emblem",
912
+ "Totem","Icon","Statue","Obelisk","Monolith","Cairn","Tablet","Scroll","Tome","Codex",
913
+ "Ledger","Journal","Chronicle","Almanac","Manual","Gazette","Folio","Pamphlet","Broadsheet","Parchment",
914
+ "Vellum","Papyrus","Inkwell","Quiver","Satchel","Pouch","Casket","Coffer","Chest","Crate",
915
+ "Barrel","Quartz","Opal","Onyx","Agate","Garnet","Topaz","Beryl","Zircon","Spinel",
916
+ "Peridot","Jasper","Amethyst","Citrine","Tourmaline","Malachite","Azurite","Lapis","Pyrite","Galena",
917
+ "Mica","Talc","Gypsum","Calcite","Dolomite","Basalt","Granite","Marble","Slate","Shale",
918
+ "Sandstone","Limestone","Obsidian","Pumice","Tuff","Chert","Flint","Chalcedony","Carnelian","Sardonyx",
919
+ "Moonstone","Sunstone","Labradorite","Tanzanite","Kunzite","Rhodonite","Sodalite","Chrysocolla","Aventurine","Fluorite",
920
+ "Seraphinite","Charoite","Sugilite","Larimar","Prehnite","Danburite","Scolecite","Celestite","Amazonite","Howlite",
921
+ "Lepidolite","Storm","Thunder","Lightning","Breeze","Gale","Squall","Typhoon","Cyclone","Monsoon",
922
+ "Tempest","Zephyr","Chinook","Mistral","Sirocco","Foehn","Bora","Rain","Drizzle","Shower",
923
+ "Downpour","Deluge","Flood","Torrent","Current","Tide","Wave","Swell","Surf","Spray",
924
+ "Foam","Mist","Fog","Haze","Dew","Frost","Ice","Snow","Sleet","Hail",
925
+ "Rime","Thaw","Flow","Drift","Eddy","Vortex","Whirl","Spiral","Funnel","Column",
926
+ "Plume","Wisp","Streak","Band","Front","Trough","Rainbow","Halo","Mirage","Shimmer",
927
+ "Glimmer","Tower","Turret","Bastion","Citadel","Fortress","Castle","Palace","Manor","Lodge",
928
+ "Cabin","Cottage","Villa","Ranch","Barn","Silo","Mill","Foundry","Workshop","Studio",
929
+ "Gallery","Museum","Library","Archive","Treasury","Vault","Chamber","Hall","Court","Plaza",
930
+ "Garden","Balcony","Porch","Arcade","Colonnade","Pergola","Pavilion","Gazebo","Kiosk","Chapel",
931
+ "Temple","Shrine","Monastery","Abbey","Cathedral","Basilica","Minaret","Pagoda","Stupa","Pyramid",
932
+ "Arena","Stadium","Forum","Market","Bazaar","Emporium","Depot","Station","Terminal","Dock",
933
+ "Wharf","Pier","Jetty","Quay","Marina","Lighthouse","Watchtower","Outpost","Camp","Shelter",
934
+ "Refuge","Sanctuary","Haven","Retreat","Nest","Aerie","Burrow","Den","Lair","Roost",
935
+ "Perch","Bower","Arbor","Alcove","Sextant","Astrolabe","Sundial","Hourglass","Pendulum","Gyroscope",
936
+ "Turbine","Dynamo","Generator","Piston","Valve","Lever","Pulley","Winch","Derrick","Gantry",
937
+ "Scaffold","Trellis","Lattice","Grid","Net","Web","Axle","Gear","Cog","Sprocket",
938
+ "Chain","Link","Cable","Wire","Thread","Cord","Rope","Rigging","Mast","Boom",
939
+ "Spar","Keel","Hull","Rudder","Tiller","Wheel","Chart","Map","Gauge","Meter",
940
+ "Scale","Caliper","Ruler","Level","Plumb","Protractor","Template","Stencil","Mold","Die",
941
+ "Lathe","Bellows","Retort","Condenser","Centrifuge","Spectrometer","Oscilloscope","Theodolite","Transit","Sounding",
942
+ "Probe","Sensor","Relay","Switch","Circuit","Diode","Quest","Saga","Legend","Myth",
943
+ "Fable","Ballad","Anthem","Hymn","Ode","Sonnet","Verse","Stanza","Canto","Chorus",
944
+ "Refrain","Motif","Theme","Arc","Prologue","Epilogue","Prelude","Overture","Finale","Crescendo",
945
+ "Cadence","Tempo","Rhythm","Pulse","Beat","Tone","Note","Chord","Harmony","Melody",
946
+ "Lyric","Opus","Suite","Etude","Fugue","Canon","March","Waltz","Rondo","Aria",
947
+ "Duet","Trio","Quartet","Ensemble","Guild","League","Order","Council","Assembly","Conclave",
948
+ "Synod","Quorum","Cohort","Legion","Brigade","Battalion","Regiment","Division","Corps","Fleet",
949
+ "Squadron","Patrol","Convoy","Caravan","Expedition","Voyage","Journey","Trek","Odyssey","Passage",
950
+ "Crossing","Venture","Enterprise","Mission","Campaign","Crusade","Pilgrimage","Safari","Sortie","Foray",
951
+ "Rally","Charge","Advance","Concord","Ember","Blaze","Inferno","Pyre","Spark","Cinder",
952
+ "Smoke","Vapor","Steam","Nimbus","Cumulus","Cirrus","Stratus","Billow","Puff","Wake",
953
+ "Ripple","Surge","Ebb","Flux","Gush","Rush","Spring","Well","Font","Source",
954
+ "Origin","Root","Seed","Sprout","Bud","Bloom","Petal","Leaf","Branch","Limb",
955
+ "Trunk","Bark","Grain","Fiber","Pulp","Core","Sap","Resin","Nectar","Pollen",
956
+ "Spore","Frond","Tendril","Runner","Shoot","Stalk","Stem","Thorn","Burr","Cone",
957
+ "Acorn","Pinecone","Nutshell","Keystone","Capstone","Milestone","Cornerstone","Foundation","Bedrock","Pillar",
958
+ "Buttress","Parapet","Battlement","Barbican","Gatehouse","Drawbridge","Portcullis","Moat","Stockade","Palisade",
959
+ "Bulwark","Levee","Embankment","Causeway","Aqueduct","Viaduct","Trestle","Span","Lintel","Plinth",
960
+ "Pedestal","Dais","Rostrum","Podium","Stage","Platform","Deck","Landing","Berth","Channel",
961
+ "Lock","Weir","Dam","Spillway","Flume","Chute","Sluice","Nozzle","Spout","Faucet",
962
+ "Cistern","Wellspring","Headwater","Watershed","Alluvium","Loam","Humus","Peat","Marl","Silt",
963
+ "Writ","Clamp","Eyepiece","Traverse","Stride","Parley","Char","Trace","Confluence","Rampart",
964
+ ]
965
+
966
+ BLOCKED_COMBOS = set() # Add any offensive adj+noun combinations here
967
+
968
+
969
+ def derive_name(wallet: str, fmt: str = "display") -> str:
970
+ """Derive a deterministic name from a Solana wallet address.
971
+
972
+ Args:
973
+ wallet: Base58 wallet address
974
+ fmt: Name format - "short", "display" (default), "tag", or "full"
975
+
976
+ Returns:
977
+ Formatted name string
978
+ """
979
+ identity = derive_identity(wallet)
980
+ return identity[fmt] if fmt != "display" else identity["name"]
981
+
982
+
983
+ def derive_identity(wallet: str) -> dict:
984
+ """Derive the full identity bundle for a wallet address."""
985
+ domain = f"solnames-{SOLNAMES_VERSION}:"
986
+ hash_bytes = hashlib.sha256((domain + wallet).encode("utf-8")).digest()
987
+ hex_str = hash_bytes.hex()
988
+
989
+ # Seed PRNG from first 4 bytes (big-endian unsigned)
990
+ seed = struct.unpack(">I", hash_bytes[0:4])[0]
991
+ rng = _mulberry32(seed)
992
+
993
+ # Pick first adj+noun pair, retrying if blocked
994
+ adj1 = ADJECTIVES[math.floor(rng() * len(ADJECTIVES))]
995
+ noun1 = NOUNS[math.floor(rng() * len(NOUNS))]
996
+ while (adj1 + noun1) in BLOCKED_COMBOS:
997
+ adj1 = ADJECTIVES[math.floor(rng() * len(ADJECTIVES))]
998
+ noun1 = NOUNS[math.floor(rng() * len(NOUNS))]
999
+
1000
+ # Pick second adj+noun pair for full format
1001
+ adj2 = ADJECTIVES[math.floor(rng() * len(ADJECTIVES))]
1002
+ noun2 = NOUNS[math.floor(rng() * len(NOUNS))]
1003
+
1004
+ # Discriminator from bytes 8-9
1005
+ discriminator = hex_str[16:20]
1006
+
1007
+ return {
1008
+ "short": adj1,
1009
+ "name": adj1 + noun1,
1010
+ "tag": adj1 + noun1 + "#" + discriminator,
1011
+ "full": adj1 + noun1 + "-" + adj2 + noun2,
1012
+ "adjective": adj1,
1013
+ "noun": noun1,
1014
+ "hash": hex_str,
1015
+ "discriminator": discriminator,
1016
+ }
1017
+
1018
+
1019
+ def generate_name(wallet: str, **kwargs) -> str:
1020
+ """Deprecated: use derive_name() instead. Returns display format for backward compat."""
1021
+ return derive_name(wallet, "display")
1022
+
432
1023
  # ─── CLI ──────────────────────────────────────────────────────
433
1024
 
434
1025
  if __name__ == "__main__":
435
1026
  import sys
436
1027
 
437
1028
  if len(sys.argv) < 2:
438
- print("Usage: python solfaces.py <wallet_address> [--svg] [--json] [--describe] [--size N]")
1029
+ print("Usage: python solfaces.py <wallet_address> [--svg] [--json] [--describe] [--size N] [--flat]")
439
1030
  sys.exit(1)
440
1031
 
441
1032
  wallet = sys.argv[1]
@@ -449,8 +1040,10 @@ if __name__ == "__main__":
449
1040
  sys.exit(1)
450
1041
  size = int(sys.argv[idx + 1])
451
1042
 
1043
+ use_flat = "--flat" in args
1044
+
452
1045
  if "--svg" in args:
453
- print(render_svg(wallet, size=size))
1046
+ print(render_svg(wallet, size=size, flat=use_flat))
454
1047
  elif "--json" in args:
455
1048
  import json
456
1049
  t = generate_traits(wallet)
@@ -468,8 +1061,8 @@ if __name__ == "__main__":
468
1061
  labels = get_trait_labels(t)
469
1062
  print(f"SolFace for {wallet[:8]}...{wallet[-4:]}")
470
1063
  print(f"Hash: {trait_hash(wallet)}")
471
- print(f"---")
1064
+ print("---")
472
1065
  for k, v in labels.items():
473
1066
  print(f" {k}: {v}")
474
- print(f"---")
1067
+ print("---")
475
1068
  print(describe_appearance(wallet))