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.
- package/dist/boot.js +1415 -91
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +1415 -91
- package/dist/cli.js.map +1 -1
- package/dist/server.js +1271 -81
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/public/__avatar3d_test.html +156 -0
- package/public/adjourn-overlay.css +2 -2
- package/public/agent-overlay.css +27 -15
- package/public/agent-overlay.js +3 -1
- package/public/agent-profile.css +331 -41
- package/public/agent-profile.js +499 -75
- package/public/app-updater.css +1 -1
- package/public/app.js +2090 -547
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +792 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +481 -0
- package/public/avatars/3d/chair.png +0 -0
- package/public/avatars/3d/first-principles.png +0 -0
- package/public/avatars/3d/historian.png +0 -0
- package/public/avatars/3d/long-horizon.png +0 -0
- package/public/avatars/3d/phenomenologist.png +0 -0
- package/public/avatars/3d/socrates.png +0 -0
- package/public/avatars/3d/user-empathy.png +0 -0
- package/public/avatars/3d/value-investor.png +0 -0
- package/public/core-avatars.js +86 -0
- package/public/home-3d-loader.js +15 -4
- package/public/home-3d-mock.js +18 -7
- package/public/home.html +80 -18
- package/public/i18n.js +279 -4
- package/public/icons/avatar_1779855104027.glb +0 -0
- package/public/icons/logo.png +0 -0
- package/public/icons/new-style.glb +0 -0
- package/public/icons/new-style2.glb +0 -0
- package/public/icons/new-style3.glb +0 -0
- package/public/icons/new-style4.glb +0 -0
- package/public/icons/new-style5.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +203 -182
- package/public/mention-picker.js +1 -1
- package/public/new-agent.css +7 -7
- package/public/new-agent.js +46 -20
- package/public/office-viewer.html +340 -0
- package/public/onboarding.css +5 -5
- package/public/quote-cta.css +5 -4
- package/public/quote-cta.js +50 -5
- package/public/room-settings.css +24 -9
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +1211 -0
- package/public/user-settings.css +16 -19
- package/public/user-settings.js +86 -78
- package/public/vendor/BufferGeometryUtils.js +1434 -0
- package/public/vendor/DRACOLoader.js +739 -0
- package/public/vendor/GLTFLoader.js +4860 -0
- package/public/vendor/RoomEnvironment.js +185 -0
- package/public/vendor/SkeletonUtils.js +496 -0
- package/public/vendor/draco/draco_decoder.js +34 -0
- package/public/vendor/draco/draco_decoder.wasm +0 -0
- package/public/vendor/draco/draco_encoder.js +33 -0
- package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
- package/public/vendor/meshopt_decoder.module.js +196 -0
- package/public/voice-3d-banner.js +12 -0
- package/public/voice-3d.js +1407 -432
- package/public/voice-clone.css +875 -0
- package/public/voice-clone.js +1351 -0
- package/public/voice-replay.css +3 -3
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- 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
|
+
}
|