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
package/public/quote-cta.js
CHANGED
|
@@ -160,8 +160,8 @@
|
|
|
160
160
|
cta.className = "qcta";
|
|
161
161
|
cta.setAttribute("role", "toolbar");
|
|
162
162
|
const t = lang() === "zh"
|
|
163
|
-
? { ask: "追问", love: "附议", save: "收藏" }
|
|
164
|
-
: { ask: "Probe", love: "Second", save: "Save" };
|
|
163
|
+
? { ask: "追问", love: "附议", save: "收藏", thread: "私聊" }
|
|
164
|
+
: { ask: "Probe", love: "Second", save: "Save", thread: "Thread" };
|
|
165
165
|
// Inline chat-bubble SVG · uses currentColor so it inherits the
|
|
166
166
|
// hover lime / base text colour like the ★ glyph does.
|
|
167
167
|
const askIcon = `
|
|
@@ -176,6 +176,17 @@
|
|
|
176
176
|
<path d="M3.5 1.5 H10.5 V12.5 L7 9.5 L3.5 12.5 Z"/>
|
|
177
177
|
</svg>
|
|
178
178
|
`;
|
|
179
|
+
// Reply curve · matches `_threadTriggerIconSvg` in app.js so the
|
|
180
|
+
// selection bar's Thread button reads as the same action as the
|
|
181
|
+
// per-bubble msg-toolbar reply icon. Both surfaces route into
|
|
182
|
+
// `openThreadWith` — selection bar additionally seeds the
|
|
183
|
+
// composer with a quote of the selected text.
|
|
184
|
+
const threadIcon = `
|
|
185
|
+
<svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
186
|
+
<path d="M5 10 L2 7 L5 4"/>
|
|
187
|
+
<path d="M2 7 H10 a2 2 0 0 1 2 2 V11"/>
|
|
188
|
+
</svg>
|
|
189
|
+
`;
|
|
179
190
|
cta.innerHTML = `
|
|
180
191
|
<button type="button" class="qcta-btn" data-qcta="ask">
|
|
181
192
|
<span class="ico">${askIcon}</span><span>${t.ask}</span>
|
|
@@ -183,6 +194,9 @@
|
|
|
183
194
|
<button type="button" class="qcta-btn" data-qcta="second">
|
|
184
195
|
<span class="ico">★</span><span>${t.love}</span>
|
|
185
196
|
</button>
|
|
197
|
+
<button type="button" class="qcta-btn qcta-btn-thread" data-qcta="thread" title="${t.thread}">
|
|
198
|
+
<span class="ico">${threadIcon}</span><span>${t.thread}</span>
|
|
199
|
+
</button>
|
|
186
200
|
<button type="button" class="qcta-btn qcta-btn-save" data-qcta="save" title="Save to Notes · S">
|
|
187
201
|
<span class="ico">${saveIcon}</span><span>${t.save}</span>
|
|
188
202
|
</button>
|
|
@@ -197,15 +211,18 @@
|
|
|
197
211
|
if (!btn) return;
|
|
198
212
|
const action = btn.getAttribute("data-qcta");
|
|
199
213
|
// Read-only state (adjourned rooms) blocks Probe / Second since
|
|
200
|
-
// they post messages to
|
|
201
|
-
//
|
|
202
|
-
|
|
214
|
+
// they post messages to the closed parent room. Save and Thread
|
|
215
|
+
// are exempt — Save is a personal bookmark, and a Thread spawns
|
|
216
|
+
// its own live 1:1 room (server doesn't gate thread-create on the
|
|
217
|
+
// parent's status), so review-mode follow-ups stay possible.
|
|
218
|
+
if (cta.classList.contains("qcta-readonly") && action !== "save" && action !== "thread") return;
|
|
203
219
|
const sel = lastSelection;
|
|
204
220
|
hideCTA();
|
|
205
221
|
if (!sel || !sel.text) return;
|
|
206
222
|
if (action === "ask") openAskOverlay(sel);
|
|
207
223
|
else if (action === "second") submitSecond(sel);
|
|
208
224
|
else if (action === "save") submitSave(sel);
|
|
225
|
+
else if (action === "thread") openSelectionThread(sel);
|
|
209
226
|
});
|
|
210
227
|
document.body.appendChild(cta);
|
|
211
228
|
return cta;
|
|
@@ -434,6 +451,34 @@
|
|
|
434
451
|
routeSend(body, mentions);
|
|
435
452
|
}
|
|
436
453
|
|
|
454
|
+
// ── Selection → private thread ──────────────────────────────
|
|
455
|
+
// Opens (or restores) a 1:1 thread window with the quoted director
|
|
456
|
+
// and pre-seeds the thread's composer textarea with the markdown
|
|
457
|
+
// blockquote of the selected text + a blank line. The user then
|
|
458
|
+
// types their follow-up question and sends; the director's reply
|
|
459
|
+
// sees the quote inline as the user's first thread message.
|
|
460
|
+
// Threads stay private to the user + this one director — the rest
|
|
461
|
+
// of the room never sees what was selected or discussed below.
|
|
462
|
+
async function openSelectionThread(sel) {
|
|
463
|
+
const app = window.app;
|
|
464
|
+
if (!app || typeof app.openThreadWith !== "function") return;
|
|
465
|
+
if (!sel.directorId) return;
|
|
466
|
+
// Stash the selected quote so the thread window mount can find
|
|
467
|
+
// it AFTER the async POST + DOM build settle. Keyed by director
|
|
468
|
+
// id so concurrent clicks on different bubbles don't overwrite
|
|
469
|
+
// each other. Consumed (and cleared) by mountThreadWindow.
|
|
470
|
+
app._threadPendingQuote = app._threadPendingQuote || {};
|
|
471
|
+
app._threadPendingQuote[sel.directorId] = {
|
|
472
|
+
text: sel.text,
|
|
473
|
+
directorName: sel.directorName || "",
|
|
474
|
+
};
|
|
475
|
+
try {
|
|
476
|
+
await app.openThreadWith(sel.directorId);
|
|
477
|
+
} catch (e) {
|
|
478
|
+
try { console.warn("[qcta-thread] open failed:", e); } catch (_) {}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
437
482
|
// ── Save to Notes ─────────────────────────────────────────────
|
|
438
483
|
// POST /api/notes with quote + sentence-based context + char
|
|
439
484
|
// offsets. No room interaction — this is a personal bookmark.
|
package/public/room-settings.css
CHANGED
|
@@ -218,7 +218,7 @@ html.is-electron-mac .room-settings-overlay * {
|
|
|
218
218
|
}
|
|
219
219
|
.rs-config-row-name {
|
|
220
220
|
font-family: var(--font-human);
|
|
221
|
-
font-size:
|
|
221
|
+
font-size: 14px;
|
|
222
222
|
font-weight: 600;
|
|
223
223
|
color: var(--text);
|
|
224
224
|
letter-spacing: -0.005em;
|
|
@@ -1096,12 +1096,11 @@ html.is-electron-mac .room-settings-overlay * {
|
|
|
1096
1096
|
0%, 100% { opacity: 1; }
|
|
1097
1097
|
50% { opacity: 0.4; }
|
|
1098
1098
|
}
|
|
1099
|
-
/* Generate-Report variant · adjourned room with no brief yet.
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1099
|
+
/* Generate-Report variant · adjourned room with no brief yet. No
|
|
1100
|
+
colour accent — the icon rests at the shared `.head-icon-btn`
|
|
1101
|
+
text-soft tone (lime only on hover, like every other head icon)
|
|
1102
|
+
so it doesn't read as a yellow/gold highlight (`--lime` is the
|
|
1103
|
+
warm gold #C9A46B in the default theme). */
|
|
1105
1104
|
/* Count chip for multi-brief popover trigger · pinned to the icon's
|
|
1106
1105
|
top-right corner so the user sees "there's more than one report"
|
|
1107
1106
|
without taking horizontal space. Mono pill, same micro-tag register
|
|
@@ -1133,10 +1132,21 @@ html.is-electron-mac .room-settings-overlay * {
|
|
|
1133
1132
|
/* Edit cast · Lucide UserPlus. Sits between the cast avatars and the
|
|
1134
1133
|
primary state action so the user reads it as a direct affordance on
|
|
1135
1134
|
the cast strip ("add a member to this group"). Hidden on adjourned
|
|
1136
|
-
rooms (the cast is frozen once the brief is filed).
|
|
1135
|
+
rooms (the cast is frozen once the brief is filed).
|
|
1136
|
+
The glyph (user silhouette + plus) sits in opposite quadrants of
|
|
1137
|
+
the 24×24 viewBox · at the shared 16×16 mask size the symbol reads
|
|
1138
|
+
visually smaller than dense siblings like `.head-divergence`. Bump
|
|
1139
|
+
the mask + ::before box to 18×18 so it sits at the same perceived
|
|
1140
|
+
weight as the cast avatars (26px) next to it. */
|
|
1137
1141
|
.head-add-cast {
|
|
1138
1142
|
--icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/><circle cx='9' cy='7' r='4'/><line x1='19' x2='19' y1='8' y2='14'/><line x1='22' x2='16' y1='11' y2='11'/></svg>");
|
|
1139
1143
|
}
|
|
1144
|
+
.head-add-cast::before {
|
|
1145
|
+
width: 18px;
|
|
1146
|
+
height: 18px;
|
|
1147
|
+
-webkit-mask-size: 18px 18px;
|
|
1148
|
+
mask-size: 18px 18px;
|
|
1149
|
+
}
|
|
1140
1150
|
html[data-status="adjourned"] .head-add-cast { display: none; }
|
|
1141
1151
|
/* Adjourn · identical glyph to the input-bar's `.ib-adjourn` (Lucide
|
|
1142
1152
|
log-out: door + arrow exit). The head and input-bar icons share the
|
|
@@ -1195,7 +1205,12 @@ html[data-status="adjourned"] .head-add-cast { display: none; }
|
|
|
1195
1205
|
.rec-countdown-overlay {
|
|
1196
1206
|
position: absolute;
|
|
1197
1207
|
inset: 0;
|
|
1198
|
-
z-index:
|
|
1208
|
+
/* Sit above the live caption · `.rt-subtitle` is z-index: 12 (see
|
|
1209
|
+
index.html line ~9554), so the countdown overlay must be
|
|
1210
|
+
higher or else the subtitle bar punches through "3 / 2 / 1 /
|
|
1211
|
+
READY" and ruins the movie-trailer intro. 30 leaves headroom
|
|
1212
|
+
for any future stage chrome we want to slip between. */
|
|
1213
|
+
z-index: 30;
|
|
1199
1214
|
display: flex;
|
|
1200
1215
|
align-items: center;
|
|
1201
1216
|
justify-content: center;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>stuff.glb · viewer</title>
|
|
6
|
+
<style>
|
|
7
|
+
html, body { margin: 0; height: 100%; background: #16181c; overflow: hidden; color: #cde; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
8
|
+
#status {
|
|
9
|
+
position: fixed; top: 8px; left: 10px;
|
|
10
|
+
color: #9fe; z-index: 9; white-space: pre-wrap;
|
|
11
|
+
max-width: 60vw;
|
|
12
|
+
padding: 6px 10px;
|
|
13
|
+
background: rgba(0, 0, 0, 0.45);
|
|
14
|
+
border: 0.5px solid #2c3038;
|
|
15
|
+
border-radius: 4px;
|
|
16
|
+
}
|
|
17
|
+
#status.is-err { color: #f88; border-color: #5a2a2a; }
|
|
18
|
+
#info {
|
|
19
|
+
position: fixed; bottom: 10px; left: 10px; z-index: 9;
|
|
20
|
+
background: rgba(0, 0, 0, 0.55);
|
|
21
|
+
border: 0.5px solid #2c3038;
|
|
22
|
+
border-radius: 4px;
|
|
23
|
+
padding: 8px 12px;
|
|
24
|
+
min-width: 220px;
|
|
25
|
+
}
|
|
26
|
+
#info h1 { margin: 0 0 6px; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #6ad29a; font-weight: 600; }
|
|
27
|
+
#info dl { margin: 0; display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 2px; font-size: 11px; }
|
|
28
|
+
#info dt { color: #7a8290; }
|
|
29
|
+
#info dd { margin: 0; color: #cde; }
|
|
30
|
+
#panel {
|
|
31
|
+
position: fixed; top: 8px; right: 10px; z-index: 9;
|
|
32
|
+
background: rgba(0, 0, 0, 0.55);
|
|
33
|
+
border: 0.5px solid #2c3038; border-radius: 4px;
|
|
34
|
+
padding: 10px 12px; width: 230px;
|
|
35
|
+
}
|
|
36
|
+
#panel label { display: block; margin: 6px 0 2px; color: #aab2c0; }
|
|
37
|
+
#panel input[type=range] { width: 100%; accent-color: #6ad29a; }
|
|
38
|
+
#panel b { color: #6ad29a; }
|
|
39
|
+
#panel button {
|
|
40
|
+
width: 100%; margin-top: 8px; padding: 6px;
|
|
41
|
+
background: #232a36; color: #cde;
|
|
42
|
+
border: 0.5px solid #3a414d; border-radius: 4px;
|
|
43
|
+
font: 11px ui-monospace, monospace; cursor: pointer;
|
|
44
|
+
}
|
|
45
|
+
#panel button:hover { border-color: #6ad29a; color: #6ad29a; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="status">booting…</div>
|
|
50
|
+
<div id="panel">
|
|
51
|
+
<label>exposure · <b id="vExp">1.00</b></label>
|
|
52
|
+
<input type="range" id="exp" min="0.2" max="2.0" step="0.01" value="1.00">
|
|
53
|
+
<label>env intensity · <b id="vEnv">0.85</b></label>
|
|
54
|
+
<input type="range" id="env" min="0" max="2" step="0.01" value="0.85">
|
|
55
|
+
<label>key light · <b id="vKey">1.00</b></label>
|
|
56
|
+
<input type="range" id="key" min="0" max="3" step="0.01" value="1.00">
|
|
57
|
+
<label>rotate model · <b id="vRot">auto</b></label>
|
|
58
|
+
<input type="range" id="rot" min="0" max="360" step="1" value="0">
|
|
59
|
+
<button id="autoRotateToggle">↻ auto-rotate: ON</button>
|
|
60
|
+
<button id="reframeBtn">⤧ re-fit camera</button>
|
|
61
|
+
</div>
|
|
62
|
+
<div id="info">
|
|
63
|
+
<h1>// stuff.glb</h1>
|
|
64
|
+
<dl>
|
|
65
|
+
<dt>size</dt><dd id="iSize">—</dd>
|
|
66
|
+
<dt>meshes</dt><dd id="iMesh">—</dd>
|
|
67
|
+
<dt>vertices</dt><dd id="iVerts">—</dd>
|
|
68
|
+
<dt>materials</dt><dd id="iMats">—</dd>
|
|
69
|
+
<dt>bounds</dt><dd id="iBounds">—</dd>
|
|
70
|
+
<dt>fps</dt><dd id="iFps">—</dd>
|
|
71
|
+
</dl>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<script>
|
|
75
|
+
window.__setStatus = function (msg, isErr) {
|
|
76
|
+
const el = document.getElementById("status");
|
|
77
|
+
if (!el) return;
|
|
78
|
+
el.textContent = msg;
|
|
79
|
+
el.classList.toggle("is-err", !!isErr);
|
|
80
|
+
};
|
|
81
|
+
if (location.protocol === "file:") {
|
|
82
|
+
window.__setStatus("⚠ Open via http://localhost:<port>/stuff-viewer.html · file:// blocks module + GLB loads.", true);
|
|
83
|
+
}
|
|
84
|
+
window.addEventListener("error", (e) => {
|
|
85
|
+
const what = e.message || (e.target && (e.target.src || e.target.href)) || "unknown";
|
|
86
|
+
window.__setStatus("JS / asset error: " + what, true);
|
|
87
|
+
}, true);
|
|
88
|
+
window.addEventListener("unhandledrejection", (e) => {
|
|
89
|
+
const r = e.reason;
|
|
90
|
+
window.__setStatus("Unhandled rejection: " + ((r && r.stack) || (r && r.message) || r), true);
|
|
91
|
+
});
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<script type="module">
|
|
95
|
+
const S = window.__setStatus;
|
|
96
|
+
(async () => {
|
|
97
|
+
try {
|
|
98
|
+
const GLB = "/icons/stuff.glb";
|
|
99
|
+
S("checking " + GLB + " …");
|
|
100
|
+
const head = await fetch(GLB, { method: "HEAD" }).catch((err) => ({ ok: false, status: "fetch failed: " + err.message }));
|
|
101
|
+
if (!head.ok) {
|
|
102
|
+
S("GLB unreachable: " + GLB + " → " + head.status, true);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const bytes = parseInt(head.headers.get("content-length") || "0", 10);
|
|
106
|
+
document.getElementById("iSize").textContent = bytes ? (bytes / 1024).toFixed(1) + " KB" : "?";
|
|
107
|
+
|
|
108
|
+
S("loading three.js …");
|
|
109
|
+
const THREE = await import("/vendor/three.module.min.js");
|
|
110
|
+
S("loading OrbitControls + GLTFLoader + RoomEnvironment …");
|
|
111
|
+
const { OrbitControls } = await import("/vendor/OrbitControls.js");
|
|
112
|
+
const { GLTFLoader } = await import("/vendor/GLTFLoader.js");
|
|
113
|
+
const { RoomEnvironment } = await import("/vendor/RoomEnvironment.js");
|
|
114
|
+
// Many Blender / Spline exports run the `meshopt` compressor on
|
|
115
|
+
// attributes (EXT_meshopt_compression). GLTFLoader refuses to
|
|
116
|
+
// load them until a decoder is attached. The decoder is a WASM-
|
|
117
|
+
// ish module shipped under three's examples; we vendored it
|
|
118
|
+
// into /vendor so cold-load works offline.
|
|
119
|
+
const { MeshoptDecoder } = await import("/vendor/meshopt_decoder.module.js");
|
|
120
|
+
|
|
121
|
+
// ── Renderer / scene / camera ───────────────────────────
|
|
122
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
|
123
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
124
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
125
|
+
renderer.shadowMap.enabled = true;
|
|
126
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
127
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
128
|
+
renderer.toneMappingExposure = 1.0;
|
|
129
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
130
|
+
document.body.appendChild(renderer.domElement);
|
|
131
|
+
|
|
132
|
+
const scene = new THREE.Scene();
|
|
133
|
+
scene.background = new THREE.Color(0x16181c);
|
|
134
|
+
|
|
135
|
+
const camera = new THREE.PerspectiveCamera(38, innerWidth / innerHeight, 0.01, 100);
|
|
136
|
+
camera.position.set(2.0, 1.6, 2.8);
|
|
137
|
+
|
|
138
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
139
|
+
controls.enableDamping = true;
|
|
140
|
+
controls.dampingFactor = 0.08;
|
|
141
|
+
controls.target.set(0, 0.5, 0);
|
|
142
|
+
controls.update();
|
|
143
|
+
|
|
144
|
+
// IBL · same recipe as the avatar viewer; gives any GLB
|
|
145
|
+
// PBR materials something to reflect.
|
|
146
|
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
147
|
+
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
148
|
+
scene.environmentIntensity = 0.85;
|
|
149
|
+
|
|
150
|
+
// Direct lights · key + fill + rim. Soft enough that PBR
|
|
151
|
+
// doesn't blow out, strong enough to read shape on matte
|
|
152
|
+
// surfaces.
|
|
153
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x2a3140, 0.32));
|
|
154
|
+
const key = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
155
|
+
key.position.set(3, 5, 4);
|
|
156
|
+
key.castShadow = true;
|
|
157
|
+
key.shadow.mapSize.set(1024, 1024);
|
|
158
|
+
key.shadow.camera.near = 0.1;
|
|
159
|
+
key.shadow.camera.far = 20;
|
|
160
|
+
key.shadow.camera.top = 4;
|
|
161
|
+
key.shadow.camera.bottom = -4;
|
|
162
|
+
key.shadow.camera.left = -4;
|
|
163
|
+
key.shadow.camera.right = 4;
|
|
164
|
+
key.shadow.bias = -0.0005;
|
|
165
|
+
scene.add(key);
|
|
166
|
+
const rim = new THREE.DirectionalLight(0xbfd4ff, 0.35);
|
|
167
|
+
rim.position.set(-4, 3, -3);
|
|
168
|
+
scene.add(rim);
|
|
169
|
+
|
|
170
|
+
// Ground · circular disk so shadows can land; matches the
|
|
171
|
+
// dark BG so it reads as "stage" not "floor".
|
|
172
|
+
const ground = new THREE.Mesh(
|
|
173
|
+
new THREE.CircleGeometry(4.0, 64),
|
|
174
|
+
new THREE.MeshStandardMaterial({ color: 0x1d2026, roughness: 1, metalness: 0 }),
|
|
175
|
+
);
|
|
176
|
+
ground.rotation.x = -Math.PI / 2;
|
|
177
|
+
ground.position.y = -0.001;
|
|
178
|
+
ground.receiveShadow = true;
|
|
179
|
+
scene.add(ground);
|
|
180
|
+
|
|
181
|
+
// Grid for scale reference · soft, low-contrast so it doesn't
|
|
182
|
+
// compete with the model.
|
|
183
|
+
const grid = new THREE.GridHelper(8, 32, 0x2a3140, 0x222831);
|
|
184
|
+
grid.material.transparent = true;
|
|
185
|
+
grid.material.opacity = 0.5;
|
|
186
|
+
scene.add(grid);
|
|
187
|
+
|
|
188
|
+
// ── Load GLB ────────────────────────────────────────────
|
|
189
|
+
S("downloading + parsing " + GLB + " …");
|
|
190
|
+
// The decoder boots its WASM lazily on first use — `ready` is a
|
|
191
|
+
// Promise we have to await so subsequent .load() calls find an
|
|
192
|
+
// initialised decoder. Without this the loader fires the same
|
|
193
|
+
// "setMeshoptDecoder must be called before loading compressed
|
|
194
|
+
// files" again on the next compressed buffer view.
|
|
195
|
+
await MeshoptDecoder.ready;
|
|
196
|
+
const loader = new GLTFLoader();
|
|
197
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
198
|
+
const gltf = await new Promise((resolve, reject) => {
|
|
199
|
+
loader.load(
|
|
200
|
+
GLB,
|
|
201
|
+
(g) => resolve(g),
|
|
202
|
+
(xhr) => {
|
|
203
|
+
if (xhr.total) {
|
|
204
|
+
S(`downloading … ${((xhr.loaded / xhr.total) * 100).toFixed(0)} %`);
|
|
205
|
+
} else {
|
|
206
|
+
S(`downloading … ${(xhr.loaded / 1024).toFixed(0)} KB`);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
(err) => reject(err),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const model = gltf.scene;
|
|
214
|
+
// Enable shadows on every mesh in the imported tree.
|
|
215
|
+
let meshCount = 0;
|
|
216
|
+
let vertCount = 0;
|
|
217
|
+
const mats = new Set();
|
|
218
|
+
model.traverse((node) => {
|
|
219
|
+
if (node.isMesh) {
|
|
220
|
+
node.castShadow = true;
|
|
221
|
+
node.receiveShadow = true;
|
|
222
|
+
meshCount += 1;
|
|
223
|
+
if (node.geometry && node.geometry.attributes && node.geometry.attributes.position) {
|
|
224
|
+
vertCount += node.geometry.attributes.position.count;
|
|
225
|
+
}
|
|
226
|
+
if (node.material) {
|
|
227
|
+
if (Array.isArray(node.material)) node.material.forEach((m) => mats.add(m));
|
|
228
|
+
else mats.add(node.material);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
scene.add(model);
|
|
233
|
+
|
|
234
|
+
// Animations · play all clips at 1× if present.
|
|
235
|
+
let mixer = null;
|
|
236
|
+
if (gltf.animations && gltf.animations.length) {
|
|
237
|
+
mixer = new THREE.AnimationMixer(model);
|
|
238
|
+
for (const clip of gltf.animations) mixer.clipAction(clip).play();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Fit camera to model bounds ─────────────────────────
|
|
242
|
+
function reframe() {
|
|
243
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
244
|
+
const size = box.getSize(new THREE.Vector3());
|
|
245
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
246
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
247
|
+
const fov = camera.fov * Math.PI / 180;
|
|
248
|
+
const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.7;
|
|
249
|
+
camera.position.set(
|
|
250
|
+
center.x + dist * 0.6,
|
|
251
|
+
center.y + dist * 0.55,
|
|
252
|
+
center.z + dist * 0.9,
|
|
253
|
+
);
|
|
254
|
+
camera.near = Math.max(0.005, maxDim / 200);
|
|
255
|
+
camera.far = Math.max(20, dist * 4);
|
|
256
|
+
camera.updateProjectionMatrix();
|
|
257
|
+
controls.target.copy(center);
|
|
258
|
+
controls.update();
|
|
259
|
+
document.getElementById("iBounds").textContent =
|
|
260
|
+
`${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}`;
|
|
261
|
+
}
|
|
262
|
+
reframe();
|
|
263
|
+
|
|
264
|
+
// ── Info panel ─────────────────────────────────────────
|
|
265
|
+
document.getElementById("iMesh").textContent = String(meshCount);
|
|
266
|
+
document.getElementById("iVerts").textContent = vertCount.toLocaleString();
|
|
267
|
+
document.getElementById("iMats").textContent = String(mats.size);
|
|
268
|
+
|
|
269
|
+
// ── UI wiring ───────────────────────────────────────────
|
|
270
|
+
const $ = (id) => document.getElementById(id);
|
|
271
|
+
const wire = (id, vid, fn, fmt) => {
|
|
272
|
+
const el = $(id), lab = $(vid);
|
|
273
|
+
const upd = () => { const v = parseFloat(el.value); fn(v); lab.textContent = fmt(v); };
|
|
274
|
+
el.addEventListener("input", upd); upd();
|
|
275
|
+
};
|
|
276
|
+
wire("exp", "vExp", (v) => { renderer.toneMappingExposure = v; }, (v) => v.toFixed(2));
|
|
277
|
+
wire("env", "vEnv", (v) => { scene.environmentIntensity = v; }, (v) => v.toFixed(2));
|
|
278
|
+
wire("key", "vKey", (v) => { key.intensity = v; }, (v) => v.toFixed(2));
|
|
279
|
+
const rotEl = $("rot");
|
|
280
|
+
rotEl.addEventListener("input", () => {
|
|
281
|
+
autoRotate = false;
|
|
282
|
+
autoBtn.textContent = "↻ auto-rotate: OFF";
|
|
283
|
+
const deg = parseFloat(rotEl.value);
|
|
284
|
+
model.rotation.y = deg * Math.PI / 180;
|
|
285
|
+
$("vRot").textContent = deg.toFixed(0) + "°";
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
let autoRotate = true;
|
|
289
|
+
const autoBtn = $("autoRotateToggle");
|
|
290
|
+
autoBtn.addEventListener("click", () => {
|
|
291
|
+
autoRotate = !autoRotate;
|
|
292
|
+
autoBtn.textContent = "↻ auto-rotate: " + (autoRotate ? "ON" : "OFF");
|
|
293
|
+
});
|
|
294
|
+
$("reframeBtn").addEventListener("click", reframe);
|
|
295
|
+
|
|
296
|
+
// ── Render loop ────────────────────────────────────────
|
|
297
|
+
const clock = new THREE.Clock();
|
|
298
|
+
let frames = 0, fpsAcc = 0;
|
|
299
|
+
renderer.setAnimationLoop(() => {
|
|
300
|
+
const dt = clock.getDelta();
|
|
301
|
+
if (autoRotate) {
|
|
302
|
+
model.rotation.y += dt * 0.6;
|
|
303
|
+
const deg = (model.rotation.y * 180 / Math.PI) % 360;
|
|
304
|
+
rotEl.value = ((deg + 360) % 360).toFixed(0);
|
|
305
|
+
$("vRot").textContent = "auto";
|
|
306
|
+
}
|
|
307
|
+
if (mixer) mixer.update(dt);
|
|
308
|
+
controls.update();
|
|
309
|
+
renderer.render(scene, camera);
|
|
310
|
+
frames += 1; fpsAcc += dt;
|
|
311
|
+
if (fpsAcc >= 0.5) {
|
|
312
|
+
$("iFps").textContent = (frames / fpsAcc).toFixed(0) + " fps";
|
|
313
|
+
frames = 0; fpsAcc = 0;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
addEventListener("resize", () => {
|
|
318
|
+
camera.aspect = innerWidth / innerHeight;
|
|
319
|
+
camera.updateProjectionMatrix();
|
|
320
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
S(`✓ loaded · ${meshCount} mesh · ${vertCount.toLocaleString()} verts · drag to orbit, scroll to zoom`);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
S("ERROR: " + (e && e.stack ? e.stack : (e && e.message) || e), true);
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
</script>
|
|
329
|
+
</body>
|
|
330
|
+
</html>
|