solfaces 1.0.1 → 2.0.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 (69) hide show
  1. package/README.md +359 -87
  2. package/dist/agent/index.cjs +14 -14
  3. package/dist/agent/index.js +4 -4
  4. package/dist/agent/mcp-server.cjs +716 -277
  5. package/dist/{chunk-VMNATBH3.cjs → chunk-23XJ5VDX.cjs} +37 -27
  6. package/dist/chunk-23XJ5VDX.cjs.map +1 -0
  7. package/dist/chunk-46ZEFA6R.cjs +243 -0
  8. package/dist/chunk-46ZEFA6R.cjs.map +1 -0
  9. package/dist/{chunk-A6N3RPEA.cjs → chunk-546TBMAR.cjs} +6 -6
  10. package/dist/{chunk-A6N3RPEA.cjs.map → chunk-546TBMAR.cjs.map} +1 -1
  11. package/dist/chunk-6QRDULAO.cjs +191 -0
  12. package/dist/chunk-6QRDULAO.cjs.map +1 -0
  13. package/dist/chunk-6UWILY7E.cjs +647 -0
  14. package/dist/chunk-6UWILY7E.cjs.map +1 -0
  15. package/dist/chunk-DRUSCLEF.js +177 -0
  16. package/dist/chunk-DRUSCLEF.js.map +1 -0
  17. package/dist/chunk-HCEE4K4T.js +625 -0
  18. package/dist/chunk-HCEE4K4T.js.map +1 -0
  19. package/dist/chunk-JS527VKL.js +238 -0
  20. package/dist/chunk-JS527VKL.js.map +1 -0
  21. package/dist/{chunk-SNJABBAT.js → chunk-LRHYF5QN.js} +3 -3
  22. package/dist/{chunk-SNJABBAT.js.map → chunk-LRHYF5QN.js.map} +1 -1
  23. package/dist/{chunk-RX6D5FGH.js → chunk-TTGJZEPV.js} +30 -20
  24. package/dist/chunk-TTGJZEPV.js.map +1 -0
  25. package/dist/core/index.cjs +69 -29
  26. package/dist/core/index.d.cts +29 -47
  27. package/dist/core/index.d.ts +29 -47
  28. package/dist/core/index.js +3 -3
  29. package/dist/index.cjs +75 -35
  30. package/dist/index.d.cts +2 -2
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +5 -5
  33. package/dist/react/index.cjs +431 -397
  34. package/dist/react/index.cjs.map +1 -1
  35. package/dist/react/index.d.cts +3 -2
  36. package/dist/react/index.d.ts +3 -2
  37. package/dist/react/index.js +427 -393
  38. package/dist/react/index.js.map +1 -1
  39. package/dist/solfaces.cdn.global.js +2 -2
  40. package/dist/solfaces.cdn.global.js.map +1 -1
  41. package/dist/themes/index.cjs +29 -17
  42. package/dist/themes/index.d.cts +10 -7
  43. package/dist/themes/index.d.ts +10 -7
  44. package/dist/themes/index.js +1 -1
  45. package/dist/{traits-DAFZnXeS.d.cts → traits-QlWuxZDD.d.cts} +45 -1
  46. package/dist/{traits-DAFZnXeS.d.ts → traits-QlWuxZDD.d.ts} +45 -1
  47. package/dist/vanilla/index.cjs +20 -8
  48. package/dist/vanilla/index.cjs.map +1 -1
  49. package/dist/vanilla/index.d.cts +1 -1
  50. package/dist/vanilla/index.d.ts +1 -1
  51. package/dist/vanilla/index.js +17 -5
  52. package/dist/vanilla/index.js.map +1 -1
  53. package/package.json +1 -2
  54. package/python/solfaces.py +557 -235
  55. package/skill.md +210 -65
  56. package/dist/chunk-2DIKGLXZ.cjs +0 -126
  57. package/dist/chunk-2DIKGLXZ.cjs.map +0 -1
  58. package/dist/chunk-CVFO7YHY.cjs +0 -97
  59. package/dist/chunk-CVFO7YHY.cjs.map +0 -1
  60. package/dist/chunk-H3SK3MNX.cjs +0 -409
  61. package/dist/chunk-H3SK3MNX.cjs.map +0 -1
  62. package/dist/chunk-KSGFMW33.js +0 -401
  63. package/dist/chunk-KSGFMW33.js.map +0 -1
  64. package/dist/chunk-LQWJRHGC.js +0 -86
  65. package/dist/chunk-LQWJRHGC.js.map +0 -1
  66. package/dist/chunk-RX6D5FGH.js.map +0 -1
  67. package/dist/chunk-VMNATBH3.cjs.map +0 -1
  68. package/dist/chunk-WURY4QGH.js +0 -117
  69. package/dist/chunk-WURY4QGH.js.map +0 -1
@@ -1,7 +1,7 @@
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
7
  from solfaces import generate_traits, render_svg, describe_appearance
@@ -13,25 +13,26 @@ 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 math
18
19
 
19
20
 
20
21
  # ─── Types ────────────────────────────────────────────────────
21
22
 
22
23
  @dataclass
23
24
  class SolFaceTraits:
24
- face_shape: int # 0-3
25
- skin_color: int # 0-5
25
+ face_shape: int # 0-3 (consumed for PRNG ordering, all render as squircle)
26
+ skin_color: int # 0-9
26
27
  eye_style: int # 0-7
27
28
  eye_color: int # 0-4
28
29
  eyebrows: int # 0-4
29
30
  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
31
+ mouth: int # 0-7
32
+ hair_style: int # 0-9
33
+ hair_color: int # 0-9
34
+ accessory: int # 0-9
35
+ bg_color: int # 0-9
35
36
 
36
37
  def to_dict(self) -> Dict[str, int]:
