privateboard 0.1.37 → 0.1.40

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