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.
- package/dist/boot.js +327 -50
- package/dist/boot.js.map +1 -1
- package/dist/cli.js +327 -50
- package/dist/cli.js.map +1 -1
- package/dist/server.js +201 -40
- 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/agent-overlay.css +14 -6
- package/public/agent-overlay.js +6 -6
- package/public/agent-profile.css +9 -12
- package/public/agent-profile.js +91 -38
- package/public/app.js +471 -528
- package/public/avatar-3d-snap.js +205 -0
- package/public/avatar-3d.js +836 -0
- package/public/avatar-customizer.html +274 -0
- package/public/avatar3d-editor.css +240 -0
- package/public/avatar3d-editor.js +484 -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 +25 -14
- package/public/home.html +78 -16
- package/public/i18n.js +8 -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/new-style6.glb +0 -0
- package/public/icons/office.glb +0 -0
- package/public/icons/stuff.glb +0 -0
- package/public/index.html +169 -141
- package/public/magazine.html +1 -1
- package/public/new-agent.js +46 -20
- package/public/newspaper.html +1 -1
- package/public/office-viewer.html +340 -0
- package/public/ppt.html +1 -1
- package/public/stuff-viewer.html +330 -0
- package/public/thread.css +16 -15
- package/public/user-settings.css +7 -31
- package/public/user-settings.js +75 -89
- 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 +19 -7
- package/public/voice-3d.js +1407 -432
- package/public/voice-replay.js +21 -0
- package/public/avatar-skill.js +0 -629
- package/public/avatars/chair-blink.svg +0 -1
- package/public/avatars/chair.svg +0 -1
- package/public/avatars/first-principles.svg +0 -1
- package/public/avatars/historian.svg +0 -1
- package/public/avatars/long-horizon.svg +0 -1
- package/public/avatars/phenomenologist.svg +0 -1
- package/public/avatars/socrates.svg +0 -1
- package/public/avatars/user-empathy.svg +0 -1
- package/public/avatars/value-investor.svg +0 -1
- package/public/icons/folded-sidebar.png +0 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════
|
|
2
|
+
3D AVATAR EDITOR (捏 avatar) · profile overlay
|
|
3
|
+
───────────────────────────────────────────
|
|
4
|
+
Opened from the director profile's ⋯ menu (data-ap-menu-action=
|
|
5
|
+
"edit-avatar3d") via window.openAvatar3DEditor(slug). Left pane is a
|
|
6
|
+
three.js stage; right pane is the customizer panel. On Save it renders a
|
|
7
|
+
PNG portrait and PATCHes { avatarPath: png, avatar3d: config } so the 2D
|
|
8
|
+
avatar everywhere updates AND the voice room can rebuild the 3D figure.
|
|
9
|
+
|
|
10
|
+
three.js is imported lazily on first open so it doesn't cost every page
|
|
11
|
+
load. Mirrors the room-settings overlay open/close/dismiss conventions.
|
|
12
|
+
═══════════════════════════════════════════ */
|
|
13
|
+
(function () {
|
|
14
|
+
// ── DOM (built once) ──────────────────────────────────────────────
|
|
15
|
+
let overlay = null, modal = null, stageEl = null, panelBody = null;
|
|
16
|
+
let statusEl = null, saveBtn = null;
|
|
17
|
+
|
|
18
|
+
// ── three.js + scene (loaded lazily, kept across opens) ───────────
|
|
19
|
+
let THREE = null, av = null, OrbitControls = null, RoomEnvironment = null;
|
|
20
|
+
let renderer = null, scene = null, camera = null, controls = null;
|
|
21
|
+
let pmrem = null, ground = null, group = null, ro = null;
|
|
22
|
+
let booted = false; // scene built
|
|
23
|
+
let raf = null;
|
|
24
|
+
|
|
25
|
+
// ── per-open state ────────────────────────────────────────────────
|
|
26
|
+
// target · who we're editing. { type:"agent", slug } saves to the agent
|
|
27
|
+
// record; { type:"user" } saves to the user's prefs.
|
|
28
|
+
let target = null;
|
|
29
|
+
let buildSeed = "editor"; // seed passed to buildAvatar3D (deterministic look)
|
|
30
|
+
let sel = null; // { model, hairStyle, outfitStyle, accessory, skin, hair, brow, outfit }
|
|
31
|
+
let saving = false;
|
|
32
|
+
|
|
33
|
+
const FULL_CAM = { pos: [0.7, 1.45, 2.7], target: [0, 0.9, 0] };
|
|
34
|
+
const AVATAR_HEIGHT = 1.6;
|
|
35
|
+
|
|
36
|
+
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
|
|
37
|
+
|
|
38
|
+
/* ── DOM scaffold ────────────────────────────────────────────────── */
|
|
39
|
+
function buildDom() {
|
|
40
|
+
if (overlay) return;
|
|
41
|
+
overlay = document.createElement("div");
|
|
42
|
+
overlay.className = "avatar3d-overlay";
|
|
43
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
44
|
+
overlay.innerHTML = `
|
|
45
|
+
<div class="avatar3d-modal" role="dialog" aria-modal="true" aria-label="3D avatar editor">
|
|
46
|
+
<div class="av3d-stage">
|
|
47
|
+
<div class="av3d-hint">拖动旋转 · 滚轮缩放</div>
|
|
48
|
+
<div class="av3d-status"></div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="av3d-panel">
|
|
51
|
+
<div class="av3d-head">
|
|
52
|
+
<h2 class="av3d-kicker">捏 avatar</h2>
|
|
53
|
+
<button type="button" class="av3d-close" data-av3d-close aria-label="Close">✕</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="av3d-body">
|
|
56
|
+
<div class="av3d-grp"><div class="av3d-lab">发型 Hair</div><div class="av3d-sw is-pill" data-row="hairStyle"></div></div>
|
|
57
|
+
<div class="av3d-grp"><div class="av3d-lab">眼眉 Brow</div><div class="av3d-sw is-pill" data-row="browStyle"></div></div>
|
|
58
|
+
<div class="av3d-grp"><div class="av3d-lab">眼睛 Eyes</div><div class="av3d-sw is-pill" data-row="eyeStyle"></div></div>
|
|
59
|
+
<div class="av3d-grp"><div class="av3d-lab">衣服款式 Outfit</div><div class="av3d-sw is-pill" data-row="outfitStyle"></div></div>
|
|
60
|
+
<div class="av3d-grp"><div class="av3d-lab">领带 Tie</div><div class="av3d-sw is-pill" data-row="tieStyle"></div></div>
|
|
61
|
+
<div class="av3d-grp"><div class="av3d-lab">装饰 Accessory</div><div class="av3d-sw is-pill" data-row="accessory"></div></div>
|
|
62
|
+
<div class="av3d-grp"><div class="av3d-lab">肤色 Skin</div><div class="av3d-sw is-color" data-row="skin"></div></div>
|
|
63
|
+
<div class="av3d-grp"><div class="av3d-lab">发色 Hair</div><div class="av3d-sw is-color" data-row="hair"></div></div>
|
|
64
|
+
<div class="av3d-grp"><div class="av3d-lab">眉色 Brow</div><div class="av3d-sw is-color" data-row="brow"></div></div>
|
|
65
|
+
<div class="av3d-grp"><div class="av3d-lab">瞳色 Eye</div><div class="av3d-sw is-color" data-row="eye"></div></div>
|
|
66
|
+
<div class="av3d-grp"><div class="av3d-lab">衣色 Outfit color</div><div class="av3d-sw is-color" data-row="outfit"></div></div>
|
|
67
|
+
<div class="av3d-grp"><div class="av3d-lab">颈饰色 Tie/Bow</div><div class="av3d-sw is-color" data-row="tie"></div></div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="av3d-foot">
|
|
70
|
+
<button type="button" class="av3d-btn-rand" data-av3d-rand title="随机">🎲</button>
|
|
71
|
+
<button type="button" class="av3d-btn-cancel" data-av3d-close>取消</button>
|
|
72
|
+
<button type="button" class="av3d-btn-save" data-av3d-save>保存</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>`;
|
|
76
|
+
document.body.appendChild(overlay);
|
|
77
|
+
modal = overlay.querySelector(".avatar3d-modal");
|
|
78
|
+
stageEl = overlay.querySelector(".av3d-stage");
|
|
79
|
+
panelBody = overlay.querySelector(".av3d-body");
|
|
80
|
+
statusEl = overlay.querySelector(".av3d-status");
|
|
81
|
+
saveBtn = overlay.querySelector("[data-av3d-save]");
|
|
82
|
+
|
|
83
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
|
84
|
+
overlay.querySelectorAll("[data-av3d-close]").forEach((b) =>
|
|
85
|
+
b.addEventListener("click", (e) => { e.preventDefault(); close(); }));
|
|
86
|
+
overlay.querySelector("[data-av3d-rand]").addEventListener("click", (e) => { e.preventDefault(); randomize(); });
|
|
87
|
+
saveBtn.addEventListener("click", (e) => { e.preventDefault(); void save(); });
|
|
88
|
+
// Escape closes the editor only (stop it from also closing the profile,
|
|
89
|
+
// which listens in capture phase).
|
|
90
|
+
document.addEventListener("keydown", (e) => {
|
|
91
|
+
if (e.key === "Escape" && overlay.classList.contains("open")) {
|
|
92
|
+
e.stopImmediatePropagation();
|
|
93
|
+
close();
|
|
94
|
+
}
|
|
95
|
+
}, true);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── three.js scene · built once on first open ───────────────────── */
|
|
99
|
+
async function ensureScene() {
|
|
100
|
+
if (booted) return;
|
|
101
|
+
THREE = await import("/vendor/three.module.min.js");
|
|
102
|
+
({ OrbitControls } = await import("/vendor/OrbitControls.js"));
|
|
103
|
+
({ RoomEnvironment } = await import("/vendor/RoomEnvironment.js"));
|
|
104
|
+
av = await import("/avatar-3d.js");
|
|
105
|
+
|
|
106
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
107
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
108
|
+
renderer.shadowMap.enabled = true;
|
|
109
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
110
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
111
|
+
renderer.toneMappingExposure = 0.7;
|
|
112
|
+
renderer.setClearColor(0x000000, 0); // transparent · CSS gradient shows through
|
|
113
|
+
stageEl.appendChild(renderer.domElement);
|
|
114
|
+
|
|
115
|
+
scene = new THREE.Scene();
|
|
116
|
+
camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
|
|
117
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
118
|
+
controls.enableDamping = true; controls.dampingFactor = 0.1;
|
|
119
|
+
controls.minDistance = 1.4; controls.maxDistance = 5;
|
|
120
|
+
controls.enablePan = false;
|
|
121
|
+
|
|
122
|
+
pmrem = new THREE.PMREMGenerator(renderer);
|
|
123
|
+
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
124
|
+
scene.environmentIntensity = 0.35;
|
|
125
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x33384a, 0.18));
|
|
126
|
+
const key = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
127
|
+
key.position.set(3, 6, 4); key.castShadow = true; scene.add(key);
|
|
128
|
+
const rim = new THREE.DirectionalLight(0xbfd4ff, 0.3);
|
|
129
|
+
rim.position.set(-4, 3, -3); scene.add(rim);
|
|
130
|
+
ground = new THREE.Mesh(
|
|
131
|
+
new THREE.CircleGeometry(2.0, 48),
|
|
132
|
+
new THREE.MeshStandardMaterial({ color: 0x20232a, roughness: 1 }),
|
|
133
|
+
);
|
|
134
|
+
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
|
|
135
|
+
|
|
136
|
+
applyCamera(FULL_CAM);
|
|
137
|
+
ro = new ResizeObserver(() => resize());
|
|
138
|
+
ro.observe(stageEl);
|
|
139
|
+
raf = renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
|
|
140
|
+
booted = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function applyCamera({ pos, target }) {
|
|
144
|
+
camera.position.set(pos[0], pos[1], pos[2]);
|
|
145
|
+
controls.target.set(target[0], target[1], target[2]);
|
|
146
|
+
controls.update();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resize() {
|
|
150
|
+
if (!renderer || !stageEl) return;
|
|
151
|
+
const w = stageEl.clientWidth || 1, h = stageEl.clientHeight || 1;
|
|
152
|
+
renderer.setSize(w, h, false);
|
|
153
|
+
renderer.domElement.style.width = w + "px";
|
|
154
|
+
renderer.domElement.style.height = h + "px";
|
|
155
|
+
camera.aspect = w / h;
|
|
156
|
+
camera.updateProjectionMatrix();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ── build / recolour the avatar from `sel` ──────────────────────── */
|
|
160
|
+
function rebuild() {
|
|
161
|
+
if (group) { scene.remove(group); group = null; }
|
|
162
|
+
group = av.buildAvatar3D(buildSeed || "editor", {
|
|
163
|
+
model: sel.model, hairStyle: sel.hairStyle, outfitStyle: sel.outfitStyle,
|
|
164
|
+
browStyle: sel.browStyle, tieStyle: sel.tieStyle, eyeStyle: sel.eyeStyle,
|
|
165
|
+
accessory: sel.accessory, height: AVATAR_HEIGHT,
|
|
166
|
+
skin: sel.skin, hair: sel.hair, brow: sel.brow, outfit: sel.outfit, tie: sel.tie, eye: sel.eye,
|
|
167
|
+
});
|
|
168
|
+
if (group) scene.add(group);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── panel population ────────────────────────────────────────────── */
|
|
172
|
+
// Body style ("model") is intentionally NOT user-selectable — it's fixed by
|
|
173
|
+
// the saved/derived config; the user shapes the look via hair / outfit /
|
|
174
|
+
// accessory / colours instead.
|
|
175
|
+
const PILL_LISTS = () => ({
|
|
176
|
+
hairStyle: av.HAIR_STYLES,
|
|
177
|
+
browStyle: av.BROW_STYLES,
|
|
178
|
+
eyeStyle: av.EYE_STYLES,
|
|
179
|
+
outfitStyle: av.OUTFIT_STYLES,
|
|
180
|
+
tieStyle: av.TIE_STYLES,
|
|
181
|
+
accessory: av.ACCESSORY_STYLES,
|
|
182
|
+
});
|
|
183
|
+
const COLOR_LISTS = () => ({
|
|
184
|
+
skin: av.AVATAR_PALETTES.skin,
|
|
185
|
+
hair: av.AVATAR_PALETTES.hair,
|
|
186
|
+
brow: av.AVATAR_PALETTES.brow,
|
|
187
|
+
eye: av.AVATAR_PALETTES.eye,
|
|
188
|
+
outfit: av.AVATAR_PALETTES.outfit,
|
|
189
|
+
tie: av.AVATAR_PALETTES.tie,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
function buildPanel() {
|
|
193
|
+
const pills = PILL_LISTS();
|
|
194
|
+
for (const role of Object.keys(pills)) {
|
|
195
|
+
const c = panelBody.querySelector(`[data-row="${role}"]`);
|
|
196
|
+
c.innerHTML = "";
|
|
197
|
+
pills[role].forEach((opt) => {
|
|
198
|
+
const b = document.createElement("button");
|
|
199
|
+
b.type = "button";
|
|
200
|
+
b.textContent = opt.label;
|
|
201
|
+
if (opt.id === sel[role]) b.classList.add("on");
|
|
202
|
+
b.addEventListener("click", () => {
|
|
203
|
+
if (sel[role] === opt.id) return;
|
|
204
|
+
sel[role] = opt.id;
|
|
205
|
+
markOn(c, b);
|
|
206
|
+
rebuild(); // hair / outfit / accessory all overlay from preloaded models
|
|
207
|
+
});
|
|
208
|
+
c.appendChild(b);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const colors = COLOR_LISTS();
|
|
212
|
+
for (const role of Object.keys(colors)) {
|
|
213
|
+
const c = panelBody.querySelector(`[data-row="${role}"]`);
|
|
214
|
+
c.innerHTML = "";
|
|
215
|
+
colors[role].forEach((hex) => {
|
|
216
|
+
const b = document.createElement("button");
|
|
217
|
+
b.type = "button";
|
|
218
|
+
b.style.background = hex;
|
|
219
|
+
b.title = hex;
|
|
220
|
+
if (String(sel[role]).toLowerCase() === hex.toLowerCase()) b.classList.add("on");
|
|
221
|
+
b.addEventListener("click", () => {
|
|
222
|
+
sel[role] = hex;
|
|
223
|
+
markOn(c, b);
|
|
224
|
+
av.recolorAvatar(group, { [role]: hex }); // instant, no rebuild
|
|
225
|
+
});
|
|
226
|
+
c.appendChild(b);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function markOn(container, btn) {
|
|
232
|
+
container.querySelectorAll("button").forEach((x) => x.classList.remove("on"));
|
|
233
|
+
btn.classList.add("on");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function randomize() {
|
|
237
|
+
const P = av.AVATAR_PALETTES;
|
|
238
|
+
const r = (a) => a[Math.floor(Math.random() * a.length)];
|
|
239
|
+
sel.skin = r(P.skin); sel.hair = r(P.hair); sel.brow = r(P.brow); sel.outfit = r(P.outfit); sel.tie = r(P.tie); sel.eye = r(P.eye);
|
|
240
|
+
av.recolorAvatar(group, sel);
|
|
241
|
+
// re-mark the colour swatches
|
|
242
|
+
for (const role of ["skin", "hair", "brow", "outfit", "tie", "eye"]) {
|
|
243
|
+
const c = panelBody.querySelector(`[data-row="${role}"]`);
|
|
244
|
+
c.querySelectorAll("button").forEach((x) =>
|
|
245
|
+
x.classList.toggle("on", x.title.toLowerCase() === String(sel[role]).toLowerCase()));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── config normalization · saved blob → editor sel ──────────────── */
|
|
250
|
+
function normalizeConfig(cfg) {
|
|
251
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
252
|
+
const need = ["model", "hairStyle", "outfitStyle", "accessory", "skin", "hair", "brow", "outfit"];
|
|
253
|
+
for (const k of need) if (typeof cfg[k] !== "string" || !cfg[k]) return null;
|
|
254
|
+
const out = { ...cfg };
|
|
255
|
+
// Newer dimensions · default when absent (configs saved before they existed).
|
|
256
|
+
if (typeof out.browStyle !== "string" || !out.browStyle) out.browStyle = "default";
|
|
257
|
+
if (typeof out.tieStyle !== "string" || !out.tieStyle) out.tieStyle = "none";
|
|
258
|
+
if (typeof out.eyeStyle !== "string" || !out.eyeStyle) out.eyeStyle = "default";
|
|
259
|
+
if (typeof out.tie !== "string" || !out.tie) out.tie = (av.AVATAR_PALETTES.tie || ["#3b5b78"])[0];
|
|
260
|
+
if (typeof out.eye !== "string" || !out.eye) out.eye = (av.AVATAR_PALETTES.eye || ["#0d0d0d"])[0];
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// World-space bounding box of the avatar's FACE features (eyes / brow /
|
|
265
|
+
// mouth / teeth). Stable across hairstyles + accessories, so it's a reliable
|
|
266
|
+
// anchor for consistent portrait framing. Null if no face meshes are found.
|
|
267
|
+
// NOTE: "eyewhite" is intentionally excluded — some models name their white
|
|
268
|
+
// SHOES "White", which resolveRole tags as eyewhite, dragging the box to the
|
|
269
|
+
// feet. Eye/brow/mouth/teeth are reliably head-only.
|
|
270
|
+
const FACE_ROLES = ["eye", "brow", "mouth", "teeth"];
|
|
271
|
+
function getFaceBox(g) {
|
|
272
|
+
if (!g) return null;
|
|
273
|
+
g.updateMatrixWorld(true);
|
|
274
|
+
const box = new THREE.Box3();
|
|
275
|
+
let any = false;
|
|
276
|
+
g.traverse((o) => {
|
|
277
|
+
if (o.isMesh && o.visible && o.userData && FACE_ROLES.includes(o.userData.avatarRole)) {
|
|
278
|
+
box.expandByObject(o);
|
|
279
|
+
any = true;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return any ? box : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Position `cam` for a consistent head-and-shoulders portrait of `g`, anchored
|
|
286
|
+
// on its face box so head size is uniform across hairstyles / accessories
|
|
287
|
+
// (the avatar's total height — incl. hats/crowns — varies, but the face
|
|
288
|
+
// doesn't). Multipliers in units of face height; verified across all looks.
|
|
289
|
+
function applyFaceFraming(cam, g) {
|
|
290
|
+
const face = getFaceBox(g);
|
|
291
|
+
if (face) {
|
|
292
|
+
const fc = face.getCenter(new THREE.Vector3());
|
|
293
|
+
const faceH = Math.max(face.max.y - face.min.y, 1e-3);
|
|
294
|
+
const TOP = 2.0, BOTTOM = 1.5;
|
|
295
|
+
const topY = fc.y + TOP * faceH, botY = fc.y - BOTTOM * faceH;
|
|
296
|
+
const lookY = (topY + botY) / 2, spanY = topY - botY;
|
|
297
|
+
const dist = (spanY / 2) / Math.tan((cam.fov * Math.PI / 180) / 2);
|
|
298
|
+
cam.position.set(fc.x, lookY, fc.z + dist);
|
|
299
|
+
cam.lookAt(fc.x, lookY, fc.z);
|
|
300
|
+
} else {
|
|
301
|
+
cam.position.set(0, 1.4, 1.6);
|
|
302
|
+
cam.lookAt(0, 1.22, 0);
|
|
303
|
+
}
|
|
304
|
+
cam.updateProjectionMatrix();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ── render a square head/shoulders PNG portrait ─────────────────── */
|
|
308
|
+
function capturePng(size = 384) {
|
|
309
|
+
const prevPos = camera.position.clone();
|
|
310
|
+
const prevTarget = controls.target.clone();
|
|
311
|
+
const prevAspect = camera.aspect;
|
|
312
|
+
const prevW = stageEl.clientWidth || size, prevH = stageEl.clientHeight || size;
|
|
313
|
+
const prevPR = renderer.getPixelRatio();
|
|
314
|
+
const groundVisible = ground.visible;
|
|
315
|
+
|
|
316
|
+
ground.visible = false;
|
|
317
|
+
renderer.setPixelRatio(1);
|
|
318
|
+
renderer.setSize(size, size, false);
|
|
319
|
+
camera.aspect = 1;
|
|
320
|
+
camera.up.set(0, 1, 0);
|
|
321
|
+
|
|
322
|
+
// Consistent head-and-shoulders framing · anchored on the face (shared).
|
|
323
|
+
applyFaceFraming(camera, group);
|
|
324
|
+
renderer.render(scene, camera);
|
|
325
|
+
const url = renderer.domElement.toDataURL("image/png");
|
|
326
|
+
|
|
327
|
+
// restore live view
|
|
328
|
+
ground.visible = groundVisible;
|
|
329
|
+
renderer.setPixelRatio(prevPR);
|
|
330
|
+
renderer.setSize(prevW, prevH, false);
|
|
331
|
+
camera.aspect = prevAspect;
|
|
332
|
+
camera.position.copy(prevPos);
|
|
333
|
+
camera.updateProjectionMatrix();
|
|
334
|
+
controls.target.copy(prevTarget);
|
|
335
|
+
controls.update();
|
|
336
|
+
return url;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ── save · capture PNG + config, persist to the target ──────────── */
|
|
340
|
+
async function save() {
|
|
341
|
+
if (saving || !group || !target) return;
|
|
342
|
+
saving = true; saveBtn.disabled = true; setStatus("保存中…");
|
|
343
|
+
try {
|
|
344
|
+
const png = capturePng();
|
|
345
|
+
const cfg = { ...sel };
|
|
346
|
+
if (target.type === "user") await saveUser(png, cfg);
|
|
347
|
+
else await saveAgent(target.slug, png, cfg);
|
|
348
|
+
close();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
console.error("[avatar3d-editor] save failed", e);
|
|
351
|
+
setStatus("保存失败:" + (e && e.message ? e.message : e));
|
|
352
|
+
} finally {
|
|
353
|
+
saving = false; saveBtn.disabled = false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Director · PATCH /api/agents/:id, then refresh roster + repaint profile.
|
|
358
|
+
async function saveAgent(slug, png, cfg) {
|
|
359
|
+
const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
|
|
360
|
+
const res = await fetch("/api/agents/" + encodeURIComponent(slug), {
|
|
361
|
+
method: "PATCH",
|
|
362
|
+
headers: { "content-type": "application/json" },
|
|
363
|
+
body: JSON.stringify({ avatarPath: png, avatar3d: cfg }),
|
|
364
|
+
});
|
|
365
|
+
if (!res.ok) throw new Error("save failed (" + res.status + ")");
|
|
366
|
+
const updated = await res.json();
|
|
367
|
+
if (live) { live.avatarPath = updated.avatarPath || png; live.avatar3d = updated.avatar3d || cfg; }
|
|
368
|
+
if (window.app && typeof window.app.refreshAgents === "function") await window.app.refreshAgents();
|
|
369
|
+
else if (window.app && typeof window.app.renderSidebarAgents === "function") window.app.renderSidebarAgents();
|
|
370
|
+
if (typeof window.openAgentProfile === "function") window.openAgentProfile(slug);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// User (host) · PUT /api/prefs, then repaint sidebar foot + settings frame.
|
|
374
|
+
async function saveUser(png, cfg) {
|
|
375
|
+
const res = await fetch("/api/prefs", {
|
|
376
|
+
method: "PUT",
|
|
377
|
+
headers: { "content-type": "application/json" },
|
|
378
|
+
body: JSON.stringify({ avatar3d: cfg, avatarUrl: png }),
|
|
379
|
+
});
|
|
380
|
+
if (!res.ok) throw new Error("save failed (" + res.status + ")");
|
|
381
|
+
const prefs = await res.json();
|
|
382
|
+
if (window.app) {
|
|
383
|
+
window.app.prefs = { ...(window.app.prefs || {}), avatar3d: prefs.avatar3d, avatarUrl: prefs.avatarUrl };
|
|
384
|
+
if (typeof window.app.renderUserBlock === "function") window.app.renderUserBlock();
|
|
385
|
+
}
|
|
386
|
+
// Let the user-settings pane repaint its avatar frame if it's open.
|
|
387
|
+
window.dispatchEvent(new CustomEvent("pb:user-avatar-updated", { detail: { avatarUrl: prefs.avatarUrl } }));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ── open / close ────────────────────────────────────────────────── */
|
|
391
|
+
// `arg` is an agent slug (string) or { kind: "user" }.
|
|
392
|
+
async function open(arg) {
|
|
393
|
+
if (arg && arg.kind === "user") {
|
|
394
|
+
target = { type: "user" };
|
|
395
|
+
} else if (typeof arg === "string" && arg) {
|
|
396
|
+
target = { type: "agent", slug: arg };
|
|
397
|
+
} else {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
buildDom();
|
|
401
|
+
overlay.classList.add("open");
|
|
402
|
+
overlay.setAttribute("aria-hidden", "false");
|
|
403
|
+
document.body.style.overflow = "hidden";
|
|
404
|
+
setStatus("加载中…");
|
|
405
|
+
try {
|
|
406
|
+
await ensureScene();
|
|
407
|
+
resize();
|
|
408
|
+
await Promise.all(av.AVATAR_MODELS.map((m) => av.loadAvatar3D(m.id)));
|
|
409
|
+
if (target.type === "user") {
|
|
410
|
+
const u = (window.app && window.app.prefs) || {};
|
|
411
|
+
buildSeed = u.avatarSeed || "user";
|
|
412
|
+
sel = normalizeConfig(u.avatar3d) || av.deriveDefaultAvatarConfig(buildSeed);
|
|
413
|
+
} else {
|
|
414
|
+
const live = window.app && window.app.agentsById ? window.app.agentsById[target.slug] : null;
|
|
415
|
+
buildSeed = target.slug;
|
|
416
|
+
sel = normalizeConfig(live && live.avatar3d) || av.deriveDefaultAvatarConfig(target.slug);
|
|
417
|
+
}
|
|
418
|
+
buildPanel();
|
|
419
|
+
rebuild();
|
|
420
|
+
applyCamera(FULL_CAM);
|
|
421
|
+
setStatus("");
|
|
422
|
+
} catch (e) {
|
|
423
|
+
console.error("[avatar3d-editor] open failed", e);
|
|
424
|
+
setStatus("加载失败:" + (e && e.message ? e.message : e));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function close() {
|
|
429
|
+
if (!overlay) return;
|
|
430
|
+
overlay.classList.remove("open");
|
|
431
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
432
|
+
document.body.style.overflow = "";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* ── Standalone portrait render ───────────────────────────────────
|
|
436
|
+
Render an avatar3d `config` to a head-and-shoulders PNG using a throwaway
|
|
437
|
+
offscreen renderer — same framing + lighting as the editor's capture, but
|
|
438
|
+
without opening the overlay. Used to give newly-created directors a 3D
|
|
439
|
+
screenshot avatar. Returns a PNG data URL (or null on failure). */
|
|
440
|
+
async function renderAvatar3DPortrait(config, size = 384) {
|
|
441
|
+
if (!THREE) THREE = await import("/vendor/three.module.min.js");
|
|
442
|
+
if (!RoomEnvironment) ({ RoomEnvironment } = await import("/vendor/RoomEnvironment.js"));
|
|
443
|
+
if (!av) av = await import("/avatar-3d.js");
|
|
444
|
+
await Promise.all(av.AVATAR_MODELS.map((m) => av.loadAvatar3D(m.id)));
|
|
445
|
+
const cfg = config || av.deriveDefaultAvatarConfig("portrait");
|
|
446
|
+
const cv = document.createElement("canvas"); cv.width = size; cv.height = size;
|
|
447
|
+
const r = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
448
|
+
r.setPixelRatio(1); r.setSize(size, size, false); r.setClearColor(0x000000, 0);
|
|
449
|
+
r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 0.7;
|
|
450
|
+
const sc = new THREE.Scene();
|
|
451
|
+
const pmrem = new THREE.PMREMGenerator(r);
|
|
452
|
+
sc.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
453
|
+
sc.environmentIntensity = 0.35;
|
|
454
|
+
sc.add(new THREE.HemisphereLight(0xffffff, 0x33384a, 0.18));
|
|
455
|
+
const key = new THREE.DirectionalLight(0xffffff, 0.7); key.position.set(3, 6, 4); sc.add(key);
|
|
456
|
+
const rim = new THREE.DirectionalLight(0xbfd4ff, 0.3); rim.position.set(-4, 3, -3); sc.add(rim);
|
|
457
|
+
let url = null;
|
|
458
|
+
try {
|
|
459
|
+
const g = av.buildAvatar3D("portrait", {
|
|
460
|
+
model: cfg.model, hairStyle: cfg.hairStyle, outfitStyle: cfg.outfitStyle,
|
|
461
|
+
browStyle: cfg.browStyle, tieStyle: cfg.tieStyle, eyeStyle: cfg.eyeStyle, accessory: cfg.accessory, height: AVATAR_HEIGHT,
|
|
462
|
+
skin: cfg.skin, hair: cfg.hair, brow: cfg.brow, outfit: cfg.outfit, tie: cfg.tie, eye: cfg.eye,
|
|
463
|
+
});
|
|
464
|
+
if (g) {
|
|
465
|
+
sc.add(g);
|
|
466
|
+
const cam = new THREE.PerspectiveCamera(32, 1, 0.1, 100); cam.up.set(0, 1, 0);
|
|
467
|
+
applyFaceFraming(cam, g);
|
|
468
|
+
r.render(sc, cam);
|
|
469
|
+
url = cv.toDataURL("image/png");
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
// Dispose the renderer (frees this context). Do NOT dispose geometry —
|
|
473
|
+
// it's shared with the cached templates.
|
|
474
|
+
try { pmrem.dispose(); } catch (_) {}
|
|
475
|
+
try { r.forceContextLoss(); } catch (_) {}
|
|
476
|
+
try { r.dispose(); } catch (_) {}
|
|
477
|
+
}
|
|
478
|
+
return url;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
window.openAvatar3DEditor = open;
|
|
482
|
+
window.closeAvatar3DEditor = close;
|
|
483
|
+
window.renderAvatar3DPortrait = renderAvatar3DPortrait;
|
|
484
|
+
})();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
core-avatars.js · canonical 3D-avatar configs for the chair +
|
|
3
|
+
seven core seeded directors.
|
|
4
|
+
|
|
5
|
+
── Why this exists ─────────────────────────────────────────────────
|
|
6
|
+
The voice-3d.js scene (`buildDirectorFigure`) builds a director
|
|
7
|
+
figure from their `member.avatar3d` config when one is present,
|
|
8
|
+
and falls back to a deterministic RNG-driven default otherwise.
|
|
9
|
+
The marketing homepage's CAST (`home-3d-mock.js`), the onboarding
|
|
10
|
+
storyline's banner (`voice-3d-banner.js`), and the in-app voice
|
|
11
|
+
room itself all populate this `member.avatar3d` field — but the
|
|
12
|
+
marketing + onboarding paths previously sent CAST entries with NO
|
|
13
|
+
avatar3d, so they rendered randomised faces that didn't match the
|
|
14
|
+
actual chair / director look the user sees in-app or on the
|
|
15
|
+
product backend (`src/seed/chair.ts` + `src/seed/directors.ts`).
|
|
16
|
+
|
|
17
|
+
This file is the single shared frontend source of truth · classic-
|
|
18
|
+
script so it can be loaded ahead of the classic-script consumers
|
|
19
|
+
without a module dance. Backend (TypeScript) carries the same
|
|
20
|
+
constants in `src/seed/*.ts` — keep them in sync by hand.
|
|
21
|
+
|
|
22
|
+
── How consumers use it ────────────────────────────────────────────
|
|
23
|
+
// After CAST is defined:
|
|
24
|
+
if (window.PB_CORE_AVATARS) {
|
|
25
|
+
for (const m of CAST) {
|
|
26
|
+
const cfg = window.PB_CORE_AVATARS[m.id];
|
|
27
|
+
if (cfg) m.avatar3d = cfg;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
That's it · `voice-3d.js` does the rest of the rigging.
|
|
31
|
+
═══════════════════════════════════════════════════════════════════ */
|
|
32
|
+
(function (root) {
|
|
33
|
+
if (root.PB_CORE_AVATARS) return; // idempotent · safe to load twice
|
|
34
|
+
root.PB_CORE_AVATARS = {
|
|
35
|
+
// Chair · the canonical 杨天真 portrait.
|
|
36
|
+
chair: {
|
|
37
|
+
model: "classic", hairStyle: "glasses", outfitStyle: "casual",
|
|
38
|
+
accessory: "glasses",
|
|
39
|
+
skin: "#f7d7b8", hair: "#6f4e37", brow: "#7a3b28", outfit: "#d8392b",
|
|
40
|
+
browStyle: "default", tieStyle: "none",
|
|
41
|
+
},
|
|
42
|
+
socrates: {
|
|
43
|
+
model: "classic", hairStyle: "street", outfitStyle: "street",
|
|
44
|
+
accessory: "glasses",
|
|
45
|
+
skin: "#e0ac69", hair: "#4a3526", brow: "#241c16", outfit: "#7a5a3b",
|
|
46
|
+
browStyle: "default", tieStyle: "none",
|
|
47
|
+
},
|
|
48
|
+
"first-principles": {
|
|
49
|
+
model: "glasses", hairStyle: "royal", outfitStyle: "classic",
|
|
50
|
+
accessory: "none",
|
|
51
|
+
skin: "#e0ac69", hair: "#6e6e6e", brow: "#3a2a1e", outfit: "#1a1a1a",
|
|
52
|
+
browStyle: "default", tieStyle: "xmas",
|
|
53
|
+
tie: "#d8392b", eye: "#0d0d0d",
|
|
54
|
+
},
|
|
55
|
+
"value-investor": {
|
|
56
|
+
model: "casual", hairStyle: "classic", outfitStyle: "casual",
|
|
57
|
+
accessory: "none",
|
|
58
|
+
skin: "#f7d7b8", hair: "#8d6a45", brow: "#7a3b28", outfit: "#6b3f4a",
|
|
59
|
+
browStyle: "default", tieStyle: "none",
|
|
60
|
+
},
|
|
61
|
+
historian: {
|
|
62
|
+
model: "glasses", hairStyle: "classic", outfitStyle: "classic",
|
|
63
|
+
accessory: "shades",
|
|
64
|
+
skin: "#ffe0bd", hair: "#6f4e37", brow: "#6f4e37", outfit: "#e0b400",
|
|
65
|
+
browStyle: "default", tieStyle: "none",
|
|
66
|
+
},
|
|
67
|
+
"user-empathy": {
|
|
68
|
+
model: "classic", hairStyle: "glasses", outfitStyle: "street",
|
|
69
|
+
accessory: "glasses",
|
|
70
|
+
skin: "#f7d7b8", hair: "#6f4e37", brow: "#6f4e37", outfit: "#0fb5b5",
|
|
71
|
+
browStyle: "default", tieStyle: "none",
|
|
72
|
+
},
|
|
73
|
+
"long-horizon": {
|
|
74
|
+
model: "classic", hairStyle: "none", outfitStyle: "casual",
|
|
75
|
+
accessory: "none",
|
|
76
|
+
skin: "#f7d7b8", hair: "#3a3a3a", brow: "#3a3a3a", outfit: "#3f4a6b",
|
|
77
|
+
browStyle: "royal", tieStyle: "none",
|
|
78
|
+
},
|
|
79
|
+
phenomenologist: {
|
|
80
|
+
model: "glasses", hairStyle: "classic", outfitStyle: "classic",
|
|
81
|
+
accessory: "glasses",
|
|
82
|
+
skin: "#8d5524", hair: "#b08d57", brow: "#e8cf9a", outfit: "#7a4a52",
|
|
83
|
+
browStyle: "default", tieStyle: "royal",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
package/public/home-3d-loader.js
CHANGED
|
@@ -61,7 +61,11 @@
|
|
|
61
61
|
setTimeout(() => hintEl.classList.remove("is-visible"), 3500);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
async function mountHero3d(heroEl, stageEl, hintEl) {
|
|
64
|
+
async function mountHero3d(heroEl, stageEl, hintEl, loadingEl) {
|
|
65
|
+
// Drop the loading veil on any non-mounted exit (capability bail,
|
|
66
|
+
// mount failure, import throw) so it never spins forever. On the
|
|
67
|
+
// success path the `[data-mounted]` CSS rule fades it instead.
|
|
68
|
+
const hideLoading = () => { if (loadingEl) loadingEl.setAttribute("hidden", ""); };
|
|
65
69
|
try {
|
|
66
70
|
// Import voice-3d.js · its top-level `import` of three.module +
|
|
67
71
|
// OrbitControls pulls those in too. The IIFE inside sets
|
|
@@ -70,7 +74,7 @@
|
|
|
70
74
|
// after the import completes.
|
|
71
75
|
await import("/voice-3d.js");
|
|
72
76
|
const VS3D = window.VoiceStage3D;
|
|
73
|
-
if (!VS3D || !VS3D.isSupported || !VS3D.isSupported()) return;
|
|
77
|
+
if (!VS3D || !VS3D.isSupported || !VS3D.isSupported()) { hideLoading(); return; }
|
|
74
78
|
|
|
75
79
|
// The stage host must be visible (display: block, not [hidden])
|
|
76
80
|
// before mount, otherwise WebGLRenderer reads a 0×0 canvas size
|
|
@@ -96,7 +100,7 @@
|
|
|
96
100
|
// top of the poster would just darken it before the canvas
|
|
97
101
|
// takes over.
|
|
98
102
|
loading: false,
|
|
99
|
-
})) return;
|
|
103
|
+
})) { hideLoading(); return; }
|
|
100
104
|
|
|
101
105
|
// Pull the cast + start the rotating-speaker driver. The mock
|
|
102
106
|
// module is small (~3KB) so importing it after VS3D.mount keeps
|
|
@@ -116,6 +120,7 @@
|
|
|
116
120
|
} catch (e) {
|
|
117
121
|
// Silently fall back to the poster. The visitor still sees a
|
|
118
122
|
// polished hero, just without the live scene.
|
|
123
|
+
hideLoading();
|
|
119
124
|
try { console.warn("[home-3d] mount failed, poster fallback:", e); } catch (_) {}
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -125,6 +130,7 @@
|
|
|
125
130
|
if (!heroEl) return;
|
|
126
131
|
const stageEl = heroEl.querySelector("[data-hero-3d-stage]");
|
|
127
132
|
const hintEl = heroEl.querySelector("[data-hero-3d-hint]");
|
|
133
|
+
const loadingEl = heroEl.querySelector("[data-hero-3d-loading]");
|
|
128
134
|
if (!stageEl) return;
|
|
129
135
|
|
|
130
136
|
if (shouldSkip3d()) return; // keep poster, do nothing
|
|
@@ -139,12 +145,17 @@
|
|
|
139
145
|
for (const entry of entries) {
|
|
140
146
|
if (!entry.isIntersecting) continue;
|
|
141
147
|
observer.disconnect();
|
|
148
|
+
// Commit to loading · reveal the loading veil NOW (before the
|
|
149
|
+
// idle yield + ~500KB download) so the section shows a spinner
|
|
150
|
+
// for the whole gap, not blank. Torn down on mount / failure
|
|
151
|
+
// inside mountHero3d.
|
|
152
|
+
if (loadingEl) loadingEl.removeAttribute("hidden");
|
|
142
153
|
// Yield to the main thread so initial paint / hero font load
|
|
143
154
|
// / hero CSS animations all finish before we kick off ~500KB
|
|
144
155
|
// of JS download. requestIdleCallback is the right primitive
|
|
145
156
|
// here; setTimeout(..., 0) fallback for Safari which still
|
|
146
157
|
// lacks it as of 2026.
|
|
147
|
-
const kick = () => mountHero3d(heroEl, stageEl, hintEl);
|
|
158
|
+
const kick = () => mountHero3d(heroEl, stageEl, hintEl, loadingEl);
|
|
148
159
|
if (typeof window.requestIdleCallback === "function") {
|
|
149
160
|
window.requestIdleCallback(kick, { timeout: 1500 });
|
|
150
161
|
} else {
|