37
38
  return {
@@ -51,46 +52,142 @@ class SolFaceTraits:
51
52
 
52
53
  # ─── Color Palettes ──────────────────────────────────────────
53
54
 
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"]
55
+ SKIN_COLORS = [
56
+ "#faeae5", "#efd6c8", "#e4c5aa", "#d5b590", "#c59e77",
57
+ "#b4875f", "#9d6d4d", "#805742", "#654134", "#4b2d25",
58
+ ]
59
+ EYE_COLORS = ["#382414", "#3868A8", "#38784C", "#808838", "#586878"]
60
+ HAIR_COLORS = [
61
+ "#1A1A24", "#4C3428", "#887058", "#D4B868", "#A84830",
62
+ "#C0C0CC", "#484858", "#783850", "#D8B0A0", "#C08048",
63
+ ]
64
+ BG_COLORS = [
65
+ "#b98387", "#a9a360", "#9eb785", "#69ab79", "#81bbb0",
66
+ "#6499af", "#7f8bbd", "#8869ab", "#b785b3", "#ab6984",
67
+ ]
68
+
69
+
70
+ # ─── Color Math ──────────────────────────────────────────────
71
+
72
+ def hex_to_rgb(h: str) -> tuple[int, int, int]:
73
+ h = h.lstrip("#")
74
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
75
+
76
+
77
+ def rgb_to_hex(r: float, g: float, b: float) -> str:
78
+ return "#{:02x}{:02x}{:02x}".format(
79
+ max(0, min(255, round(r))),
80
+ max(0, min(255, round(g))),
81
+ max(0, min(255, round(b))),
82
+ )
83
+
84
+
85
+ def darken(h: str, pct: float = 0.12) -> str:
86
+ r, g, b = hex_to_rgb(h)
87
+ return rgb_to_hex(r * (1 - pct), g * (1 - pct), b * (1 - pct))
88
+
89
+
90
+ def lighten(h: str, pct: float = 0.15) -> str:
91
+ r, g, b = hex_to_rgb(h)
92
+ return rgb_to_hex(r + (255 - r) * pct, g + (255 - g) * pct, b + (255 - b) * pct)
93
+
94
+
95
+ def blend(a: str, b: str, t: float = 0.5) -> str:
96
+ r1, g1, b1 = hex_to_rgb(a)
97
+ r2, g2, b2 = hex_to_rgb(b)
98
+ return rgb_to_hex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
99
+
100
+
101
+ def luminance(h: str) -> float:
102
+ r, g, b = hex_to_rgb(h)
103
+ return (r + g + b) / 3
104
+
105
+
106
+ def derive_skin_colors(skin: str) -> dict:
107
+ sL = luminance(skin)
108
+ is_dark = sL < 100
109
+
110
+ skin_hi = lighten(skin, 0.10)
111
+ skin_lo = darken(skin, 0.22)
112
+ skin_mid = darken(skin, 0.05)
113
+
114
+ sr, sg, sb = hex_to_rgb(skin)
115
+ if sL > 120:
116
+ r_b = 0.03 if sL > 180 else 0.06
117
+ g_d = 0.30 if sL > 180 else 0.28
118
+ b_d = 0.25 if sL > 180 else 0.22
119
+ cheek_color = rgb_to_hex(
120
+ min(255, sr + sr * r_b),
121
+ max(0, sg - sg * g_d),
122
+ max(0, sb - sb * b_d),
123
+ )
124
+ else:
125
+ cheek_color = rgb_to_hex(min(255, sr + 50), max(0, sg - 10), max(0, sb - 5))
126
+
127
+ cheek_opacity = 0.15 + 0.18 * (1 - min(1, sL / 240))
128
+
129
+ lip_t = max(0, min(1, (sL - 60) / 180))
130
+ lip_base = blend("#D89090", "#A83848", lip_t)
131
+ mid_boost = 1 - abs(sL - 140) / 80
132
+ lip_blend = (0.70 if is_dark else 0.62) + max(0, mid_boost) * 0.12
133
+ lip_raw = blend(skin, lip_base, min(0.82, lip_blend))
134
+ lr, lg, lb = hex_to_rgb(lip_raw)
135
+ lip_d = abs(sr - lr) + abs(sg - lg) + abs(sb - lb)
136
+ lip_color = blend(skin, lip_base, 0.78) if lip_d < 60 else lip_raw
137
+
138
+ brow_color = lighten(skin, 0.35 if sL < 80 else 0.25) if is_dark else darken(skin, 0.55)
139
+ nose_fill = lighten(skin, 0.20) if is_dark else darken(skin, 0.20)
140
+
141
+ ear_t = max(0, min(1, (sL - 100) / 60))
142
+ ear_fill = blend(lighten(skin, 0.08), skin_mid, ear_t)
143
+ ear_shadow = darken(skin, 0.10 + 0.06 * (1 - min(1, sL / 160)))
144
+ lid_color = lighten(skin, 0.18) if is_dark else darken(skin, 0.15)
145
+
146
+ ew_t = max(0, min(1, (sL - 60) / 180))
147
+ eye_white = blend("#EDE8E0", "#FBF8F2", ew_t)
148
+
149
+ warmth = 0.3 if sL > 140 else (0.5 if sL > 100 else 0.7)
150
+ acc_color = blend("#808890", blend(skin, "#B0A898", 0.3), warmth)
151
+
152
+ return {
153
+ "skin_hi": skin_hi, "skin_lo": skin_lo, "skin_mid": skin_mid,
154
+ "is_dark": is_dark, "cheek_color": cheek_color, "cheek_opacity": cheek_opacity,
155
+ "lip_color": lip_color, "nose_fill": nose_fill, "brow_color": brow_color,
156
+ "ear_fill": ear_fill, "ear_shadow": ear_shadow,
157
+ "eye_white": eye_white, "lid_color": lid_color, "acc_color": acc_color,
158
+ }
159
+
160
+
161
+ def buzz_opacity(hair_col: str, skin: str) -> float:
162
+ hr, hg, hb = hex_to_rgb(hair_col)
163
+ sr, sg, sb = hex_to_rgb(skin)
164
+ return 0.70 if abs(hr - sr) + abs(hg - sg) + abs(hb - sb) < 80 else 0.50
58
165
 
59
166
 
60
167
  # ─── Hashing (djb2) — exact JS parity ────────────────────────
61
168
 
62
169
  def _djb2(s: str) -> int:
63
- """DJB2 hash — matches JavaScript implementation exactly."""
64
170
  hash_val = 5381
65
171
  for ch in s:
66
- # Replicate JS: ((hash << 5) + hash + charCode) | 0
67
172
  hash_val = ctypes.c_int32((hash_val << 5) + hash_val + ord(ch)).value
68
- # Return unsigned 32-bit (>>> 0 in JS)
69
173
  return hash_val & 0xFFFFFFFF
70
174
 
71
175
 
72
176
  # ─── PRNG (mulberry32) — exact JS parity ─────────────────────
73
177
 
74
178
  def _mulberry32(seed: int):
75
- """Mulberry32 PRNG — matches JavaScript implementation exactly."""
76
179
  s = ctypes.c_int32(seed).value
77
180
 
78
181
  def next_val() -> float:
79
182
  nonlocal s
80
183
  s = ctypes.c_int32(s + 0x6D2B79F5).value
81
-
82
- # Math.imul(s ^ (s >>> 15), 1 | s)
83
184
  a = (s ^ ((s & 0xFFFFFFFF) >> 15)) & 0xFFFFFFFF
84
185
  b = (1 | s) & 0xFFFFFFFF
85
186
  t = ctypes.c_int32(_imul(a, b)).value
86
-
87
- # (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
88
187
  c = (t ^ ((t & 0xFFFFFFFF) >> 7)) & 0xFFFFFFFF
89
188
  d = (61 | t) & 0xFFFFFFFF
90
189
  old_t = t
91
190
  t = (ctypes.c_int32(old_t + _imul(c, d)).value) ^ old_t
92
-
93
- # ((t ^ (t >>> 14)) >>> 0) / 4294967296
94
191
  result = ((t ^ ((t & 0xFFFFFFFF) >> 14)) & 0xFFFFFFFF)
95
192
  return result / 4294967296
96
193
 
@@ -98,7 +195,6 @@ def _mulberry32(seed: int):
98
195
 
99
196
 
100
197
  def _imul(a: int, b: int) -> int:
101
- """Emulate Math.imul — 32-bit integer multiply."""
102
198
  a = a & 0xFFFFFFFF
103
199
  b = b & 0xFFFFFFFF
104
200
  result = (a * b) & 0xFFFFFFFF
@@ -110,197 +206,383 @@ def _imul(a: int, b: int) -> int:
110
206
  # ─── Trait Generation ─────────────────────────────────────────
111
207
 
112
208
  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
209
  seed = _djb2(wallet_address)
118
210
  rand = _mulberry32(seed)
119
211
 
212
+ # IMPORTANT: Order must NEVER change — shifts all downstream values.
120
213
  return SolFaceTraits(
121
214
  face_shape=int(rand() * 4),
122
- skin_color=int(rand() * 6),
215
+ skin_color=int(rand() * 10),
123
216
  eye_style=int(rand() * 8),
124
217
  eye_color=int(rand() * 5),
125
218
  eyebrows=int(rand() * 5),
126
219
  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),
220
+ mouth=int(rand() * 8),
221
+ hair_style=int(rand() * 10),
222
+ hair_color=int(rand() * 10),
223
+ accessory=int(rand() * 10),
224
+ bg_color=int(rand() * 10),
132
225
  )
