privateboard 0.1.38 → 0.1.41

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 (78) hide show
  1. package/dist/boot.js +327 -50
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +327 -50
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +201 -40
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/agent-overlay.css +14 -6
  13. package/public/agent-overlay.js +6 -6
  14. package/public/agent-profile.css +9 -12
  15. package/public/agent-profile.js +91 -38
  16. package/public/app.js +471 -528
  17. package/public/avatar-3d-snap.js +205 -0
  18. package/public/avatar-3d.js +836 -0
  19. package/public/avatar-customizer.html +274 -0
  20. package/public/avatar3d-editor.css +240 -0
  21. package/public/avatar3d-editor.js +484 -0
  22. package/public/avatars/3d/chair.png +0 -0
  23. package/public/avatars/3d/first-principles.png +0 -0
  24. package/public/avatars/3d/historian.png +0 -0
  25. package/public/avatars/3d/long-horizon.png +0 -0
  26. package/public/avatars/3d/phenomenologist.png +0 -0
  27. package/public/avatars/3d/socrates.png +0 -0
  28. package/public/avatars/3d/user-empathy.png +0 -0
  29. package/public/avatars/3d/value-investor.png +0 -0
  30. package/public/core-avatars.js +86 -0
  31. package/public/home-3d-loader.js +15 -4
  32. package/public/home-3d-mock.js +25 -14
  33. package/public/home.html +78 -16
  34. package/public/i18n.js +8 -4
  35. package/public/icons/avatar_1779855104027.glb +0 -0
  36. package/public/icons/logo.png +0 -0
  37. package/public/icons/new-style.glb +0 -0
  38. package/public/icons/new-style2.glb +0 -0
  39. package/public/icons/new-style3.glb +0 -0
  40. package/public/icons/new-style4.glb +0 -0
  41. package/public/icons/new-style5.glb +0 -0
  42. package/public/icons/new-style6.glb +0 -0
  43. package/public/icons/office.glb +0 -0
  44. package/public/icons/stuff.glb +0 -0
  45. package/public/index.html +169 -141
  46. package/public/magazine.html +1 -1
  47. package/public/new-agent.js +46 -20
  48. package/public/newspaper.html +1 -1
  49. package/public/office-viewer.html +340 -0
  50. package/public/ppt.html +1 -1
  51. package/public/stuff-viewer.html +330 -0
  52. package/public/thread.css +16 -15
  53. package/public/user-settings.css +7 -31
  54. package/public/user-settings.js +75 -89
  55. package/public/vendor/BufferGeometryUtils.js +1434 -0
  56. package/public/vendor/DRACOLoader.js +739 -0
  57. package/public/vendor/GLTFLoader.js +4860 -0
  58. package/public/vendor/RoomEnvironment.js +185 -0
  59. package/public/vendor/SkeletonUtils.js +496 -0
  60. package/public/vendor/draco/draco_decoder.js +34 -0
  61. package/public/vendor/draco/draco_decoder.wasm +0 -0
  62. package/public/vendor/draco/draco_encoder.js +33 -0
  63. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  64. package/public/vendor/meshopt_decoder.module.js +196 -0
  65. package/public/voice-3d-banner.js +19 -7
  66. package/public/voice-3d.js +1407 -432
  67. package/public/voice-replay.js +21 -0
  68. package/public/avatar-skill.js +0 -629
  69. package/public/avatars/chair-blink.svg +0 -1
  70. package/public/avatars/chair.svg +0 -1
  71. package/public/avatars/first-principles.svg +0 -1
  72. package/public/avatars/historian.svg +0 -1
  73. package/public/avatars/long-horizon.svg +0 -1
  74. package/public/avatars/phenomenologist.svg +0 -1
  75. package/public/avatars/socrates.svg +0 -1
  76. package/public/avatars/user-empathy.svg +0 -1
  77. package/public/avatars/value-investor.svg +0 -1
  78. package/public/icons/folded-sidebar.png +0 -0
