privateboard 0.1.37 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/boot.js +1415 -91
  2. package/dist/boot.js.map +1 -1
  3. package/dist/cli.js +1415 -91
  4. package/dist/cli.js.map +1 -1
  5. package/dist/server.js +1271 -81
  6. package/dist/server.js.map +1 -1
  7. package/dist/version.d.ts +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/version.js.map +1 -1
  10. package/package.json +1 -1
  11. package/public/__avatar3d_test.html +156 -0
  12. package/public/adjourn-overlay.css +2 -2
  13. package/public/agent-overlay.css +27 -15
  14. package/public/agent-overlay.js +3 -1
  15. package/public/agent-profile.css +331 -41
  16. package/public/agent-profile.js +499 -75
  17. package/public/app-updater.css +1 -1
  18. package/public/app.js +2090 -547
  19. package/public/avatar-3d-snap.js +205 -0
  20. package/public/avatar-3d.js +792 -0
  21. package/public/avatar-customizer.html +274 -0
  22. package/public/avatar3d-editor.css +240 -0
  23. package/public/avatar3d-editor.js +481 -0
  24. package/public/avatars/3d/chair.png +0 -0
  25. package/public/avatars/3d/first-principles.png +0 -0
  26. package/public/avatars/3d/historian.png +0 -0
  27. package/public/avatars/3d/long-horizon.png +0 -0
  28. package/public/avatars/3d/phenomenologist.png +0 -0
  29. package/public/avatars/3d/socrates.png +0 -0
  30. package/public/avatars/3d/user-empathy.png +0 -0
  31. package/public/avatars/3d/value-investor.png +0 -0
  32. package/public/core-avatars.js +86 -0
  33. package/public/home-3d-loader.js +15 -4
  34. package/public/home-3d-mock.js +18 -7
  35. package/public/home.html +80 -18
  36. package/public/i18n.js +279 -4
  37. package/public/icons/avatar_1779855104027.glb +0 -0
  38. package/public/icons/logo.png +0 -0
  39. package/public/icons/new-style.glb +0 -0
  40. package/public/icons/new-style2.glb +0 -0
  41. package/public/icons/new-style3.glb +0 -0
  42. package/public/icons/new-style4.glb +0 -0
  43. package/public/icons/new-style5.glb +0 -0
  44. package/public/icons/office.glb +0 -0
  45. package/public/icons/stuff.glb +0 -0
  46. package/public/index.html +203 -182
  47. package/public/mention-picker.js +1 -1
  48. package/public/new-agent.css +7 -7
  49. package/public/new-agent.js +46 -20
  50. package/public/office-viewer.html +340 -0
  51. package/public/onboarding.css +5 -5
  52. package/public/quote-cta.css +5 -4
  53. package/public/quote-cta.js +50 -5
  54. package/public/room-settings.css +24 -9
  55. package/public/stuff-viewer.html +330 -0
  56. package/public/thread.css +1211 -0
  57. package/public/user-settings.css +16 -19
  58. package/public/user-settings.js +86 -78
  59. package/public/vendor/BufferGeometryUtils.js +1434 -0
  60. package/public/vendor/DRACOLoader.js +739 -0
  61. package/public/vendor/GLTFLoader.js +4860 -0
  62. package/public/vendor/RoomEnvironment.js +185 -0
  63. package/public/vendor/SkeletonUtils.js +496 -0
  64. package/public/vendor/draco/draco_decoder.js +34 -0
  65. package/public/vendor/draco/draco_decoder.wasm +0 -0
  66. package/public/vendor/draco/draco_encoder.js +33 -0
  67. package/public/vendor/draco/draco_wasm_wrapper.js +117 -0
  68. package/public/vendor/meshopt_decoder.module.js +196 -0
  69. package/public/voice-3d-banner.js +12 -0
  70. package/public/voice-3d.js +1407 -432
  71. package/public/voice-clone.css +875 -0
  72. package/public/voice-clone.js +1351 -0
  73. package/public/voice-replay.css +3 -3
  74. package/public/voice-replay.js +21 -0
  75. package/public/avatar-skill.js +0 -629
  76. package/public/icons/folded-sidebar.png +0 -0
@@ -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 a closed room. Save is exempt — the
201
- // user is bookmarking for personal review, not interacting.
202
- if (cta.classList.contains("qcta-readonly") && action !== "save") return;
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.
@@ -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: 13px;
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. The
1100
- primary CTA-ness moves to the icon's color tone (lime) same
1101
- tactic as the sidebar's `.new-btn.active` lime state. */
1102
- .head-view-report.is-generate {
1103
- color: var(--lime);
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: 10;
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>