133
226
 
134
227
 
228
+ def effective_accessory(t: SolFaceTraits) -> int:
229
+ ai = t.accessory % 10
230
+ hi = t.hair_style % 10
231
+ if (ai == 4 or ai == 7) and (hi == 5 or hi == 6):
232
+ return 0
233
+ return ai
234
+
235
+
135
236
  def trait_hash(wallet_address: str) -> str:
136
- """Return 8-char hex hash for cache keys."""
137
237
  return f"{_djb2(wallet_address):08x}"
138
238
 
139
239
 
140
240
  # ─── Trait Labels ─────────────────────────────────────────────
141
241
 
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."""
242
+ SKIN_LABELS = ["Porcelain", "Ivory", "Fair", "Light", "Sand",
243
+ "Golden", "Warm", "Caramel", "Brown", "Deep"]
244
+ EYE_STYLE_LABELS = ["Round", "Minimal", "Almond", "Wide", "Relaxed", "Joyful", "Bright", "Gentle"]
245
+ EYE_COLOR_LABELS = ["Chocolate", "Sky", "Emerald", "Hazel", "Storm"]
246
+ BROW_LABELS = ["Wispy", "Straight", "Natural", "Arched", "Angled"]
247
+ NOSE_LABELS = ["Shadow", "Button", "Soft", "Nostrils"]
248
+ MOUTH_LABELS = ["Smile", "Calm", "Happy", "Oh", "Smirk", "Grin", "Flat", "Pout"]
249
+ HAIR_STYLE_LABELS = ["Bald", "Short", "Curly", "Side Sweep", "Puff",
250
+ "Long", "Bob", "Buzz", "Wavy", "Topknot"]
251
+ HAIR_COLOR_LABELS = ["Black", "Espresso", "Walnut", "Honey", "Copper",
252
+ "Silver", "Charcoal", "Burgundy", "Strawberry", "Ginger"]
253
+ ACC_LABELS = ["None", "Beauty Mark", "Round Glasses", "Rect Glasses", "Earring",
254
+ "Headband", "Freckles", "Stud Earrings", "Aviators", "Band-Aid"]
255
+ BG_COLOR_LABELS = ["Rose", "Olive", "Sage", "Fern", "Mint",
256
+ "Ocean", "Sky", "Lavender", "Orchid", "Blush"]
257
+
258
+
259
+ def get_trait_labels(t: SolFaceTraits) -> Dict[str, str]:
260
+ ai = effective_accessory(t)
156
261
  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",
262
+ "faceShape": "Squircle",
263
+ "skinColor": SKIN_LABELS[t.skin_color] if t.skin_color < len(SKIN_LABELS) else "Fair",
264
+ "eyeStyle": EYE_STYLE_LABELS[t.eye_style] if t.eye_style < len(EYE_STYLE_LABELS) else "Round",
265
+ "eyeColor": EYE_COLOR_LABELS[t.eye_color] if t.eye_color < len(EYE_COLOR_LABELS) else "Chocolate",
266
+ "eyebrows": BROW_LABELS[t.eyebrows] if t.eyebrows < len(BROW_LABELS) else "Wispy",
267
+ "nose": NOSE_LABELS[t.nose] if t.nose < len(NOSE_LABELS) else "Shadow",
268
+ "mouth": MOUTH_LABELS[t.mouth] if t.mouth < len(MOUTH_LABELS) else "Smile",
269
+ "hairStyle": HAIR_STYLE_LABELS[t.hair_style] if t.hair_style < len(HAIR_STYLE_LABELS) else "Bald",
270
+ "hairColor": HAIR_COLOR_LABELS[t.hair_color] if t.hair_color < len(HAIR_COLOR_LABELS) else "Black",
271
+ "accessory": ACC_LABELS[ai] if ai < len(ACC_LABELS) else "None",
272
+ "bgColor": BG_COLOR_LABELS[t.bg_color] if t.bg_color < len(BG_COLOR_LABELS) else "Rose",
168
273
  }
169
274
 
170
275
 
171
276
  # ─── SVG Rendering ────────────────────────────────────────────
172
277
 
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"/>'
278
+ def _to_base36(n: int) -> str:
279
+ if n == 0:
280
+ return "0"
281
+ chars = "0123456789abcdefghijklmnopqrstuvwxyz"
282
+ result = ""
283
+ while n > 0:
284
+ result = chars[n % 36] + result
285
+ n //= 36
286
+ return result
287
+
288
+
289
+ def _build_defs(gid: str, skin: str, skin_hi: str, skin_lo: str,
290
+ hair_col: str, bg_col: str, cheek_color: str,
291
+ cheek_opacity: float, flat: bool, full: bool) -> str:
292
+ if flat:
293
+ return ""
294
+ d = "<defs>"
295
+ d += f'<linearGradient id="{gid}sg" x1="0" y1="0" x2="0" y2="1">'
296
+ d += f'<stop offset="0%" stop-color="{skin_hi}"/>'
297
+ d += f'<stop offset="100%" stop-color="{skin_lo}"/>'
298
+ d += "</linearGradient>"
299
+ d += f'<linearGradient id="{gid}hg" x1="0" y1="0" x2="0" y2="1">'
300
+ d += f'<stop offset="0%" stop-color="{lighten(hair_col, 0.15)}"/>'
301
+ d += f'<stop offset="100%" stop-color="{darken(hair_col, 0.15)}"/>'
302
+ d += "</linearGradient>"
303
+ d += f'<linearGradient id="{gid}bg" x1="0" y1="0" x2="1" y2="1">'
304
+ d += f'<stop offset="0%" stop-color="{lighten(bg_col, 0.12)}"/>'
305
+ d += f'<stop offset="100%" stop-color="{darken(bg_col, 0.12)}"/>'
306
+ d += "</linearGradient>"
307
+ if full:
308
+ d += f'<radialGradient id="{gid}glow" cx="0.5" cy="0.28" r="0.45">'
309
+ d += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.10"/>'
310
+ d += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>'
311
+ d += "</radialGradient>"
312
+ d += f'<radialGradient id="{gid}chin" cx="0.5" cy="0.85" r="0.35">'
313
+ d += f'<stop offset="0%" stop-color="{skin_lo}" stop-opacity="0.30"/>'
314
+ d += f'<stop offset="100%" stop-color="{skin_lo}" stop-opacity="0"/>'
315
+ d += "</radialGradient>"
316
+ d += f'<radialGradient id="{gid}cL" cx="0.5" cy="0.5" r="0.5">'
317
+ d += f'<stop offset="0%" stop-color="{cheek_color}" stop-opacity="{cheek_opacity:.2f}"/>'
318
+ d += f'<stop offset="100%" stop-color="{cheek_color}" stop-opacity="0"/>'
319
+ d += "</radialGradient>"
320
+ d += f'<radialGradient id="{gid}cR" cx="0.5" cy="0.5" r="0.5">'
321
+ d += f'<stop offset="0%" stop-color="{cheek_color}" stop-opacity="{cheek_opacity:.2f}"/>'
322
+ d += f'<stop offset="100%" stop-color="{cheek_color}" stop-opacity="0"/>'
323
+ d += "</radialGradient>"
324
+ d += "</defs>"
325
+ return d
326
+
327
+
328
+ def _render_hair_back(hi: int, gid: str, flat: bool) -> str:
329
+ fill = "currentColor" if flat else f"url(#{gid}hg)"
330
+ if hi == 5: return f'<rect x="10" y="14" width="44" height="42" rx="6" fill="{fill}"/>'
331
+ if hi == 6: return f'<rect x="12" y="14" width="40" height="32" rx="8" fill="{fill}"/>'
332
+ if hi == 8: return f'<rect x="11" y="14" width="42" height="38" rx="8" fill="{fill}"/>'
209
333
  return ""
210
334
 
211
335
 
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}"/>'
336
+ def _render_ears(ear_fill: str, ear_shadow: str) -> str:
337
+ return (
338
+ f'<ellipse cx="11" cy="34" rx="4" ry="5" fill="{ear_fill}"/>'
339
+ f'<ellipse cx="11" cy="34" rx="2.5" ry="3.5" fill="{ear_shadow}" opacity="0.3"/>'
340
+ f'<ellipse cx="53" cy="34" rx="4" ry="5" fill="{ear_fill}"/>'
341
+ f'<ellipse cx="53" cy="34" rx="2.5" ry="3.5" fill="{ear_shadow}" opacity="0.3"/>'
342
+ )
343
+
344
+
345
+ def _render_face(gid: str, skin: str, flat: bool) -> str:
346
+ fill = skin if flat else f"url(#{gid}sg)"
347
+ return f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="{fill}"/>'
348
+
349
+
350
+ def _render_face_overlays(gid: str) -> str:
351
+ return (
352
+ f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="url(#{gid}glow)"/>'
353
+ f'<rect x="14" y="16" width="36" height="38" rx="12" ry="12" fill="url(#{gid}chin)"/>'
354
+ f'<ellipse cx="22" cy="42" rx="5" ry="3.5" fill="url(#{gid}cL)"/>'
355
+ f'<ellipse cx="42" cy="42" rx="5" ry="3.5" fill="url(#{gid}cR)"/>'
356
+ '<line x1="20" y1="50" x2="44" y2="50" stroke="currentColor" stroke-width="0.3" opacity="0.08" stroke-linecap="round"/>'
357
+ )
358
+
359
+
360
+ def _render_hair_front(hi: int, gid: str, hair_col: str, skin: str, flat: bool) -> str:
361
+ fill = hair_col if flat else f"url(#{gid}hg)"
362
+ if hi == 0: return ""
363
+ 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}"/>'
364
+ if hi == 2:
365
+ return (f'<g fill="{fill}">'
366
+ '<circle cx="20" cy="14" r="5"/><circle cx="28" cy="11" r="5.5"/>'
367
+ '<circle cx="36" cy="11" r="5.5"/><circle cx="44" cy="14" r="5"/>'
368
+ '<circle cx="16" cy="20" r="4"/><circle cx="48" cy="20" r="4"/></g>')
369
+ if hi == 3:
370
+ 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}"/>'
371
+ f'<path d="M14 20 Q8 16 10 8 Q14 10 20 16 Z" fill="{fill}"/>')
372
+ if hi == 4: return f'<ellipse cx="32" cy="10" rx="14" ry="8" fill="{fill}"/>'
373
+ 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}"/>'
374
+ if hi == 6:
375
+ 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
+ f'<rect x="10" y="28" width="8" height="14" rx="4" fill="{fill}"/>'
377
+ f'<rect x="46" y="28" width="8" height="14" rx="4" fill="{fill}"/>')
378
+ if hi == 7:
379
+ bop = buzz_opacity(hair_col, skin)
380
+ return f'<rect x="15" y="13" width="34" height="16" rx="10" ry="8" fill="{hair_col}" opacity="{bop:.2f}"/>'
381
+ if hi == 8:
382
+ 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}"/>'
383
+ f'<path d="M12 30 Q10 20 14 16" fill="none" stroke="{fill}" stroke-width="4" stroke-linecap="round"/>'
384
+ f'<path d="M52 30 Q54 20 50 16" fill="none" stroke="{fill}" stroke-width="4" stroke-linecap="round"/>')
385
+ if hi == 9:
386
+ 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}"/>'
387
+ f'<ellipse cx="32" cy="6" rx="6" ry="5" fill="{fill}"/>')
219
388
  return ""
220
389
 
221
390
 
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"/>'
391
+ def _render_eyes(ei: int, eye_col: str, eye_white: str, lid_color: str, full: bool) -> str:
392
+ lx, rx, y = 25, 39, 33
393
+ s = ""
394
+ if ei == 0:
395
+ 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}"/>'
396
+ if full: s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="0.7" fill="white" opacity="0.8"/>'
397
+ 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}"/>'
398
+ if full: s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="0.7" fill="white" opacity="0.8"/>'
399
+ elif ei == 1:
400
+ s += f'<circle cx="{lx}" cy="{y}" r="2" fill="{eye_col}"/><circle cx="{rx}" cy="{y}" r="2" fill="{eye_col}"/>'
401
+ elif ei == 2:
402
+ 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}"/>'
403
+ if full: s += f'<circle cx="{lx+1.2}" cy="{y-0.8}" r="0.6" fill="white" opacity="0.7"/>'
404
+ 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}"/>'
405
+ if full: s += f'<circle cx="{rx+1.2}" cy="{y-0.8}" r="0.6" fill="white" opacity="0.7"/>'
406
+ elif ei == 3:
407
+ 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}"/>'
408
+ if full: s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="0.8" fill="white" opacity="0.8"/>'
409
+ 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}"/>'
410
+ if full: s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="0.8" fill="white" opacity="0.8"/>'
411
+ elif ei == 4:
412
+ 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}"/>'
413
+ 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"/>'
414
+ 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}"/>'
415
+ 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"/>'
416
+ elif ei == 5:
417
+ 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"/>'
418
+ 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"/>'
419
+ elif ei == 6:
420
+ 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}"/>'
421
+ s += f'<circle cx="{lx+1.5}" cy="{y-1}" r="1" fill="white" opacity="0.9"/>'
422
+ if full:
423
+ 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"/>'
424
+ 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"/>'
425
+ 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}"/>'
426
+ s += f'<circle cx="{rx+1.5}" cy="{y-1}" r="1" fill="white" opacity="0.9"/>'
427
+ if full:
428
+ 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"/>'
429
+ 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"/>'
430
+ elif ei == 7:
431
+ 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}"/>'
432
+ 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}"/>'
433
+ else:
434
+ 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}"/>'
435
+ 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}"/>'
436
+
437
+ if full and ei not in (1, 5):
438
+ 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"/>'
439
+ 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"/>'
440
+ return s
441
+
442
+
443
+ def _render_eyebrows(bi: int, brow_color: str) -> str:
444
+ lx, rx, y = 25, 39, 27
445
+ if bi == 0:
446
+ 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"/>'
447
+ f'<line x1="{rx-3}" y1="{y-0.5}" x2="{rx+3}" y2="{y}" stroke="{brow_color}" stroke-width="0.7" stroke-linecap="round"/>')
448
+ if bi == 1:
449
+ 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"/>'
450
+ f'<line x1="{rx-3.5}" y1="{y}" x2="{rx+3.5}" y2="{y}" stroke="{brow_color}" stroke-width="1.2" stroke-linecap="round"/>')
451
+ if bi == 2:
452
+ 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"/>'
453
+ 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"/>')
454
+ if bi == 3:
455
+ 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"/>'
456
+ 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"/>')
457
+ if bi == 4:
458
+ 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"/>'
459
+ 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
460
  return ""
243
461
 
244
462
 
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>'
463
+ def _render_nose(ni: int, nose_fill: str) -> str:
464
+ cx, y = 32, 39
465
+ if ni == 0: return f'<ellipse cx="{cx}" cy="{y}" rx="2" ry="1.2" fill="{nose_fill}" opacity="0.35"/>'
466
+ if ni == 1: return f'<circle cx="{cx}" cy="{y}" r="1.8" fill="{nose_fill}" opacity="0.5"/>'
467
+ 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"/>'
468
+ 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"/>'
469
+ return f'<ellipse cx="{cx}" cy="{y}" rx="2" ry="1.2" fill="{nose_fill}" opacity="0.35"/>'
470
+
471
+
472
+ def _render_mouth(mi: int, lip_color: str, is_dark: bool) -> str:
473
+ cx, y = 32, 45
474
+ tc = "#e8e0d8" if is_dark else "#ffffff"
475
+ 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"/>'
476
+ 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"/>'
477
+ 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"/>'
478
+ if mi == 3: return f'<ellipse cx="{cx}" cy="{y+1}" rx="2.5" ry="3" fill="{lip_color}" opacity="0.7"/>'
479
+ 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"/>'
480
+ if mi == 5:
481
+ return (f'<path d="M{cx-5} {y} Q{cx} {y+6} {cx+5} {y}" fill="{tc}" stroke="{lip_color}" stroke-width="1"/>'
482
+ 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"/>')
483
+ 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"/>'
484
+ if mi == 7:
485
+ return (f'<ellipse cx="{cx}" cy="{y+1}" rx="3.5" ry="2" fill="{lip_color}" opacity="0.25"/>'
486
+ 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"/>')
487
+ 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"/>'
488
+
489
+
490
+ def _render_accessory(ai: int, acc_color: str, glasses_color: str, earring_color: str, headband_color: str) -> str:
491
+ if ai == 0: return ""
492
+ if ai == 1: return '<circle cx="40" cy="44" r="0.8" fill="#3a2a2a"/>'
493
+ if ai == 2:
494
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1">'
495
+ '<circle cx="25" cy="33" r="5.5"/><circle cx="39" cy="33" r="5.5"/>'
496
+ '<line x1="30.5" y1="33" x2="33.5" y2="33"/>'
497
+ '<line x1="19.5" y1="33" x2="14" y2="31"/>'
498
+ '<line x1="44.5" y1="33" x2="50" y2="31"/></g>')
499
+ if ai == 3:
500
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1">'
501
+ '<rect x="19" y="29" width="12" height="8" rx="1.5"/>'
502
+ '<rect x="33" y="29" width="12" height="8" rx="1.5"/>'
503
+ '<line x1="31" y1="33" x2="33" y2="33"/>'
504
+ '<line x1="19" y1="33" x2="14" y2="31"/>'
505
+ '<line x1="45" y1="33" x2="50" y2="31"/></g>')
506
+ if ai == 4:
507
+ return (f'<circle cx="10" cy="38" r="1.5" fill="{earring_color}"/>'
508
+ f'<circle cx="10" cy="41" r="2" fill="{earring_color}" opacity="0.8"/>')
509
+ if ai == 5:
510
+ return f'<rect x="13" y="20" width="38" height="3.5" rx="1.5" fill="{headband_color}" opacity="0.85"/>'
511
+ if ai == 6:
512
+ return ('<g fill="#a0785a" opacity="0.35">'
513
+ '<circle cx="21" cy="40" r="0.6"/><circle cx="23" cy="42" r="0.5"/>'
514
+ '<circle cx="19" cy="41.5" r="0.5"/><circle cx="43" cy="40" r="0.6"/>'
515
+ '<circle cx="41" cy="42" r="0.5"/><circle cx="45" cy="41.5" r="0.5"/></g>')
516
+ if ai == 7:
517
+ return (f'<circle cx="10" cy="37" r="1.2" fill="{earring_color}"/>'
518
+ f'<circle cx="54" cy="37" r="1.2" fill="{earring_color}"/>')
519
+ if ai == 8:
520
+ return (f'<g fill="none" stroke="{glasses_color}" stroke-width="1.2">'
521
+ 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"/>'
522
+ 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"/>'
523
+ '<line x1="31" y1="32" x2="33" y2="32"/>'
524
+ '<line x1="19" y1="31" x2="14" y2="29"/>'
525
+ '<line x1="45" y1="31" x2="50" y2="29"/></g>')
526
+ if ai == 9:
527
+ return ('<g>'
528
+ '<rect x="38" y="38" width="8" height="4" rx="1" fill="#f0d0a0" transform="rotate(-15 42 40)"/>'
529
+ '<line x1="40" y1="39" x2="40" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/>'
530
+ '<line x1="42" y1="39" x2="42" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/>'
531
+ '<line x1="44" y1="39" x2="44" y2="41" stroke="#c0a080" stroke-width="0.4" transform="rotate(-15 42 40)"/></g>')
251
532
  return ""
252
533
 
253
534
 
254
535
  def render_svg(
255
536
  wallet_address: str,
256
537
  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,
538
+ flat: bool = False,
539
+ detail: str = "auto",
265
540
  ) -> 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
541
  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
- ]
542
+
543
+ full = detail == "full" or (detail == "auto" and size >= 48)
544
+ skin = SKIN_COLORS[t.skin_color % len(SKIN_COLORS)]
545
+ eye_col = EYE_COLORS[t.eye_color % len(EYE_COLORS)]
546
+ hair_col = HAIR_COLORS[t.hair_color % len(HAIR_COLORS)]
547
+ bg_col = BG_COLORS[t.bg_color % len(BG_COLORS)]
548
+
549
+ derived = derive_skin_colors(skin)
550
+ gid = "sf" + _to_base36(_djb2(wallet_address))
551
+
552
+ hi = t.hair_style % 10
553
+ ai = effective_accessory(t)
554
+
555
+ glasses_color = "#4a4a5a"
556
+ earring_color = blend(skin, "#d4a840", 0.4)
557
+ headband_color = blend(hair_col, "#c04040", 0.5)
558
+
559
+ bg_fill = bg_col if flat else f"url(#{gid}bg)"
560
+
561
+ parts = []
562
+ parts.append(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="{size}" height="{size}">')
563
+ parts.append(_build_defs(gid, skin, derived["skin_hi"], derived["skin_lo"],
564
+ hair_col, bg_col, derived["cheek_color"],
565
+ derived["cheek_opacity"], flat, full))
566
+ parts.append(f'<rect x="0" y="0" width="64" height="64" fill="{bg_fill}" opacity="1" rx="4"/>')
567
+ parts.append(_render_hair_back(hi, gid, flat))
568
+ parts.append(_render_ears(derived["ear_fill"], derived["ear_shadow"]))
569
+ parts.append(_render_face(gid, skin, flat))
570
+ if full:
571
+ parts.append(_render_face_overlays(gid))
572
+ if ai == 5:
573
+ parts.append(_render_accessory(5, derived["acc_color"], glasses_color, earring_color, headband_color))
574
+ parts.append(_render_hair_front(hi, gid, hair_col, skin, flat))
575
+ parts.append(_render_eyes(t.eye_style % 8, eye_col, derived["eye_white"], derived["lid_color"], full))
576
+ parts.append(_render_eyebrows(t.eyebrows % 5, derived["brow_color"]))
577
+ parts.append(_render_nose(t.nose % 4, derived["nose_fill"]))
578
+ parts.append(_render_mouth(t.mouth % 8, derived["lip_color"], derived["is_dark"]))
579
+ if ai != 0 and ai != 5:
580
+ parts.append(_render_accessory(ai, derived["acc_color"], glasses_color, earring_color, headband_color))
581
+ parts.append("</svg>")
299
582
  return "".join(p for p in parts if p)
300
583
 
301
584
 
302
585
  def render_data_uri(wallet_address: str, **kwargs) -> str:
303
- """Render as a data URI for use in <img> tags or HTML emails."""
304
586
  from urllib.parse import quote
305
587
  svg = render_svg(wallet_address, **kwargs)
306
588
  return f"data:image/svg+xml;charset=utf-8,{quote(svg)}"
@@ -308,40 +590,38 @@ def render_data_uri(wallet_address: str, **kwargs) -> str:
308
590
 
309
591
  # ─── Description ──────────────────────────────────────────────
310
592
 
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,
593
+ _SKIN_DESC = {0: "porcelain", 1: "ivory", 2: "fair", 3: "light", 4: "sand",
594
+ 5: "golden", 6: "warm", 7: "caramel", 8: "brown", 9: "deep"}
595
+ _EYE_STYLE_DESC = {0: "round, wide-open", 1: "small and minimal", 2: "almond-shaped",
596
+ 3: "wide and expressive", 4: "relaxed, half-lidded", 5: "joyful, crescent-shaped",
597
+ 6: "bright and sparkling", 7: "gentle and narrow"}
598
+ _EYE_COLOR_DESC = {0: "dark brown", 1: "blue", 2: "green", 3: "hazel", 4: "gray"}
599
+ _BROW_DESC = {0: "wispy", 1: "straight", 2: "natural", 3: "elegantly arched", 4: "sharply angled"}
600
+ _NOSE_DESC = {0: "a subtle shadow nose", 1: "a small button nose", 2: "a soft curved nose",
601
+ 3: "a button nose with visible nostrils"}
602
+ _MOUTH_DESC = {0: "a gentle smile", 1: "a calm, neutral expression", 2: "a happy grin",
603
+ 3: "a surprised O-shaped mouth", 4: "a confident smirk", 5: "a wide, toothy grin",
604
+ 6: "a flat, straight expression", 7: "a soft pout"}
605
+ _HAIR_STYLE_DESC = {0: "bald, with no hair", 1: "short, neatly cropped hair", 2: "bouncy, curly hair",
606
+ 3: "side-swept hair", 4: "a voluminous puff",
607
+ 5: "long hair that falls past the shoulders", 6: "a clean bob cut",
608
+ 7: "a close buzz cut", 8: "flowing, wavy hair", 9: "a neat topknot"}
609
+ _HAIR_COLOR_DESC = {0: "jet black", 1: "espresso brown", 2: "walnut", 3: "honey blonde",
610
+ 4: "copper red", 5: "silver", 6: "charcoal", 7: "burgundy",
611
+ 8: "strawberry", 9: "ginger"}
612
+ _ACC_DESC = {0: "", 1: "a beauty mark", 2: "round glasses", 3: "rectangular glasses",
613
+ 4: "a dangling earring", 5: "a headband", 6: "freckles", 7: "stud earrings",
614
+ 8: "aviator sunglasses", 9: "a band-aid"}
615
+ _BG_DESC = {0: "rose", 1: "olive", 2: "sage", 3: "fern", 4: "mint",
616
+ 5: "ocean", 6: "sky", 7: "lavender", 8: "orchid", 9: "blush"}
617
+
618
+
619
+ def _build_paragraph(
620
+ t, ai: int,
326
621
  perspective: str = "third",
327
622
  name: Optional[str] = None,
328
623
  include_background: bool = True,
329
624
  ) -> 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
625
  if perspective == "first":
346
626
  subj = f"I'm {name}. I have" if name else "I have"
347
627
  im = "I'm"
@@ -349,25 +629,17 @@ def describe_appearance(
349
629
  subj = f"{name} has" if name else "This SolFace has"
350
630
  im = "They're"
351
631
 
352
- # Build parts list
353
632
  parts = []
633
+ parts.append(f"{subj} a squircle face with {_SKIN_DESC.get(t.skin_color, 'warm')} skin")
354
634
 
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
635
  eye_s = _EYE_STYLE_DESC.get(t.eye_style, "round")
362
636
  eye_c = _EYE_COLOR_DESC.get(t.eye_color, "dark")
363
637
  parts.append(f"{eye_s} {eye_c} eyes")
364
638
 
365
- # Eyebrows
366
639
  brows = _BROW_DESC.get(t.eyebrows, "")
367
640
  if brows:
368
641
  parts.append(f"{brows} eyebrows")
369
642
 
370
- # Hair
371
643
  if t.hair_style == 0:
372
644
  parts.append("and is bald")
373
645
  else:
@@ -378,7 +650,6 @@ def describe_appearance(
378
650
  else:
379
651
  parts.append(f"and {hc} {hs}")
380
652
 
381
- # Assemble main sentence
382
653
  desc = parts[0]
383
654
  if len(parts) > 2:
384
655
  desc += ", " + ", ".join(parts[1:-1]) + ", " + parts[-1]
@@ -386,29 +657,19 @@ def describe_appearance(
386
657
  desc += " and " + parts[1]
387
658
  desc += "."
388
659
 
389
- # Nose
390
660
  nose = _NOSE_DESC.get(t.nose, "")
391
661
  if nose:
392
- if perspective == "first":
393
- nose_subj = "I have"
394
- else:
395
- nose_subj = f"{name} has" if name else "They have"
662
+ nose_subj = "I have" if perspective == "first" else (f"{name} has" if name else "They have")
396
663
  desc += f" {nose_subj} {nose}."
397
664
 
398
- # Accessory
399
- acc = _ACC_DESC.get(t.accessory, "")
665
+ acc = _ACC_DESC.get(ai, "")
400
666
  if acc:
401
667
  desc += f" {im} wearing {acc}."
402
668
 
403
- # Mouth
404
669
  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"
670
+ mouth_subj = "I have" if perspective == "first" else (f"{name} has" if name else "They have")
409
671
  desc += f" {mouth_subj} {mouth}."
410
672
 
411
- # Background
412
673
  if include_background:
413
674
  bg = _BG_DESC.get(t.bg_color, "colorful")
414
675
  desc += f" The background is {bg}."
@@ -416,14 +677,73 @@ def describe_appearance(
416
677
  return desc
417
678
 
418
679
 
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.
680
+ def _build_structured(t, ai: int, include_background: bool = True) -> str:
681
+ lines = [
682
+ "Face: squircle",
683
+ f"Skin: {_SKIN_DESC.get(t.skin_color, 'warm')}",
684
+ f"Eyes: {_EYE_STYLE_DESC.get(t.eye_style, 'round')}, {_EYE_COLOR_DESC.get(t.eye_color, 'dark')}",
685
+ f"Eyebrows: {_BROW_DESC.get(t.eyebrows, 'wispy')}",
686
+ ]
687
+ nose = _NOSE_DESC.get(t.nose, "")
688
+ if nose:
689
+ lines.append(f"Nose: {nose.lstrip('a ')}")
690
+ lines.append(f"Mouth: {_MOUTH_DESC.get(t.mouth, 'smile')}")
691
+ if t.hair_style == 0:
692
+ lines.append("Hair: bald")
693
+ else:
694
+ hs = _HAIR_STYLE_DESC.get(t.hair_style, "")
695
+ hc = _HAIR_COLOR_DESC.get(t.hair_color, "")
696
+ lines.append(f"Hair: {hc} {hs[2:] if hs.startswith('a ') else hs}")
697
+ acc = _ACC_DESC.get(ai, "")
698
+ if acc:
699
+ lines.append(f"Accessory: {acc}")
700
+ if include_background:
701
+ lines.append(f"Background: {_BG_DESC.get(t.bg_color, 'colorful')}")
702
+ return "\n".join(lines)
703
+
704
+
705
+ def _build_compact(t, ai: int) -> str:
706
+ parts = []
707
+ parts.append("squircle face")
708
+ parts.append(f"{_SKIN_DESC.get(t.skin_color, 'warm')} skin")
709
+ parts.append(f"{_EYE_COLOR_DESC.get(t.eye_color, 'dark')} {_EYE_STYLE_DESC.get(t.eye_style, 'round')} eyes")
710
+ if t.hair_style == 0:
711
+ parts.append("bald")
712
+ else:
713
+ hs = _HAIR_STYLE_DESC.get(t.hair_style, "hair").split(",")[0]
714
+ hc = _HAIR_COLOR_DESC.get(t.hair_color, "")
715
+ parts.append(f"{hc} {hs[2:] if hs.startswith('a ') else hs}")
716
+ acc = _ACC_DESC.get(ai, "")
717
+ if acc:
718
+ parts.append(acc)
719
+ mouth = _MOUTH_DESC.get(t.mouth, "smiling")
720
+ if mouth.startswith("a "):
721
+ mouth = mouth[2:]
722
+ parts.append(mouth)
723
+ return ", ".join(parts)
724
+
725
+
726
+ def describe_appearance(
727
+ wallet_address: str,
728
+ format: str = "paragraph",
729
+ perspective: str = "third",
730
+ name: Optional[str] = None,
731
+ include_background: bool = True,
732
+ ) -> str:
733
+ t = generate_traits(wallet_address)
734
+ ai = effective_accessory(t)
735
+ if format == "structured":
736
+ return _build_structured(t, ai, include_background)
737
+ if format == "compact":
738
+ return _build_compact(t, ai)
739
+ return _build_paragraph(t, ai, perspective, name, include_background)
422
740
 
423
- Usage:
424
- prompt = agent_appearance_prompt("7xKXq...", "Atlas")
425
- system_prompt = f"You are Atlas, an AI agent. {prompt}"
426
- """
741
+
742
+ def sol_face_alt_text(wallet_address: str) -> str:
743
+ return f"SolFace avatar: {describe_appearance(wallet_address, format='compact', include_background=False)}"
744
+
745
+
746
+ def agent_appearance_prompt(wallet_address: str, agent_name: Optional[str] = None) -> str:
427
747
  desc = describe_appearance(wallet_address, perspective="first", name=agent_name, include_background=False)