@@ -0,0 +1,836 @@
1
+ /* ═══════════════════════════════════════════════════════════════════
2
+ avatar-3d.js · dynamic 3D director avatars (three.js).
3
+
4
+ Loads rigged GLB base models and produces real 3D avatar instances —
5
+ one per director, recoloured per-seed (or explicitly by the customizer)
6
+ so each reads distinct. Supports MULTIPLE base "styles" (each its own
7
+ GLB); the customizer lets the user pick a style + tweak skin / hair /
8
+ outfit colours + toggle the style's accessory (hat / glasses).
9
+
10
+ ES module · imports the vendored three + loaders directly. Consumers:
11
+ await loadAvatar3D(modelId?) → caches + a model's GLB
12
+ buildAvatar3D(seed, opts?) → fresh THREE.Group instance
13
+ recolorAvatar(group, {skin,hair,outfit})
14
+ setAvatarPartVisible(group, 'hat'|'glasses', bool)
15
+ isAvatar3DReady(modelId?) · AVATAR_MODELS · AVATAR_PALETTES
16
+
17
+ `buildAvatar3D` returns a Group with its origin between the feet (feet
18
+ at y=0, centred x/z) so callers drop it straight onto a seat position.
19
+ ═══════════════════════════════════════════════════════════════════ */
20
+
21
+ import * as THREE from "/vendor/three.module.min.js";
22
+ import { GLTFLoader } from "/vendor/GLTFLoader.js";
23
+ import { clone as cloneSkeleton } from "/vendor/SkeletonUtils.js";
24
+
25
+ /* ── Base-model registry ─────────────────────────────────────────────
26
+ Each style is its own rigged GLB. Material names in these exports are
27
+ unreliable (skin/hair/outfit all ship as unnamed / "Color_"), so we map
28
+ role by the material's BASE COLOUR (matched with tolerance) — reliable
29
+ for curated assets. Named materials (Teeth / BlackShiny / White / Glass
30
+ / Hat) are caught by name first. `accessory` is the toggleable extra
31
+ that style carries. */
32
+ export const AVATAR_MODELS = [
33
+ {
34
+ id: "classic", label: "经典 · 帽子",
35
+ url: "/icons/avatar_1779855104027.glb",
36
+ accessory: "hat",
37
+ colorRoles: [
38
+ { c: [0.745, 0.413, 0.141], role: "skin" },
39
+ { c: [0.025, 0.011, 0.009], role: "hair" },
40
+ { c: [0.913, 0.913, 0.913], role: "outfit" },
41
+ ],
42
+ },
43
+ {
44
+ id: "glasses", label: "眼镜 · 丸子头",
45
+ url: "/icons/new-style.glb",
46
+ accessory: "glasses",
47
+ colorRoles: [
48
+ { c: [0.913, 0.565, 0.376], role: "skin" },
49
+ { c: [0.147, 0.076, 0.031], role: "hair" },
50
+ { c: [0.565, 0.021, 0.021], role: "glasses" }, // red frame
51
+ { c: [0.010, 0.181, 0.644], role: "outfit" }, // blue top
52
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // white bottom
53
+ ],
54
+ },
55
+ {
56
+ id: "casual", label: "休闲 · 耳机",
57
+ url: "/icons/new-style2.glb",
58
+ accessory: "headphones",
59
+ colorRoles: [
60
+ { c: [0.913, 0.565, 0.376], role: "skin" },
61
+ { c: [0.147, 0.076, 0.031], role: "hair" }, // shaggy hair
62
+ { c: [0.119, 0.119, 0.119], role: "headphones" }, // over-ear cans
63
+ { c: [0.054, 0.054, 0.054], role: "outfit" }, // t-shirt
64
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // shorts
65
+ ],
66
+ },
67
+ {
68
+ // Parts source only · supplies a baseball cap, hair, and a T-shirt+shorts
69
+ // outfit. Not offered as a body style (partsOnly) — its cap, hair, and
70
+ // clothing are mixed onto the other bodies via the swap dimensions.
71
+ id: "street", label: "街头 · 鸭舌帽", partsOnly: true,
72
+ url: "/icons/new-style3.glb",
73
+ accessory: "cap",
74
+ colorRoles: [
75
+ { c: [0.913, 0.565, 0.376], role: "skin" },
76
+ { c: [0.147, 0.076, 0.031], role: "hair" }, // short hair
77
+ { c: [0.054, 0.054, 0.054], role: "outfit" }, // black tee
78
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // white shorts + shoes
79
+ ],
80
+ // The cap + shorts + shoes are all white, and the only textured mesh is
81
+ // the "deal-with-it" sunglasses (NOT a hat). Tag by geometry/texture so
82
+ // the cap is its own accessory role and the glasses aren't mistaken for a
83
+ // hat. (Applied on load, before colour classification.)
84
+ partTags: [
85
+ { role: "glasses", textured: true }, // pixel sunglasses
86
+ { role: "cap", color: [0.913, 0.913, 0.913], minY: 1.4 }, // white cap (above head)
87
+ ],
88
+ },
89
+ {
90
+ // Parts source only · supplies a gold crown, mid-length hair, a tie, and
91
+ // distinct (thicker) eyebrows. Not a standalone body (partsOnly).
92
+ id: "royal", label: "皇室 · 王冠", partsOnly: true,
93
+ url: "/icons/new-style4.glb",
94
+ accessory: "crown",
95
+ colorRoles: [
96
+ { c: [0.913, 0.565, 0.376], role: "skin" },
97
+ { c: [0.147, 0.076, 0.031], role: "hair" }, // mid-length hair
98
+ { c: [0.054, 0.054, 0.054], role: "outfit" }, // black tee
99
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // white shorts + shoes
100
+ ],
101
+ // The crown (gold + orange) sits above the head; the tie is white and
102
+ // collides with the white shorts/shoes, so split it out by its chest-level
103
+ // band. (Eyebrows are tagged generically by tagEyebrows.)
104
+ partTags: [
105
+ { role: "crown", minY: 1.9 }, // crown caps (above head)
106
+ { role: "tie", color: [0.913, 0.913, 0.913], minY: 0.7, maxY: 1.3 }, // white tie at chest
107
+ ],
108
+ },
109
+ {
110
+ // Parts source only · supplies a Santa hat, long hair, pixel sunglasses
111
+ // ("墨镜"), a bow ("蝴蝶结"), and eyebrows. Not a standalone body.
112
+ id: "xmas", label: "圣诞 · 圣诞帽", partsOnly: true,
113
+ url: "/icons/new-style5.glb",
114
+ accessory: "santa",
115
+ colorRoles: [
116
+ { c: [0.913, 0.565, 0.376], role: "skin" },
117
+ { c: [0.147, 0.076, 0.031], role: "hair" }, // long hair
118
+ { c: [0.054, 0.054, 0.054], role: "outfit" }, // black tee
119
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // white shorts + shoes
120
+ ],
121
+ // Santa hat = red body + white pom, both above the head; the white pom is
122
+ // named "White" (would mis-tag as eyewhite) so split it out by height. The
123
+ // bow is a unique teal — tag it as neckwear ("tie"). The textured mesh is
124
+ // the pixel sunglasses ("shades"), not a hat.
125
+ partTags: [
126
+ { role: "shades", textured: true }, // pixel sunglasses
127
+ { role: "santa", minY: 1.9 }, // red + white santa hat (above head)
128
+ { role: "tie", color: [0.074, 0.631, 0.753] }, // teal bow (neckwear)
129
+ ],
130
+ },
131
+ {
132
+ // Parts source only · supplies a top hat ("礼帽"), a low-ponytail
133
+ // hairstyle, distinct eyes, and a sleeveless dress. Not a standalone
134
+ // body (partsOnly).
135
+ id: "style6", label: "礼帽 · 背心裙", partsOnly: true,
136
+ url: "/icons/new-style6.glb",
137
+ accessory: "tophat",
138
+ colorRoles: [
139
+ { c: [0.913, 0.565, 0.376], role: "skin" },
140
+ { c: [0.147, 0.076, 0.031], role: "hair" }, // long ponytail hair
141
+ { c: [0.054, 0.054, 0.054], role: "outfit" }, // dark dress
142
+ { c: [0.913, 0.913, 0.913], role: "outfit" }, // light dress trim
143
+ ],
144
+ // The top hat is the only textured mesh → tag it as its own accessory
145
+ // role so it doesn't collide with classic's "hat". The dress's second
146
+ // white mesh is named "White.001", which the name rule would mis-tag as
147
+ // eyewhite (it sits at torso level, not the eyes) — force it to outfit so
148
+ // the outfit swap carries the whole dress.
149
+ partTags: [
150
+ { role: "tophat", textured: true },
151
+ { role: "outfit", name: "white" },
152
+ ],
153
+ },
154
+ ];
155
+ /** Back-compat · the first style's GLB. */
156
+ export const DEFAULT_AVATAR_URL = AVATAR_MODELS[0].url;
157
+
158
+ const _templates = new Map(); // model.id -> normalized gltf root (clone source)
159
+ const _loadPromises = new Map(); // model.id -> in-flight load promise
160
+
161
+ function resolveModel(idOrUrl) {
162
+ if (!idOrUrl) return AVATAR_MODELS[0];
163
+ return AVATAR_MODELS.find((m) => m.id === idOrUrl || m.url === idOrUrl) || AVATAR_MODELS[0];
164
+ }
165
+
166
+ export function isAvatar3DReady(idOrUrl) {
167
+ return _templates.has(resolveModel(idOrUrl).id);
168
+ }
169
+
170
+ /** Load + cache a base model. Accepts a model id ("classic"/"glasses"),
171
+ * a known GLB url, or nothing (→ first model). Idempotent per model. */
172
+ export function loadAvatar3D(idOrUrl) {
173
+ const model = resolveModel(idOrUrl);
174
+ if (_templates.has(model.id)) return Promise.resolve(_templates.get(model.id));
175
+ if (_loadPromises.has(model.id)) return _loadPromises.get(model.id);
176
+ const loader = new GLTFLoader();
177
+ const p = new Promise((resolve, reject) => {
178
+ loader.load(
179
+ model.url,
180
+ (gltf) => {
181
+ const root = gltf.scene || (gltf.scenes && gltf.scenes[0]);
182
+ if (!root) { reject(new Error("GLB has no scene: " + model.url)); return; }
183
+ root.traverse((o) => {
184
+ if (o.isMesh) {
185
+ o.castShadow = true;
186
+ o.receiveShadow = true;
187
+ o.frustumCulled = false; // skinned meshes mis-cull when posed
188
+ }
189
+ });
190
+ tagEyebrows(root, model); // tag brow meshes once on the template
191
+ tagModelParts(root, model); // model-specific mesh→role overrides (cap / glasses)
192
+ _templates.set(model.id, root);
193
+ resolve(root);
194
+ },
195
+ undefined,
196
+ (err) => { _loadPromises.delete(model.id); reject(err); },
197
+ );
198
+ });
199
+ _loadPromises.set(model.id, p);
200
+ return p;
201
+ }
202
+
203
+ /* ── Deterministic per-seed RNG (mulberry32 over a string hash) ──────── */
204
+ function makeRng(seed) {
205
+ let s = 0;
206
+ const str = String(seed || "default");
207
+ for (let i = 0; i < str.length; i++) s = (s * 31 + str.charCodeAt(i)) >>> 0;
208
+ return function () {
209
+ s = (s + 0x6d2b79f5) >>> 0;
210
+ let t = s;
211
+ t = Math.imul(t ^ (t >>> 15), t | 1);
212
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
213
+ return ((t ^ (t >>> 14)) >>> 0) / 0xffffffff;
214
+ };
215
+ }
216
+
217
+ /* ── Palettes (sRGB hex) ─────────────────────────────────────────────── */
218
+ const SKIN_TONES = [
219
+ "#ffe0bd", "#f1c27d", "#e0ac69", "#c68642", "#a8703a", "#8d5524", "#5c3a21", "#f7d7b8",
220
+ ];
221
+ const HAIR_COLORS = [
222
+ "#14100d", "#241c16", "#3a2a1e", "#4a3526", "#6f4e37", "#8d6a45",
223
+ "#b08d57", "#d8b878", "#e8cf9a", "#3a3a3a", "#6e6e6e", "#9a9a9a", "#7a3b28",
224
+ ];
225
+ const OUTFIT_COLORS = [
226
+ // Muted / professional
227
+ "#3b5b78", "#4a6b52", "#7a4a52", "#5a4a78", "#8a6d3b", "#445a5a", "#6b3f4a", "#3f4a6b",
228
+ "#7a5a3b", "#556070", "#6d4a78", "#2f6b5e",
229
+ // Neutrals · black + white
230
+ "#1a1a1a", "#f2f2f2",
231
+ // Vivid / saturated
232
+ "#d8392b", "#1f6fe0", "#19a974", "#e87722", "#7d3cc4", "#0fb5b5", "#e0b400", "#e23a8a",
233
+ ];
234
+ // Eyebrows read as a hair-family colour, so they share the hair palette.
235
+ const BROW_COLORS = HAIR_COLORS;
236
+ // Iris / pupil colours · the GLB pupils are all the same black mesh, so the
237
+ // only eye "variety" is colour. Mostly natural (near-black → browns) plus a
238
+ // few subtle iris tints (green / blue / slate).
239
+ const EYE_COLORS = [
240
+ "#0d0d0d", "#241c16", "#3a2a1e", "#5a3a22", "#6f4e37", "#3a5a3a", "#2f5a78", "#4a4a55",
241
+ ];
242
+ export const AVATAR_PALETTES = { skin: SKIN_TONES, hair: HAIR_COLORS, outfit: OUTFIT_COLORS, brow: BROW_COLORS, tie: OUTFIT_COLORS, eye: EYE_COLORS };
243
+
244
+ /* ── Role resolution · name rules first, then per-model colour match ──── */
245
+ function colorNear(c, rgb) {
246
+ return Math.abs(c.r - rgb[0]) < 0.03 && Math.abs(c.g - rgb[1]) < 0.03 && Math.abs(c.b - rgb[2]) < 0.03;
247
+ }
248
+ function resolveRole(m, model) {
249
+ if (m.map) return "hat"; // only the (textured) hat carries a map
250
+ const name = (m.name || "").toLowerCase();
251
+ if (/insidemouth|mouth|tongue|gum|nail/.test(name)) return "mouth";
252
+ if (/teeth|tooth/.test(name)) return "teeth";
253
+ if (/blackshiny/.test(name)) return "eye";
254
+ if (/white/.test(name)) return "eyewhite";
255
+ if (/glass/.test(name)) return "glasses"; // transparent lens
256
+ if (/hat/.test(name)) return "hat";
257
+ if (m.color && model && model.colorRoles) {
258
+ for (const e of model.colorRoles) if (colorNear(m.color, e.c)) return e.role;
259
+ }
260
+ return "other";
261
+ }
262
+
263
+ /** Mesh-level role · a pre-set `userData.avatarRole` (e.g. eyebrows tagged
264
+ * on the template, or parts tagged by the swap helpers) wins over the
265
+ * material colour classification. Use this — not raw resolveRole — anywhere
266
+ * visibility/role decisions are made per mesh, so e.g. eyebrows that share
267
+ * the hair colour aren't mistaken for hair. */
268
+ function meshRole(o, model) {
269
+ const pre = o.userData && o.userData.avatarRole;
270
+ if (pre) return pre;
271
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
272
+ return resolveRole(m, model);
273
+ }
274
+
275
+ /** Give every mesh under `root` its OWN material clone. Templates ship SHARED
276
+ * material objects (e.g. classic's hair mesh and brow mesh are one material),
277
+ * and SkeletonUtils.clone shares those refs. Without this, painting/recolouring
278
+ * a built instance mutates the cached template — corrupting later builds (a
279
+ * re-used hair source would lose its "hair" colour classification and vanish).
280
+ * buildAvatar3D does this for the body; the swap helpers must do it too. */
281
+ function cloneMaterialsInPlace(root) {
282
+ root.traverse((o) => {
283
+ if (o.isMesh && o.material) {
284
+ o.material = Array.isArray(o.material) ? o.material.map((x) => x.clone()) : o.material.clone();
285
+ }
286
+ });
287
+ }
288
+
289
+ /** Tag eyebrow meshes on a freshly-loaded template so they're treated as
290
+ * their own role (independently colourable, and not hidden when hair is
291
+ * swapped). Eyebrows are the very-dark `Color_` mesh (~[0.025,0.011,0.009])
292
+ * in every model. In most models the hair is a distinct lighter colour, so
293
+ * the single dark mesh is unambiguously the brows; in models where the hair
294
+ * shares that dark colour (e.g. classic), the LARGER dark mesh is the hair
295
+ * and the smaller one(s) are the brows. */
296
+ function tagEyebrows(root, model) {
297
+ const BROW = [0.025, 0.011, 0.009];
298
+ const hairEntry = (model.colorRoles || []).find((e) => e.role === "hair");
299
+ const hairSharesBrowColor = !!hairEntry &&
300
+ Math.abs(hairEntry.c[0] - BROW[0]) < 0.03 &&
301
+ Math.abs(hairEntry.c[1] - BROW[1]) < 0.03 &&
302
+ Math.abs(hairEntry.c[2] - BROW[2]) < 0.03;
303
+
304
+ const cands = [];
305
+ root.updateMatrixWorld(true);
306
+ root.traverse((o) => {
307
+ if (!o.isMesh || !o.material) return;
308
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
309
+ if (m.map) return; // textured (hat) — never a brow
310
+ // Exclude facial features whose near-black colour also falls inside the
311
+ // brow tolerance — esp. the BlackShiny eyes at [0,0,0]. Match by material
312
+ // NAME so we don't recolour eyes / mouth / teeth / sclera / lens as brows.
313
+ const nm = (m.name || "").toLowerCase();
314
+ if (/insidemouth|mouth|tongue|gum|nail|teeth|tooth|blackshiny|white|glass|hat/.test(nm)) return;
315
+ if (m.color && colorNear(m.color, BROW)) {
316
+ const size = new THREE.Vector3();
317
+ new THREE.Box3().setFromObject(o).getSize(size);
318
+ cands.push({ o, vol: (size.x || 1e-4) * (size.y || 1e-4) * (size.z || 1e-4) });
319
+ }
320
+ });
321
+ if (!cands.length) return;
322
+ cands.sort((a, b) => b.vol - a.vol);
323
+ // Drop the largest dark mesh (= hair) only when hair shares the colour.
324
+ const brows = hairSharesBrowColor ? cands.slice(1) : cands;
325
+ for (const c of brows) c.o.userData.avatarRole = "brow";
326
+ }
327
+
328
+ /** Apply a model's explicit `partTags` (load-time mesh→role overrides) for
329
+ * parts that colour/name classification can't separate — e.g. a white cap
330
+ * that shares the shorts' colour, or sunglasses that would misfire as a hat
331
+ * via the textured-mesh rule. Each rule matches on `textured` (has a map),
332
+ * `name` (case-insensitive substring of the material name), `color`
333
+ * (≈ base colour), and/or a `minY`/`maxY` band on the mesh's raw
334
+ * bounding-box centre; the first matching rule wins. Runs after tagEyebrows. */
335
+ function tagModelParts(root, model) {
336
+ if (!model.partTags || !model.partTags.length) return;
337
+ root.updateMatrixWorld(true);
338
+ root.traverse((o) => {
339
+ if (!o.isMesh || !o.material) return;
340
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
341
+ let cy = null;
342
+ for (const rule of model.partTags) {
343
+ if (rule.textured && !m.map) continue;
344
+ if (rule.name && !(m.name || "").toLowerCase().includes(rule.name.toLowerCase())) continue;
345
+ if (rule.color && !(m.color && colorNear(m.color, rule.color))) continue;
346
+ if (rule.minY != null || rule.maxY != null) {
347
+ if (cy == null) {
348
+ const b = new THREE.Box3().setFromObject(o);
349
+ cy = (b.min.y + b.max.y) / 2;
350
+ }
351
+ if (rule.minY != null && cy < rule.minY) continue;
352
+ if (rule.maxY != null && cy > rule.maxY) continue;
353
+ }
354
+ o.userData.avatarRole = rule.role;
355
+ break;
356
+ }
357
+ });
358
+ }
359
+
360
+ /* ── Per-role surface finish · base GLBs ship every material at the GLTF
361
+ default roughness 1.0 (matte). Lowering roughness + raising
362
+ envMapIntensity lets skin / hair / eyes catch IBL reflections (the
363
+ gloss). Requires the host scene to set `scene.environment`. */
364
+ const FINISH = {
365
+ skin: { roughness: 0.48, metalness: 0.0, envMapIntensity: 1.15 },
366
+ hair: { roughness: 0.30, metalness: 0.05, envMapIntensity: 1.5 },
367
+ brow: { roughness: 0.45, metalness: 0.0, envMapIntensity: 0.8 },
368
+ outfit: { roughness: 0.72, metalness: 0.0, envMapIntensity: 0.9 },
369
+ eye: { roughness: 0.06, metalness: 0.0, envMapIntensity: 1.7 },
370
+ };
371
+ function applyFinish(m, f) {
372
+ if (typeof f.roughness === "number") m.roughness = f.roughness;
373
+ if (typeof f.metalness === "number") m.metalness = f.metalness;
374
+ if ("envMapIntensity" in m && typeof f.envMapIntensity === "number") m.envMapIntensity = f.envMapIntensity;
375
+ m.needsUpdate = true;
376
+ return m;
377
+ }
378
+
379
+ function pick(arr, rng) { return arr[Math.floor(rng() * arr.length) % arr.length]; }
380
+
381
+ /** Build a fresh, normalized avatar instance. `opts`:
382
+ * model · body style id ("classic" / "glasses"), default first
383
+ * hairStyle · INDEPENDENT hair dimension · a model id whose hair to
384
+ * wear, or "none" (bald), or omitted (keep the body's own
385
+ * hair). Cross-model hair works because both GLBs share
386
+ * the same Mixamo rig → the hair re-binds to the body's
387
+ * skeleton. The source hair model must be loaded first.
388
+ * height · world-unit height (default 1.6)
389
+ * skin/hair/outfit · explicit hex overrides (else seeded from palette)
390
+ * accessory · false to hide the style's hat/glasses
391
+ * tint · false to keep the GLB's baked colours untouched */
392
+ export function buildAvatar3D(seed, opts = {}) {
393
+ const model = resolveModel(opts.model);
394
+ const template = _templates.get(model.id);
395
+ if (!template) return null;
396
+ const rng = makeRng(seed);
397
+ const targetHeight = typeof opts.height === "number" ? opts.height : 1.6;
398
+
399
+ const inst = cloneSkeleton(template);
400
+ inst.traverse((o) => {
401
+ if (o.isMesh && o.material) {
402
+ o.material = Array.isArray(o.material) ? o.material.map((m) => m.clone()) : o.material.clone();
403
+ }
404
+ });
405
+
406
+ inst.updateMatrixWorld(true);
407
+ const box = new THREE.Box3().setFromObject(inst);
408
+ const size = new THREE.Vector3();
409
+ const center = new THREE.Vector3();
410
+ box.getSize(size);
411
+ box.getCenter(center);
412
+ const s = targetHeight / (size.y || 1);
413
+ inst.scale.setScalar(s);
414
+ inst.position.set(-center.x * s, -box.min.y * s, -center.z * s);
415
+
416
+ const group = new THREE.Group();
417
+ group.name = "avatar3d";
418
+ group.add(inst);
419
+
420
+ // Hair dimension · swap BEFORE painting so swapped-in hair (tagged role
421
+ // "hair") is coloured by the same pass.
422
+ const hairStyle = opts.hairStyle || model.id;
423
+ if (hairStyle !== model.id) swapHair(group, inst, model, hairStyle);
424
+
425
+ // Clothing dimension · independent of body style. `opts.outfitStyle` is
426
+ // a model id whose clothing to wear; default / own id keeps the built-in.
427
+ const outfitStyle = opts.outfitStyle || model.id;
428
+ if (outfitStyle !== model.id) swapOutfitStyle(group, inst, model, outfitStyle);
429
+
430
+ // Eyebrow-shape dimension · `opts.browStyle` is a model id whose brows to
431
+ // wear; "default" / omitted / own id keeps the body's built-in brows.
432
+ // Swapped BEFORE painting so the overlaid brow (role "brow") gets 眉色.
433
+ const browStyle = opts.browStyle || "default";
434
+ if (browStyle !== "default" && browStyle !== model.id) overlayRole(group, inst, model, browStyle, "brow");
435
+
436
+ // Tie dimension · `opts.tieStyle` is a model id supplying a tie, or
437
+ // "none"/omitted for no tie.
438
+ const tieStyle = opts.tieStyle && opts.tieStyle !== "none" ? opts.tieStyle : null;
439
+ if (tieStyle) overlayRole(group, inst, model, tieStyle, "tie");
440
+
441
+ // Eye-shape dimension · `opts.eyeStyle` is a model id whose eyes (role
442
+ // "eye") to wear; "default" / omitted / own id keeps the body's own eyes.
443
+ // Overlaid BEFORE painting so the swapped-in eyes still pick up 瞳色.
444
+ const eyeStyle = opts.eyeStyle && opts.eyeStyle !== "default" ? opts.eyeStyle : null;
445
+ if (eyeStyle) overlayRole(group, inst, model, eyeStyle, "eye");
446
+
447
+ // Accessory dimension · independent of body style. `opts.accessory` is a
448
+ // style id ("none" / "glasses" / "hat"); back-compat: false → "none",
449
+ // true / undefined → the body's own accessory.
450
+ let accStyle = opts.accessory;
451
+ if (accStyle === false) accStyle = "none";
452
+ else if (accStyle === true || accStyle == null) accStyle = model.accessory || "none";
453
+ swapAccessory(group, inst, model, accStyle);
454
+
455
+ const colors = {
456
+ skin: opts.skin || pick(SKIN_TONES, rng),
457
+ hair: opts.hair || pick(HAIR_COLORS, rng),
458
+ outfit: opts.outfit || pick(OUTFIT_COLORS, rng),
459
+ };
460
+ // Eyebrows default to the hair colour (natural), but are independently
461
+ // overridable via opts.brow / the customizer's 眉色 row.
462
+ colors.brow = opts.brow || colors.hair;
463
+ // Neckwear (tie / bow) colour · independently adjustable (颈饰色).
464
+ colors.tie = opts.tie || OUTFIT_COLORS[0];
465
+ // Iris / pupil colour · defaults to near-black (the original look).
466
+ colors.eye = opts.eye || EYE_COLORS[0];
467
+ paintInstance(group, model, colors, opts.tint !== false);
468
+
469
+ group.userData.avatarSeed = seed;
470
+ group.userData.avatarModel = model.id;
471
+ group.userData.avatarHairStyle = hairStyle;
472
+ group.userData.avatarOutfitStyle = outfitStyle;
473
+ group.userData.avatarBrowStyle = browStyle || "default";
474
+ group.userData.avatarTieStyle = tieStyle || "none";
475
+ group.userData.avatarEyeStyle = eyeStyle || "default";
476
+ group.userData.avatarAccessory = accStyle;
477
+ return group;
478
+ }
479
+
480
+ /** Collect a model template's hair meshes (role "hair"). */
481
+ function templateHairMeshes(model) {
482
+ const t = _templates.get(model.id);
483
+ if (!t) return [];
484
+ const out = [];
485
+ t.traverse((o) => {
486
+ if (o.isMesh && o.material && meshRole(o, model) === "hair") out.push(o);
487
+ });
488
+ return out;
489
+ }
490
+
491
+ /** Replace the body's hair with another style's hair (or none).
492
+ *
493
+ * We do NOT re-bind across skeletons — the two GLBs share bone *names*
494
+ * but not the same bind pose, so re-binding distorts the mesh. Instead
495
+ * we instantiate the hair model with its OWN (consistent) skeleton and
496
+ * overlay it at the body instance's exact transform, showing only its
497
+ * hair. Size is therefore always correct; head *position* aligns only as
498
+ * far as the two rigs' bind poses agree (good for similarly-proportioned
499
+ * chibis; a per-pair offset can be added if needed). `hairStyle` is a
500
+ * model id, or "none" to go bald. */
501
+ function swapHair(group, inst, bodyModel, hairStyle) {
502
+ // Hide the body's own hair (brows are role "brow" → kept).
503
+ inst.traverse((o) => {
504
+ if (o.isMesh && o.material && meshRole(o, bodyModel) === "hair") o.visible = false;
505
+ });
506
+ if (hairStyle === "none") return;
507
+ const hairModel = resolveModel(hairStyle);
508
+ if (hairModel.id === bodyModel.id) return;
509
+ const hairTemplate = _templates.get(hairModel.id);
510
+ if (!hairTemplate) return; // source not loaded → stay bald rather than break
511
+
512
+ const hairClone = cloneSkeleton(hairTemplate);
513
+ cloneMaterialsInPlace(hairClone); // isolate from the cached template
514
+ // Overlay at the body instance's transform so size matches the body.
515
+ hairClone.scale.copy(inst.scale);
516
+ hairClone.position.copy(inst.position);
517
+ hairClone.quaternion.copy(inst.quaternion);
518
+ hairClone.traverse((o) => {
519
+ if (!o.isMesh || !o.material) return;
520
+ if (meshRole(o, hairModel) === "hair") {
521
+ o.userData.avatarRole = "hair";
522
+ o.visible = true;
523
+ } else {
524
+ o.visible = false; // we only want the hair from this clone
525
+ }
526
+ });
527
+ group.add(hairClone);
528
+ }
529
+
530
+ /** Which model supplies each accessory (it's baked into that model). */
531
+ const ACCESSORY_SRC = { hat: "classic", glasses: "glasses", headphones: "casual", cap: "street", crown: "royal", santa: "xmas", shades: "xmas", tophat: "style6" };
532
+ const ACCESSORY_ROLES = ["hat", "glasses", "headphones", "cap", "crown", "santa", "shades", "tophat"];
533
+
534
+ /** Swap the avatar's accessory · independent of body style. Hides the
535
+ * body's OWN accessory, then (if `accStyle` isn't "none" and isn't the
536
+ * body's own) overlays it from its source model via the same sibling-
537
+ * clone trick as hair (size from the body transform; head fit from the
538
+ * shared chibi proportions). */
539
+ function swapAccessory(group, inst, bodyModel, accStyle) {
540
+ // Show only the body's own accessory mesh that matches accStyle; hide
541
+ // any other built-in accessory (hat / glasses) it carries.
542
+ inst.traverse((o) => {
543
+ if (!o.isMesh || !o.material) return;
544
+ const r = meshRole(o, bodyModel);
545
+ if (ACCESSORY_ROLES.includes(r)) o.visible = (r === accStyle);
546
+ });
547
+ if (accStyle === "none") return;
548
+ if (bodyModel.accessory === accStyle) return; // own accessory already shown
549
+ const srcId = ACCESSORY_SRC[accStyle];
550
+ const srcModel = resolveModel(srcId);
551
+ const tpl = _templates.get(srcModel.id);
552
+ if (!tpl) return; // source not loaded → just no accessory rather than break
553
+
554
+ const clone = cloneSkeleton(tpl);
555
+ cloneMaterialsInPlace(clone); // isolate from the cached template
556
+ clone.scale.copy(inst.scale);
557
+ clone.position.copy(inst.position);
558
+ clone.quaternion.copy(inst.quaternion);
559
+ clone.traverse((o) => {
560
+ if (!o.isMesh || !o.material) return;
561
+ if (meshRole(o, srcModel) === accStyle) {
562
+ o.userData.avatarRole = accStyle;
563
+ o.visible = true;
564
+ } else {
565
+ o.visible = false; // only borrow the accessory from this clone
566
+ }
567
+ });
568
+ group.add(clone);
569
+ }
570
+
571
+ /** Swap the avatar's clothing · independent of body style. `outfitStyle`
572
+ * is a model id whose outfit (role "outfit") meshes get borrowed. Same
573
+ * sibling-clone overlay as hair / accessory: hide the body's own outfit,
574
+ * then overlay the source model's outfit at the body transform. Passing
575
+ * the body's own id (or null) keeps the built-in clothing. */
576
+ function swapOutfitStyle(group, inst, bodyModel, outfitStyle) {
577
+ if (!outfitStyle || outfitStyle === bodyModel.id) return; // keep own clothing
578
+ const srcModel = resolveModel(outfitStyle);
579
+ const tpl = _templates.get(srcModel.id);
580
+ if (!tpl) return; // source not loaded → keep own rather than go nude
581
+
582
+ // Hide the body's own outfit only once we know we can replace it.
583
+ inst.traverse((o) => {
584
+ if (o.isMesh && o.material && meshRole(o, bodyModel) === "outfit") o.visible = false;
585
+ });
586
+
587
+ const clone = cloneSkeleton(tpl);
588
+ cloneMaterialsInPlace(clone); // isolate from the cached template
589
+ clone.scale.copy(inst.scale);
590
+ clone.position.copy(inst.position);
591
+ clone.quaternion.copy(inst.quaternion);
592
+ clone.traverse((o) => {
593
+ if (!o.isMesh || !o.material) return;
594
+ if (meshRole(o, srcModel) === "outfit") {
595
+ o.userData.avatarRole = "outfit";
596
+ o.visible = true;
597
+ } else {
598
+ o.visible = false; // only borrow the clothing from this clone
599
+ }
600
+ });
601
+ group.add(clone);
602
+ }
603
+
604
+ /** Generic single-role overlay · hide the body's own meshes of `role`, then
605
+ * overlay that role's meshes from `srcModelId` via the same sibling-clone
606
+ * trick (size from the body transform). Used by the eyebrow + tie dimensions.
607
+ * `srcModelId` === the body's id (or null) keeps the body's own part. */
608
+ function overlayRole(group, inst, bodyModel, srcModelId, role) {
609
+ if (!srcModelId || srcModelId === bodyModel.id) return; // keep own
610
+ const srcModel = resolveModel(srcModelId);
611
+ const tpl = _templates.get(srcModel.id);
612
+ if (!tpl) return; // source not loaded → keep own rather than break
613
+
614
+ inst.traverse((o) => {
615
+ if (o.isMesh && o.material && meshRole(o, bodyModel) === role) o.visible = false;
616
+ });
617
+
618
+ const clone = cloneSkeleton(tpl);
619
+ cloneMaterialsInPlace(clone); // isolate from the cached template
620
+ clone.scale.copy(inst.scale);
621
+ clone.position.copy(inst.position);
622
+ clone.quaternion.copy(inst.quaternion);
623
+ clone.traverse((o) => {
624
+ if (!o.isMesh || !o.material) return;
625
+ if (meshRole(o, srcModel) === role) {
626
+ o.userData.avatarRole = role;
627
+ o.visible = true;
628
+ } else {
629
+ o.visible = false; // only borrow this role from the clone
630
+ }
631
+ });
632
+ group.add(clone);
633
+ }
634
+
635
+ /** Single colour/finish pass. Respects a pre-set `userData.avatarRole`
636
+ * (set by swapHair / swapAccessory on overlaid cross-model parts, whose
637
+ * material colour won't match the body model's role map). Visibility of
638
+ * hair / accessory is owned by the swap helpers, not here. */
639
+ function paintInstance(inst, model, colors, doTint) {
640
+ inst.traverse((o) => {
641
+ if (!o.isMesh || !o.material) return;
642
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
643
+ const pre = o.userData && o.userData.avatarRole;
644
+ let role = pre || "other";
645
+ const out = mats.map((m) => {
646
+ const r = pre || resolveRole(m, model);
647
+ if (r !== "other") role = r;
648
+ if (doTint) {
649
+ if (r === "skin") { m.color.set(colors.skin); return applyFinish(m, FINISH.skin); }
650
+ if (r === "hair") { m.color.set(colors.hair); return applyFinish(m, FINISH.hair); }
651
+ if (r === "brow") { m.color.set(colors.brow); return applyFinish(m, FINISH.brow); }
652
+ if (r === "tie") { m.color.set(colors.tie); return applyFinish(m, FINISH.outfit); }
653
+ if (r === "outfit") { m.color.set(colors.outfit); return applyFinish(m, FINISH.outfit); }
654
+ if (r === "eye") { m.color.set(colors.eye); return applyFinish(m, FINISH.eye); }
655
+ }
656
+ return m; // teeth / mouth / eyewhite / glasses / hat / other → untouched
657
+ });
658
+ o.material = Array.isArray(o.material) ? out : out[0];
659
+ if (!pre) o.userData.avatarRole = role; // don't clobber swap tags
660
+ });
661
+ }
662
+
663
+ /** Hair styles offered by the customizer · one per loaded model GLB, plus
664
+ * "none". Each id maps to the model whose hair to borrow. */
665
+ export const HAIR_STYLES = [
666
+ { id: "classic", label: "短发" },
667
+ { id: "glasses", label: "丸子头" },
668
+ { id: "casual", label: "蓬松/乱发" },
669
+ { id: "street", label: "街头短发" },
670
+ { id: "royal", label: "中长发/刘海" },
671
+ { id: "xmas", label: "披肩长发" },
672
+ { id: "style6", label: "低马尾" },
673
+ { id: "none", label: "无 (光头)" },
674
+ ];
675
+
676
+ /** Eyebrow-shape dimension · "default" keeps the body's own brows; each model
677
+ * id overlays that model's brow mesh (role "brow"), still tinted by 眉色. */
678
+ export const BROW_STYLES = [
679
+ { id: "default", label: "默认" },
680
+ { id: "royal", label: "浓眉" },
681
+ { id: "xmas", label: "自然眉" },
682
+ ];
683
+
684
+ /** Neckwear dimension · independent toggle (overlaid from its source, role
685
+ * "tie"): a tie or a bow. */
686
+ export const TIE_STYLES = [
687
+ { id: "none", label: "无" },
688
+ { id: "royal", label: "领带" },
689
+ { id: "xmas", label: "蝴蝶结" },
690
+ ];
691
+
692
+ /** Eye-shape dimension · "default" keeps the body's own eyes; each model id
693
+ * overlays that model's eye mesh (role "eye"), still tinted by 瞳色. The
694
+ * pupil COLOUR is a separate dimension (AVATAR_PALETTES.eye). */
695
+ export const EYE_STYLES = [
696
+ { id: "default", label: "默认" },
697
+ { id: "style6", label: "圆亮眼" },
698
+ ];
699
+
700
+ /** Clothing styles offered by the customizer · an independent dimension.
701
+ * Each id is a model whose outfit (role "outfit") is overlaid onto the
702
+ * body. The body's own clothing is the default for each style. */
703
+ export const OUTFIT_STYLES = [
704
+ { id: "classic", label: "西装" },
705
+ { id: "glasses", label: "蓝白校园" },
706
+ { id: "casual", label: "T恤短裤" },
707
+ { id: "street", label: "黑T短裤·街头" },
708
+ { id: "style6", label: "背心裙" },
709
+ ];
710
+
711
+ /** Accessory styles offered by the customizer · an independent dimension.
712
+ * Each (non-"none") id is overlaid from its source model. */
713
+ export const ACCESSORY_STYLES = [
714
+ { id: "none", label: "无" },
715
+ { id: "glasses", label: "眼镜" },
716
+ { id: "shades", label: "墨镜" },
717
+ { id: "hat", label: "帽子" },
718
+ { id: "headphones", label: "耳机" },
719
+ { id: "cap", label: "鸭舌帽" },
720
+ { id: "crown", label: "王冠" },
721
+ { id: "santa", label: "圣诞帽" },
722
+ { id: "tophat", label: "礼帽" },
723
+ ];
724
+
725
+ /** Live-recolour an existing avatar Group without rebuilding (customizer
726
+ * instant feedback). `colors` is a partial `{ skin, hair, brow, outfit, tie, eye }`. */
727
+ export function recolorAvatar(group, colors = {}) {
728
+ if (!group) return;
729
+ group.traverse((o) => {
730
+ if (!o.isMesh || !o.material) return;
731
+ const hex = colors[o.userData && o.userData.avatarRole];
732
+ if (!hex) return;
733
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
734
+ for (const m of mats) if (m.color) m.color.set(hex);
735
+ });
736
+ }
737
+
738
+ /** Deterministic per-seed default avatar config · the same `seed` always
739
+ * yields the same look. Used so an un-customized director shows an
740
+ * identical, distinct avatar in BOTH the editor and the voice room (the
741
+ * room and editor MUST call this with the same seed, e.g. the director id).
742
+ * Returns the persisted config shape `{model,hairStyle,outfitStyle,
743
+ * accessory,skin,hair,brow,outfit}`. Brows default to the hair colour. */
744
+ export function deriveDefaultAvatarConfig(seed) {
745
+ const rng = makeRng("av3d:" + String(seed == null ? "default" : seed));
746
+ const pickId = (list) => list[Math.floor(rng() * list.length) % list.length].id;
747
+ // Only full bodies can be the base model · `partsOnly` entries (e.g. the
748
+ // street cap/outfit source) contribute parts but aren't standalone bodies.
749
+ const bodies = AVATAR_MODELS.filter((m) => !m.partsOnly);
750
+ const model = bodies[Math.floor(rng() * bodies.length) % bodies.length];
751
+ const hairChoices = HAIR_STYLES.filter((h) => h.id !== "none"); // not bald by default
752
+ const hair = pick(HAIR_COLORS, rng);
753
+ return {
754
+ model: model.id,
755
+ hairStyle: hairChoices[Math.floor(rng() * hairChoices.length) % hairChoices.length].id,
756
+ outfitStyle: pickId(OUTFIT_STYLES),
757
+ accessory: pickId(ACCESSORY_STYLES),
758
+ browStyle: "default", // keep the body's own brows by default
759
+ tieStyle: "none", // no tie by default
760
+ eyeStyle: "default", // keep the body's own eyes by default
761
+ skin: pick(SKIN_TONES, rng),
762
+ hair,
763
+ brow: hair,
764
+ outfit: pick(OUTFIT_COLORS, rng),
765
+ tie: pick(OUTFIT_COLORS, rng), // neckwear colour (only shows when a tie/bow is on)
766
+ eye: pick(EYE_COLORS, rng), // iris / pupil colour
767
+ };
768
+ }
769
+
770
+ /** Reliable face mesh roles · used by getFaceBox to compute a head-
771
+ * anchor for portrait framing. Stays head-only: ear/eyewhite/shoes
772
+ * / etc. would drag the box to the wrong region. */
773
+ const FACE_ROLES = ["eye", "brow", "mouth", "teeth"];
774
+
775
+ /** Compute the world-space bounding box of the avatar's face mesh
776
+ * set (eye / brow / mouth / teeth). Used by `applyFaceFraming` to
777
+ * anchor head-and-shoulders portraits at a consistent zoom across
778
+ * hairstyles / accessories — the avatar's total height (with hats
779
+ * or crowns) varies, but the face doesn't. Returns null when no
780
+ * face roles are present (e.g. body still building). */
781
+ export function getFaceBox(group) {
782
+ if (!group) return null;
783
+ group.updateMatrixWorld(true);
784
+ const box = new THREE.Box3();
785
+ let any = false;
786
+ group.traverse((o) => {
787
+ if (o.isMesh && o.visible && o.userData && FACE_ROLES.includes(o.userData.avatarRole)) {
788
+ box.expandByObject(o);
789
+ any = true;
790
+ }
791
+ });
792
+ return any ? box : null;
793
+ }
794
+
795
+ /** Position `cam` for a consistent head-and-shoulders portrait of
796
+ * `group`. Used by the avatar-customizer's `capturePng` + by the
797
+ * new-agent composer's "hire a known mind" cards + anywhere else
798
+ * the app needs a face-anchored portrait. The TOP / BOTTOM
799
+ * multipliers are in units of face height; verified across every
800
+ * preset look. Fallback (no face mesh) lands a reasonable upper-
801
+ * body crop so the caller still gets something usable. */
802
+ export function applyFaceFraming(cam, group) {
803
+ const face = getFaceBox(group);
804
+ if (face) {
805
+ const fc = face.getCenter(new THREE.Vector3());
806
+ const faceH = Math.max(face.max.y - face.min.y, 1e-3);
807
+ const TOP = 2.0, BOTTOM = 1.5;
808
+ const topY = fc.y + TOP * faceH, botY = fc.y - BOTTOM * faceH;
809
+ const lookY = (topY + botY) / 2, spanY = topY - botY;
810
+ const dist = (spanY / 2) / Math.tan((cam.fov * Math.PI / 180) / 2);
811
+ cam.position.set(fc.x, lookY, fc.z + dist);
812
+ cam.lookAt(fc.x, lookY, fc.z);
813
+ } else {
814
+ cam.position.set(0, 1.4, 1.6);
815
+ cam.lookAt(0, 1.22, 0);
816
+ }
817
+ cam.updateProjectionMatrix();
818
+ }
819
+
820
+ /** Toggle a part by role · "hat", "glasses" (frame + lens), or "headphones". */
821
+ export function setAvatarPartVisible(group, part, visible) {
822
+ if (!group) return;
823
+ group.traverse((o) => {
824
+ if (o.isMesh && o.userData && o.userData.avatarRole === part) o.visible = !!visible;
825
+ });
826
+ }
827
+
828
+ if (typeof window !== "undefined") {
829
+ window.Avatar3D = {
830
+ loadAvatar3D, buildAvatar3D, isAvatar3DReady, recolorAvatar, setAvatarPartVisible,
831
+ deriveDefaultAvatarConfig,
832
+ getFaceBox, applyFaceFraming,
833
+ DEFAULT_AVATAR_URL, AVATAR_MODELS, AVATAR_PALETTES, HAIR_STYLES, OUTFIT_STYLES, ACCESSORY_STYLES,
834
+ BROW_STYLES, TIE_STYLES, EYE_STYLES,
835
+ };
836
+ }