428
748
  h = trait_hash(wallet_address)
429
749
  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."
@@ -435,7 +755,7 @@ if __name__ == "__main__":
435
755
  import sys
436
756
 
437
757
  if len(sys.argv) < 2:
438
- print("Usage: python solfaces.py <wallet_address> [--svg] [--json] [--describe] [--size N]")
758
+ print("Usage: python solfaces.py <wallet_address> [--svg] [--json] [--describe] [--size N] [--flat]")
439
759
  sys.exit(1)
440
760
 
441
761
  wallet = sys.argv[1]
@@ -449,8 +769,10 @@ if __name__ == "__main__":
449
769
  sys.exit(1)
450
770
  size = int(sys.argv[idx + 1])
451
771
 
772
+ use_flat = "--flat" in args
773
+
452
774
  if "--svg" in args:
453
- print(render_svg(wallet, size=size))
775
+ print(render_svg(wallet, size=size, flat=use_flat))
454
776
  elif "--json" in args:
455
777
  import json
456
778
  t = generate_traits(wallet)
@@ -468,8 +790,8 @@ if __name__ == "__main__":
468
790
  labels = get_trait_labels(t)
469
791
  print(f"SolFace for {wallet[:8]}...{wallet[-4:]}")
470
792
  print(f"Hash: {trait_hash(wallet)}")
471
- print(f"---")
793
+ print("---")
472
794
  for k, v in labels.items():
473
795
  print(f" {k}: {v}")
474
- print(f"---")
796
+ print("---")
475
797
  print(describe_appearance(wallet))