lens-inspector 0.1.2 → 0.2.0

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/index.html CHANGED
@@ -5,6 +5,14 @@
5
5
  <title>Lens Inspector</title>
6
6
  <link rel="preconnect" href="https://fonts.googleapis.com">
7
7
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
8
+ <script type="importmap">
9
+ {
10
+ "imports": {
11
+ "three": "https://cdn.jsdelivr.net/npm/three@0.171.0/build/three.module.js",
12
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.171.0/examples/jsm/"
13
+ }
14
+ }
15
+ </script>
8
16
  <style>
9
17
  * { margin: 0; padding: 0; box-sizing: border-box; }
10
18
 
@@ -19,27 +27,27 @@
19
27
  --text-muted: #70707e;
20
28
  --white: #fff;
21
29
 
22
- /* LS component categories no green, higher saturation */
23
- --c-camera: #d08020;
24
- --c-visual: #3088c0;
25
- --c-text: #3070d0;
26
- --c-script: #5060c0;
27
- --c-audio: #1090b0;
28
- --c-light: #c09010;
29
- --c-physics: #d05030;
30
- --c-vfx: #1098c0;
31
- --c-interaction: #4060c0;
32
- --c-tracking: #1090b0;
33
- --c-animation: #c07020;
34
- --c-ui: #3878d0;
35
- --c-ml: #3070c0;
36
- --c-empty: #909098;
37
- --c-prefab: #4080d0;
38
-
39
- --c-runtime: #3088c0;
40
- --c-selected: #1850d0;
41
- --c-enabled: #3088c0;
42
- --c-disabled: #d04040;
30
+ /* BRIGHT, PUNCHY. No purple, green, cyan, or washed-out steel blue. */
31
+ --c-camera: #ff9500;
32
+ --c-visual: #0090ff;
33
+ --c-text: #0080ff;
34
+ --c-script: #ff2d20;
35
+ --c-audio: #ff6b00;
36
+ --c-light: #ffc800;
37
+ --c-physics: #ff1a1a;
38
+ --c-vfx: #00a0ff;
39
+ --c-interaction: #ff5500;
40
+ --c-tracking: #99a;
41
+ --c-animation: #ff8800;
42
+ --c-ui: #0070ff;
43
+ --c-ml: #5060ff;
44
+ --c-empty: #99a;
45
+ --c-prefab: #00aaff;
46
+
47
+ --c-runtime: #0090ff;
48
+ --c-selected: #0070ff;
49
+ --c-enabled: #0090ff;
50
+ --c-disabled: #ff2d20;
43
51
 
44
52
  --font: 'IBM Plex Sans', -apple-system, system-ui, sans-serif;
45
53
  --mono: 'IBM Plex Mono', 'JetBrains Mono', monospace;
@@ -67,19 +75,45 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
67
75
  font-family: var(--font); font-size: 10px; font-weight: 500; border-radius: 3px; cursor: pointer; transition: all var(--transition);
68
76
  }
69
77
  #toolbar button:hover { border-color: var(--border-bright); color: var(--text); background: var(--border); }
70
- .status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; transition: all 0.3s ease; }
71
- .dot-off { background: #bbb; }
72
- .dot-live { background: var(--c-selected); box-shadow: 0 0 6px rgba(24,80,208,0.5); }
73
- .dot-mcp { background: var(--c-text); box-shadow: 0 0 6px rgba(48,112,208,0.5); }
78
+ .status-dot { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; transition: background 0.2s ease; }
79
+ .dot-off { background: #101018; }
80
+ .dot-live { background: var(--c-selected); }
81
+ .dot-example { background: var(--c-camera); }
74
82
  #status-text { font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
83
+ #source-badge {
84
+ font-family: var(--mono); font-size: 9px; font-weight: 600; padding: 1px 7px;
85
+ border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;
86
+ }
87
+ .badge-example { background: #fff3e0; color: #c06000; border: 1px solid #f0c880; }
88
+ .badge-live { background: #e0f0ff; color: #0060c0; border: 1px solid #80c0f0; }
89
+ .badge-off { display: none; }
90
+ #project-name { font-family: var(--mono); font-size: 10px; color: var(--text); font-weight: 500; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
91
+ .toolbar-spacer { flex: 1; }
92
+ #setup-btn { font-size: 9px; }
75
93
 
76
94
  /* --- Main layout --- */
77
95
  #main { flex: 1; display: flex; min-height: 0; }
78
96
 
97
+ /* --- Resize handles --- */
98
+ .resize-handle {
99
+ width: 5px; flex-shrink: 0; cursor: col-resize; background: transparent;
100
+ position: relative; z-index: 5; margin: 0 -2px;
101
+ transition: background 0.15s;
102
+ }
103
+ .resize-handle:hover, .resize-handle.active { background: var(--c-selected); }
104
+ .resize-handle::after {
105
+ content: ''; position: absolute; inset: 0; width: 1px; margin: auto;
106
+ background: var(--border);
107
+ }
108
+ .resize-handle:hover::after, .resize-handle.active::after { background: transparent; }
109
+ body.resizing { cursor: col-resize !important; user-select: none !important; }
110
+ body.resizing * { cursor: col-resize !important; user-select: none !important; pointer-events: none !important; }
111
+ body.resizing .resize-handle { pointer-events: auto !important; }
112
+
79
113
  /* --- Tree panel (left) --- */
80
114
  #tree-panel {
81
- width: 300px; background: var(--panel); border-right: 2px solid var(--border);
82
- display: flex; flex-direction: column; flex-shrink: 0;
115
+ width: 300px; background: var(--panel);
116
+ display: flex; flex-direction: column; flex-shrink: 0; min-width: 150px;
83
117
  }
84
118
  #tree-header {
85
119
  padding: 4px 10px; border-bottom: 1px solid var(--border);
@@ -93,6 +127,17 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
93
127
  }
94
128
  #tree-search:focus { border-color: var(--c-selected); }
95
129
  #tree-search::placeholder { color: var(--border-bright); }
130
+
131
+ /* --- Category filter chips --- */
132
+ #tree-filters { display: flex; flex-wrap: wrap; gap: 2px; padding: 2px 6px 3px; }
133
+ .filter-chip {
134
+ font-family: var(--mono); font-size: 8px; padding: 1px 5px; border-radius: 3px;
135
+ border: 1px solid var(--border); background: transparent; color: var(--text-dim);
136
+ cursor: pointer; transition: all var(--transition); line-height: 1.4;
137
+ }
138
+ .filter-chip:hover { border-color: var(--border-bright); color: var(--text); }
139
+ .filter-chip.active { background: var(--text); color: var(--panel); border-color: var(--text); }
140
+
96
141
  #tree-list { flex: 1; overflow-y: auto; padding: 1px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
97
142
  #tree-list::-webkit-scrollbar { width: 5px; }
98
143
  #tree-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
@@ -106,6 +151,8 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
106
151
  .tree-node:hover { background: var(--surface); }
107
152
  .tree-node.selected { background: #dde4f4; border-left-color: var(--c-selected); }
108
153
  .tree-node.disabled { opacity: 0.3; }
154
+ .tree-node.hidden-vis { opacity: 0.35; }
155
+ .tree-node.hidden-vis .tree-name { text-decoration: line-through; }
109
156
  .tree-arrow { width: 12px; font-size: 7px; color: var(--border-bright); text-align: center; flex-shrink: 0; cursor: pointer; user-select: none; transition: color var(--transition); }
110
157
  .tree-arrow:hover { color: var(--text); }
111
158
  .tree-icon { width: 13px; height: 13px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
@@ -117,29 +164,60 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
117
164
  .tree-badge { font-family: var(--mono); font-size: 7px; padding: 0 4px; border-radius: 2px; flex-shrink: 0; font-weight: 500; }
118
165
  .badge-prefab { background: #d8e4f4; color: var(--c-prefab); }
119
166
 
120
- /* --- Viewport (center, single view) --- */
121
- #viewport { flex: 1; position: relative; background: var(--panel); overflow: hidden; min-width: 0; }
122
- #viewport canvas { width: 100%; height: 100%; cursor: grab; }
123
- #viewport canvas:active { cursor: grabbing; }
124
- #view-bar {
125
- position: absolute; top: 6px; left: 8px; display: flex; align-items: center; gap: 0; z-index: 2;
126
- background: rgba(255,255,255,0.92); border: 2px solid var(--border); border-radius: 4px; overflow: hidden;
127
- }
128
- .view-btn {
129
- font-family: var(--mono); font-size: 10px; font-weight: 600; padding: 3px 10px;
130
- border: none; background: none; color: var(--text-dim); cursor: pointer; transition: all var(--transition);
131
- letter-spacing: 0.3px;
132
- }
133
- .view-btn:hover { color: var(--text); background: var(--surface); }
134
- .view-btn.active { color: var(--c-selected); background: #dde4f4; }
135
- .view-btn + .view-btn { border-left: 1px solid var(--border); }
136
- #view-info {
137
- position: absolute; bottom: 6px; right: 8px; font-family: var(--mono); font-size: 9px;
138
- color: var(--text-dim); pointer-events: none; background: rgba(255,255,255,0.85); padding: 1px 6px; border-radius: 3px;
167
+ /* Eye (visibility) toggle */
168
+ .tree-vis {
169
+ width: 14px; height: 14px; flex-shrink: 0; display: flex; align-items: center;
170
+ justify-content: center; cursor: pointer; color: var(--border-bright);
171
+ opacity: 0; transition: opacity var(--transition), color var(--transition);
172
+ }
173
+ .tree-vis svg { width: 11px; height: 11px; }
174
+ .tree-node:hover .tree-vis { opacity: 0.6; }
175
+ .tree-vis:hover { color: var(--text); opacity: 1 !important; }
176
+ .tree-vis.vis-off { opacity: 0.7; color: var(--c-disabled); }
177
+ .tree-node:hover .tree-vis.vis-off { opacity: 1; }
178
+
179
+ /* --- 3D Viewport (center) --- */
180
+ #viewport { flex: 1; position: relative; background: #f0f0f4; overflow: hidden; min-width: 0; user-select: none; -webkit-user-select: none; }
181
+ #viewport canvas { width: 100%; height: 100%; display: block; -webkit-user-drag: none; user-select: none; -webkit-user-select: none; touch-action: none; }
182
+ #scene-labels {
183
+ position: absolute; inset: 0; pointer-events: none; overflow: hidden;
184
+ }
185
+ .scene-label {
186
+ position: absolute; font-family: var(--mono); font-size: 9px; font-weight: 500;
187
+ white-space: nowrap; pointer-events: auto; cursor: pointer;
188
+ transform: translate(-50%, -100%) translateY(-8px);
189
+ text-shadow: 0 0 3px rgba(255,255,255,0.9), 0 0 6px rgba(255,255,255,0.5);
190
+ opacity: 0.7; transition: opacity 0.15s;
191
+ }
192
+ .scene-label:hover { opacity: 1; }
193
+ .scene-label.selected { opacity: 1; font-weight: 600; font-size: 10px; }
194
+ .grad-label {
195
+ position: absolute; font-family: var(--mono); font-size: 9px; font-weight: 500;
196
+ white-space: nowrap; pointer-events: none;
197
+ transform: translate(-50%, -50%);
198
+ opacity: 0.6;
199
+ }
200
+ .grad-label-unit { font-weight: 600; font-size: 10px; opacity: 0.7; }
201
+
202
+ /* --- Gizmo --- */
203
+ #gizmo {
204
+ position: absolute; top: 8px; right: 8px; width: 90px; height: 90px; z-index: 3;
205
+ }
206
+ #gizmo canvas { width: 100%; height: 100%; }
207
+ .gizmo-label {
208
+ position: absolute; font-family: var(--mono); font-size: 11px; font-weight: 700;
209
+ pointer-events: auto; cursor: pointer; user-select: none;
210
+ transform: translate(-50%, -50%);
211
+ width: 18px; height: 18px; line-height: 18px; text-align: center;
212
+ border-radius: 50%;
213
+ }
214
+ .gizmo-label:hover { text-decoration: underline; }
215
+ .gizmo-back {
216
+ font-size: 8px; font-weight: 500; opacity: 0.3;
139
217
  }
140
218
  #view-stats {
141
- position: absolute; bottom: 6px; left: 8px; display: flex; gap: 12px; z-index: 2;
142
- font-family: var(--mono); font-size: 10px; background: rgba(255,255,255,0.92); padding: 3px 10px; border-radius: 3px; border: 1px solid var(--border);
219
+ position: absolute; bottom: 8px; left: 10px; display: flex; gap: 12px; z-index: 2;
220
+ font-family: var(--mono); font-size: 10px; background: rgba(255,255,255,0.85); padding: 3px 10px; border-radius: 3px; border: 1px solid var(--border);
143
221
  }
144
222
  .stat-item { display: flex; align-items: baseline; gap: 4px; }
145
223
  .stat-num { font-size: 13px; font-weight: 700; color: var(--text); }
@@ -147,8 +225,8 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
147
225
 
148
226
  /* --- Properties panel (right) --- */
149
227
  #props-panel {
150
- width: 240px; background: var(--panel); border-left: 2px solid var(--border);
151
- display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto;
228
+ width: 320px; background: var(--panel);
229
+ display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto; min-width: 180px;
152
230
  scrollbar-width: thin; scrollbar-color: var(--border) transparent;
153
231
  }
154
232
  #props-panel::-webkit-scrollbar { width: 5px; }
@@ -156,12 +234,12 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
156
234
  #props-header { padding: 4px 10px; border-bottom: 1px solid var(--border); }
157
235
  #props-header h2 { font-family: var(--mono); font-size: 9px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
158
236
  #props-content { padding: 6px 10px; }
159
- .prop-section { margin-bottom: 8px; animation: fadeIn 0.2s ease; }
237
+ .prop-section { margin-bottom: 8px; }
160
238
  @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
161
239
  .prop-section-title { font-family: var(--mono); font-size: 8px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3px; }
162
240
  .prop-row { display: flex; justify-content: space-between; align-items: center; padding: 1px 0; border-bottom: 1px solid var(--surface); }
163
241
  .prop-key { font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
164
- .prop-val { font-family: var(--mono); font-size: 10px; color: var(--text); text-align: right; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
242
+ .prop-val { font-family: var(--mono); font-size: 10px; color: var(--text); text-align: right; word-break: break-all; }
165
243
  .prop-val-color { display: inline-block; width: 10px; height: 10px; border-radius: 2px; border: 1px solid var(--border); vertical-align: middle; margin-right: 4px; }
166
244
  .prop-hint { font-size: 11px; color: var(--text-dim); padding: 16px 0; text-align: center; }
167
245
 
@@ -176,6 +254,7 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
176
254
  #welcome {
177
255
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
178
256
  z-index: 10; background: var(--bg); animation: fadeIn 0.3s ease;
257
+ overflow-y: auto; padding: 24px 0;
179
258
  }
180
259
  #welcome.hidden { display: none; }
181
260
  .welcome-box {
@@ -211,25 +290,50 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
211
290
 
212
291
  <div id="welcome">
213
292
  <div class="welcome-box">
214
- <h1>Lens Inspector</h1>
293
+ <h1>Connect to Lens Studio</h1>
215
294
  <div class="step">
216
295
  <span class="step-check">&#10003;</span>
217
- <span class="step-text">Server running</span>
296
+ <span class="step-text">Start the inspector: <code>npx lens-inspector</code></span>
218
297
  </div>
219
298
  <div class="step">
220
299
  <span class="step-num">2</span>
221
300
  <span class="step-text">
222
- Download <a href="/SceneInspector.ts" style="color: var(--accent);">SceneInspector.ts</a>, add it to your Lens Studio project's Assets, and attach it to any object.
223
- Then go to <strong>Project Settings &gt; General</strong> and enable <strong>Experimental APIs</strong>.
301
+ Download <a href="/SceneInspector.ts" style="color: var(--c-selected);">SceneInspector.ts</a> and drag it into your Lens Studio project's <strong>Assets</strong> panel.
302
+ Attach it to any SceneObject (e.g. Camera).
224
303
  </span>
225
304
  </div>
226
305
  <div class="step">
227
306
  <span class="step-num">3</span>
228
- <span class="step-text">Hit <strong>Play</strong> in Lens Studio.</span>
307
+ <span class="step-text">
308
+ In Lens Studio: <strong>Project Settings &gt; General &gt; Experimental APIs</strong> &rarr; enable.
309
+ </span>
310
+ </div>
311
+ <div class="step">
312
+ <span class="step-num">4</span>
313
+ <span class="step-text">Hit <strong>Play</strong> in Lens Studio. The scene appears here automatically. Component details require LS MCP (AI Assistant &gt; MCP, port 8733).</span>
314
+ </div>
315
+ <div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
316
+ <div style="font-family:var(--mono);font-size:9px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Server</div>
317
+ <div style="display:flex;gap:6px;align-items:center;">
318
+ <input type="text" id="welcome-url" value="localhost:8200"
319
+ style="flex:1;padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:3px;font-family:var(--mono);font-size:11px;color:var(--text);outline:none;">
320
+ </div>
321
+ <div style="font-size:9px;color:var(--text-muted);margin-top:4px;">Change this if your inspector runs on a different port or machine.</div>
322
+ <div style="margin-top:8px;">
323
+ <div style="font-family:var(--mono);font-size:9px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">MCP Config <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-muted)">(optional, for component details)</span></div>
324
+ <textarea id="mcp-config" rows="3" placeholder='Paste MCP config JSON from Lens Studio (AI Assistant > MCP)'
325
+ style="width:100%;padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:3px;font-family:var(--mono);font-size:10px;color:var(--text);outline:none;resize:vertical;line-height:1.4;"></textarea>
326
+ <div id="mcp-config-status" style="font-size:9px;color:var(--text-muted);margin-top:3px;">Copy from Lens Studio: AI Assistant > MCP config.</div>
327
+ </div>
328
+ <button id="server-connect-btn" style="margin-top:14px;width:100%;padding:8px 0;background:var(--c-selected);border:none;border-radius:4px;font-family:var(--font);font-size:12px;font-weight:600;cursor:pointer;color:#fff;">Connect</button>
229
329
  </div>
230
- <div class="welcome-status">
330
+ <div class="welcome-status" id="welcome-status">
231
331
  <div class="welcome-spinner"></div>
232
- <span>Waiting for Lens Studio to connect...</span>
332
+ <span>Waiting for Lens Studio...</span>
333
+ </div>
334
+ <div class="welcome-status" id="welcome-example" style="display:none">
335
+ <span style="color:var(--c-camera);font-weight:500">Viewing example scene.</span>
336
+ <span>Connect Lens Studio to see your real project.</span>
233
337
  </div>
234
338
  </div>
235
339
  </div>
@@ -238,17 +342,14 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
238
342
  <span class="toolbar-brand"><b>lens</b>-inspector</span>
239
343
  <div class="sep"></div>
240
344
  <span class="status-dot dot-off" id="ws-dot"></span>
241
- <span id="status-text">disconnected</span>
242
- <div class="sep"></div>
243
- <label style="font-size:10px;color:var(--text-muted)">Server:</label>
244
- <input id="ws-url" value="ws://localhost:8200" style="width:140px">
245
- <button id="ws-btn">Connect</button>
246
- <div class="sep"></div>
247
- <label style="font-size:10px;color:var(--text-muted)">MCP:</label>
248
- <input id="mcp-token" value="" placeholder="token" style="width:90px">
249
- <button id="mcp-btn">Design-Time</button>
250
- <span class="status-dot dot-off" id="mcp-dot"></span>
345
+ <span id="status-text">connecting...</span>
346
+ <span id="source-badge" class="badge-off"></span>
347
+ <span id="project-name"></span>
348
+ <div class="toolbar-spacer"></div>
349
+ <button id="setup-btn">How to connect</button>
251
350
  </div>
351
+ <input type="hidden" id="ws-url" value="ws://localhost:8200">
352
+ <input type="hidden" id="mcp-base" value="http://localhost:8200">
252
353
 
253
354
  <div id="main">
254
355
  <div id="tree-panel">
@@ -257,22 +358,33 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
257
358
  <span id="tree-count"></span>
258
359
  </div>
259
360
  <input type="text" id="tree-search" placeholder="Filter...">
361
+ <div id="tree-filters">
362
+ <button class="filter-chip active" data-cat="all">All</button>
363
+ <button class="filter-chip" data-cat="camera">Camera</button>
364
+ <button class="filter-chip" data-cat="visual">Visual</button>
365
+ <button class="filter-chip" data-cat="script">Script</button>
366
+ <button class="filter-chip" data-cat="light">Light</button>
367
+ <button class="filter-chip" data-cat="physics">Physics</button>
368
+ <button class="filter-chip" data-cat="audio">Audio</button>
369
+ <button class="filter-chip" data-cat="animation">Anim</button>
370
+ <button class="filter-chip" data-cat="ui">UI</button>
371
+ <button class="filter-chip" data-cat="tracking">Track</button>
372
+ <button class="filter-chip" data-cat="empty">Empty</button>
373
+ </div>
260
374
  <div id="tree-list"></div>
261
375
  </div>
376
+ <div class="resize-handle" id="resize-left"></div>
262
377
 
263
378
  <div id="viewport">
264
379
  <canvas id="scene-canvas"></canvas>
265
- <div id="view-bar">
266
- <button class="view-btn active" data-view="xz">TOP</button>
267
- <button class="view-btn" data-view="xy">FRONT</button>
268
- <button class="view-btn" data-view="yz">SIDE</button>
269
- </div>
270
- <span id="view-info"></span>
380
+ <div id="scene-labels"></div>
381
+ <div id="gizmo"><canvas width="180" height="180"></canvas></div>
271
382
  <div id="view-stats">
272
383
  <div class="stat-item"><span class="stat-num" id="stat-objects">0</span><span class="stat-label">objects</span></div>
273
384
  <div class="stat-item"><span class="stat-num" id="stat-components">0</span><span class="stat-label">components</span></div>
274
385
  </div>
275
386
  </div>
387
+ <div class="resize-handle" id="resize-right"></div>
276
388
 
277
389
  <div id="props-panel">
278
390
  <div id="props-header"><h2>Inspector</h2></div>
@@ -280,8 +392,11 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); heig
280
392
  </div>
281
393
  </div>
282
394
 
283
- <script>
284
- // --- Icons (no green) ---
395
+ <script type="module">
396
+ import * as THREE from 'three';
397
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
398
+
399
+ // --- Icons ---
285
400
  const ICONS = {
286
401
  camera: `<svg viewBox="0 0 12 12" fill="none"><rect x="1" y="3" width="7" height="6" rx="1" stroke="currentColor" stroke-width="1.2"/><path d="M8 5l3-1.5v5L8 7" stroke="currentColor" stroke-width="1.2"/></svg>`,
287
402
  visual: `<svg viewBox="0 0 12 12" fill="none"><rect x="2" y="2" width="8" height="8" rx="1" fill="currentColor" opacity="0.6"/></svg>`,
@@ -302,61 +417,630 @@ const ICONS = {
302
417
  unknown: `<svg viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="4" stroke="currentColor" stroke-width="1"/></svg>`,
303
418
  };
304
419
 
420
+ const ICON_EYE = `<svg viewBox="0 0 12 12" fill="none"><path d="M1 6s2-3.5 5-3.5S11 6 11 6s-2 3.5-5 3.5S1 6 1 6z" stroke="currentColor" stroke-width="1" fill="none"/><circle cx="6" cy="6" r="1.5" fill="currentColor"/></svg>`;
421
+ const ICON_EYE_OFF = `<svg viewBox="0 0 12 12" fill="none"><path d="M1 6s2-3.5 5-3.5S11 6 11 6s-2 3.5-5 3.5S1 6 1 6z" stroke="currentColor" stroke-width="1" fill="none"/><line x1="2" y1="10" x2="10" y2="2" stroke="currentColor" stroke-width="1.2"/></svg>`;
422
+
305
423
  const CAT_COLORS = {
306
- camera: '#d08020', visual: '#3088c0', text: '#3070d0', script: '#5060c0',
307
- audio: '#1090b0', light: '#c09010', physics: '#d05030', vfx: '#1098c0',
308
- interaction: '#4060c0', tracking: '#1090b0', animation: '#c07020', ui: '#3878d0',
309
- ml: '#3070c0', utility: '#70707e', empty: '#909098', prefab: '#4080d0', unknown: '#909098',
424
+ camera: '#ff9500', visual: '#0090ff', text: '#0080ff', script: '#ff2d20',
425
+ audio: '#ff6b00', light: '#ffc800', physics: '#ff1a1a', vfx: '#00a0ff',
426
+ interaction: '#ff5500', tracking: '#99a', animation: '#ff8800', ui: '#0070ff',
427
+ ml: '#5060ff', utility: '#88889a', empty: '#99a', prefab: '#00aaff', unknown: '#99a',
310
428
  };
311
429
 
312
430
  // --- State ---
313
- let liveTree = null, mcpTree = null, flatNodes = [], selectedId = null;
314
- let collapsedPaths = new Set(), searchFilter = '', ws = null;
315
-
316
- // Single view with switchable plane
317
- let activeView = 'xz';
318
- const viewDefs = {
319
- xz: { offset: {x:0,y:0}, scale: 14, axes: [0,2], label: ['X','Z'] },
320
- xy: { offset: {x:0,y:0}, scale: 14, axes: [0,1], label: ['X','Y'] },
321
- yz: { offset: {x:0,y:0}, scale: 14, axes: [2,1], label: ['Z','Y'] },
322
- };
323
- let canvas = null, ctx = null, dragging = false;
431
+ let liveTree = null, mcpTree = null, flatNodes = [], sceneNodes = [], selectedId = null;
432
+ let collapsedPaths = new Set(), hiddenPaths = new Set(), searchFilter = '', activeCategoryFilter = 'all', ws = null;
433
+ let dataSource = null; // 'example' | 'live' | null
434
+
435
+ // --- Three.js ---
436
+ let scene3d, camera3d, renderer3d, controls3d, raycaster, pointer, gridMat;
437
+ let sceneMeshes = []; // { group, boxMesh, node }
438
+ let labelEls = []; // { el, node }
439
+ const labelContainer = document.getElementById('scene-labels');
440
+ const tmpVec = new THREE.Vector3();
441
+
442
+ function initScene3D() {
443
+ const container = document.getElementById('viewport');
444
+ const canvas = document.getElementById('scene-canvas');
445
+
446
+ scene3d = new THREE.Scene();
447
+
448
+ // Gradient background (top: white, bottom: soft blue-gray)
449
+ const bgCanvas = document.createElement('canvas');
450
+ bgCanvas.width = 2; bgCanvas.height = 512;
451
+ const bgCtx = bgCanvas.getContext('2d');
452
+ const grad = bgCtx.createLinearGradient(0, 0, 0, 512);
453
+ grad.addColorStop(0, '#ffffff');
454
+ grad.addColorStop(0.5, '#f0f2f8');
455
+ grad.addColorStop(1, '#d8dce8');
456
+ bgCtx.fillStyle = grad;
457
+ bgCtx.fillRect(0, 0, 2, 512);
458
+ const bgTex = new THREE.CanvasTexture(bgCanvas);
459
+ bgTex.mapping = THREE.EquirectangularReflectionMapping;
460
+ scene3d.background = bgTex;
461
+
462
+ camera3d = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 2000);
463
+ camera3d.position.set(30, 25, 10);
464
+
465
+ renderer3d = new THREE.WebGLRenderer({ canvas, antialias: true });
466
+ renderer3d.setSize(container.clientWidth, container.clientHeight);
467
+ renderer3d.setPixelRatio(window.devicePixelRatio);
468
+
469
+ controls3d = new OrbitControls(camera3d, canvas);
470
+ controls3d.enableDamping = true;
471
+ controls3d.dampingFactor = 0.08;
472
+ controls3d.enablePan = true;
473
+ controls3d.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
474
+ controls3d.target.set(0, 0, 40);
475
+
476
+ // Infinite grid (multi-level shader, smooth transitions)
477
+ gridMat = new THREE.ShaderMaterial({
478
+ transparent: true, depthWrite: false, side: THREE.DoubleSide,
479
+ uniforms: { uCamDist: { value: 50.0 } },
480
+ vertexShader: `
481
+ varying vec3 vWorld;
482
+ void main() {
483
+ vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
484
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
485
+ }
486
+ `,
487
+ fragmentShader: `
488
+ varying vec3 vWorld;
489
+ uniform float uCamDist;
490
+ float gridLine(vec2 pos, float spacing) {
491
+ vec2 coord = pos / spacing;
492
+ vec2 g = abs(fract(coord - 0.5) - 0.5);
493
+ vec2 line = g / fwidth(coord);
494
+ return 1.0 - min(min(line.x, line.y), 1.0);
495
+ }
496
+ void main() {
497
+ float dist = length(vWorld.xz);
498
+ float fadeR = uCamDist * 3.0;
499
+ float fade = 1.0 - smoothstep(fadeR * 0.5, fadeR, dist);
500
+ if (fade < 0.01) discard;
501
+
502
+ float logD = log(uCamDist) / log(10.0);
503
+ float level = floor(logD);
504
+ float frac = logD - level;
505
+
506
+ float s0 = pow(10.0, level - 1.0);
507
+ float s1 = pow(10.0, level);
508
+ float s2 = pow(10.0, level + 1.0);
509
+
510
+ float g0 = gridLine(vWorld.xz, s0);
511
+ float g1 = gridLine(vWorld.xz, s1);
512
+ float g2 = gridLine(vWorld.xz, s2);
513
+
514
+ float a0 = g0 * 0.06 * (1.0 - frac);
515
+ float a1 = g1 * 0.18;
516
+ float a2 = g2 * (0.28 + 0.1 * frac);
517
+
518
+ float a = max(max(a0, a1), a2) * fade;
519
+ if (a < 0.01) discard;
520
+ gl_FragColor = vec4(0.55, 0.57, 0.63, a);
521
+ }
522
+ `,
523
+ });
524
+ const gridPlane = new THREE.Mesh(new THREE.PlaneGeometry(20000, 20000), gridMat);
525
+ gridPlane.rotation.x = -Math.PI / 2;
526
+ scene3d.add(gridPlane);
527
+
528
+ // Subtle axes at origin
529
+ const axLen = 8;
530
+ const axMat = (c) => new THREE.LineBasicMaterial({ color: c, transparent: true, opacity: 0.8 });
531
+ const mkAx = (dir, color) => {
532
+ const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), dir.clone().multiplyScalar(axLen)]);
533
+ return new THREE.Line(g, axMat(color));
534
+ };
535
+ scene3d.add(mkAx(new THREE.Vector3(1,0,0), 0xe03020));
536
+ scene3d.add(mkAx(new THREE.Vector3(0,1,0), 0xe0b010));
537
+ scene3d.add(mkAx(new THREE.Vector3(0,0,1), 0x1878e0));
538
+
539
+ raycaster = new THREE.Raycaster();
540
+ pointer = new THREE.Vector2();
541
+
542
+ // Click to select
543
+ canvas.addEventListener('pointerdown', onPointerDown);
544
+ canvas.addEventListener('pointermove', (e) => {
545
+ const rect = canvas.getBoundingClientRect();
546
+ pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
547
+ pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
548
+ });
549
+
550
+ window.addEventListener('resize', onResize);
551
+ onResize();
552
+
553
+ animate();
554
+ }
555
+
556
+ let pointerDownPos = { x: 0, y: 0 };
557
+ function onPointerDown(e) {
558
+ pointerDownPos = { x: e.clientX, y: e.clientY };
559
+ const canvas = renderer3d.domElement;
560
+ canvas.addEventListener('pointerup', onPointerUp, { once: true });
561
+ }
562
+
563
+ function onPointerUp(e) {
564
+ const dx = e.clientX - pointerDownPos.x, dy = e.clientY - pointerDownPos.y;
565
+ if (dx * dx + dy * dy > 25) return;
566
+
567
+ const rect = renderer3d.domElement.getBoundingClientRect();
568
+ pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
569
+ pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
570
+
571
+ raycaster.setFromCamera(pointer, camera3d);
572
+ const meshes = sceneMeshes.map(m => m.boxMesh);
573
+ const hits = raycaster.intersectObjects(meshes);
574
+ if (hits.length > 0) {
575
+ const hit = sceneMeshes.find(m => m.boxMesh === hits[0].object);
576
+ if (hit) {
577
+ selectedId = hit.node.id;
578
+ updateMeshStyles();
579
+ renderTree();
580
+ renderProps();
581
+ }
582
+ }
583
+ }
584
+
585
+ function onResize() {
586
+ const container = document.getElementById('viewport');
587
+ const w = container.clientWidth, h = container.clientHeight;
588
+ if (w <= 0 || h <= 0) return;
589
+ camera3d.aspect = w / h;
590
+ camera3d.updateProjectionMatrix();
591
+ renderer3d.setSize(w, h);
592
+ }
593
+
594
+ // --- Gizmo (navigation gnomon) ---
595
+ const gizmoEl = document.getElementById('gizmo');
596
+ const gizmoCanvas = gizmoEl.querySelector('canvas');
597
+ const gizmoCtx = gizmoCanvas.getContext('2d');
598
+ const gizmoDirs = [
599
+ { label: 'X', dir: new THREE.Vector3(1,0,0), color: '#e03020' },
600
+ { label: 'Y', dir: new THREE.Vector3(0,1,0), color: '#e0b010' },
601
+ { label: 'Z', dir: new THREE.Vector3(0,0,1), color: '#1878e0' },
602
+ ];
603
+ const gizmoLabels = [];
604
+
605
+ for (const d of gizmoDirs) {
606
+ const pos = document.createElement('div');
607
+ pos.className = 'gizmo-label';
608
+ pos.style.color = d.color;
609
+ pos.textContent = d.label;
610
+ pos.addEventListener('click', () => snapToView(d.dir.clone()));
611
+ gizmoEl.appendChild(pos);
612
+ gizmoLabels.push({ el: pos, dir: d.dir, color: d.color, back: false });
613
+
614
+ const neg = document.createElement('div');
615
+ neg.className = 'gizmo-label gizmo-back';
616
+ neg.style.color = d.color;
617
+ neg.textContent = '-' + d.label;
618
+ neg.addEventListener('click', () => snapToView(d.dir.clone().negate()));
619
+ gizmoEl.appendChild(neg);
620
+ gizmoLabels.push({ el: neg, dir: d.dir.clone().negate(), color: d.color, back: true });
621
+ }
622
+
623
+ let snapAnimId = null;
624
+ function snapToView(dir) {
625
+ const dist = camera3d.position.distanceTo(controls3d.target);
626
+ const target = controls3d.target.clone();
627
+ const endPos = target.clone().add(dir.clone().multiplyScalar(dist));
628
+ const startPos = camera3d.position.clone();
629
+ const up = Math.abs(dir.y) > 0.9 ? new THREE.Vector3(0, 0, dir.y > 0 ? -1 : 1) : new THREE.Vector3(0, 1, 0);
630
+ const startTime = performance.now();
631
+ if (snapAnimId) cancelAnimationFrame(snapAnimId);
632
+ function step() {
633
+ const t = Math.min((performance.now() - startTime) / 350, 1);
634
+ const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
635
+ camera3d.position.lerpVectors(startPos, endPos, ease);
636
+ camera3d.up.lerp(up, ease * 0.5 + 0.5);
637
+ camera3d.lookAt(target);
638
+ controls3d.update();
639
+ if (t < 1) snapAnimId = requestAnimationFrame(step);
640
+ else snapAnimId = null;
641
+ }
642
+ step();
643
+ }
644
+
645
+ function updateGizmo() {
646
+ const cx = 90, cy = 90, r = 32;
647
+ gizmoCtx.clearRect(0, 0, 180, 180);
648
+ const quat = camera3d.quaternion.clone().invert();
649
+
650
+ for (const d of gizmoDirs) {
651
+ const p = d.dir.clone().applyQuaternion(quat);
652
+ const n = d.dir.clone().negate().applyQuaternion(quat);
653
+ gizmoCtx.beginPath();
654
+ gizmoCtx.moveTo(cx + n.x * r, cy - n.y * r);
655
+ gizmoCtx.lineTo(cx + p.x * r, cy - p.y * r);
656
+ gizmoCtx.strokeStyle = d.color;
657
+ gizmoCtx.globalAlpha = 0.4;
658
+ gizmoCtx.lineWidth = 2;
659
+ gizmoCtx.stroke();
660
+ gizmoCtx.globalAlpha = 1;
661
+ }
662
+
663
+ for (const gl of gizmoLabels) {
664
+ const p = gl.dir.clone().applyQuaternion(quat);
665
+ const x = 45 + p.x * 34;
666
+ const y = 45 - p.y * 34;
667
+ gl.el.style.left = x + 'px';
668
+ gl.el.style.top = y + 'px';
669
+ gl.el.style.opacity = gl.back ? (p.z > 0 ? '0.15' : '0.4') : (p.z > 0 ? '0.4' : '0.9');
670
+ gl.el.style.zIndex = p.z > 0 ? '0' : '1';
671
+ }
672
+ }
673
+
674
+ function animate() {
675
+ requestAnimationFrame(animate);
676
+ updateFlyMovement();
677
+ controls3d.update();
678
+ rebuildGraduations();
679
+ renderer3d.render(scene3d, camera3d);
680
+ updateLabelPositions();
681
+ updateGradLabels();
682
+ updateGizmo();
683
+ }
684
+
685
+ let hasAutoFit = false;
686
+
687
+ function autoFitCamera() {
688
+ const nodes = sceneNodes.length ? sceneNodes : flatNodes;
689
+ if (nodes.length === 0) return;
690
+ const box = new THREE.Box3();
691
+ for (const node of nodes) {
692
+ const p = new THREE.Vector3(node.pos[0], node.pos[1], node.pos[2]);
693
+ const s = new THREE.Vector3(
694
+ Math.max(Math.abs(node.scale[0]), 0.3),
695
+ Math.max(Math.abs(node.scale[1]), 0.3),
696
+ Math.max(Math.abs(node.scale[2]), 0.3)
697
+ );
698
+ box.expandByPoint(p.clone().sub(s.clone().multiplyScalar(0.5)));
699
+ box.expandByPoint(p.clone().add(s.clone().multiplyScalar(0.5)));
700
+ }
701
+ const center = box.getCenter(new THREE.Vector3());
702
+ const size = box.getSize(new THREE.Vector3());
703
+ const maxDim = Math.max(size.x, size.y, size.z);
704
+ const dist = maxDim * 1.2;
705
+
706
+ controls3d.target.copy(center);
707
+ camera3d.position.set(center.x + dist * 0.6, center.y + dist * 0.4, center.z - dist * 0.6);
708
+ controls3d.update();
709
+ }
710
+
711
+ // --- Build 3D scene from scene nodes (independent of tree collapse) ---
712
+ function buildScene3D() {
713
+ for (const m of sceneMeshes) {
714
+ scene3d.remove(m.group);
715
+ m.group.traverse(child => {
716
+ if (child.geometry) child.geometry.dispose();
717
+ if (child.material) child.material.dispose();
718
+ });
719
+ }
720
+ sceneMeshes = [];
721
+
722
+ for (const l of labelEls) l.el.remove();
723
+ labelEls = [];
724
+
725
+ const visibleNodes = sceneNodes.filter(n => !hiddenPaths.has(n.id));
726
+
727
+ for (const node of visibleNodes) {
728
+ const cat = node.isPrefab ? 'prefab' : node.category;
729
+ const hexColor = CAT_COLORS[cat] || '#909098';
730
+ const color = new THREE.Color(hexColor);
731
+ const sel = node.id === selectedId;
732
+
733
+ const group = new THREE.Group();
734
+ group.position.set(node.pos[0], node.pos[1], node.pos[2]);
735
+
736
+ const sx = Math.max(Math.abs(node.scale[0]), 0.3);
737
+ const sy = Math.max(Math.abs(node.scale[1]), 0.3);
738
+ const sz = Math.max(Math.abs(node.scale[2]), 0.3);
739
+
740
+ const geom = new THREE.BoxGeometry(sx, sy, sz);
741
+
742
+ const fillMat = new THREE.MeshBasicMaterial({
743
+ color, transparent: true, opacity: sel ? 0.3 : 0.15, depthWrite: false, side: THREE.DoubleSide,
744
+ });
745
+ const boxMesh = new THREE.Mesh(geom, fillMat);
746
+ group.add(boxMesh);
747
+
748
+ const edges = new THREE.EdgesGeometry(geom);
749
+ const edgeMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: sel ? 1.0 : 0.65 });
750
+ const wire = new THREE.LineSegments(edges, edgeMat);
751
+ group.add(wire);
752
+
753
+ scene3d.add(group);
754
+ sceneMeshes.push({ group, boxMesh, wire, fillMat, edgeMat, node });
755
+
756
+ const label = document.createElement('div');
757
+ label.className = 'scene-label' + (sel ? ' selected' : '');
758
+ label.style.color = hexColor;
759
+ label.textContent = node.name;
760
+ label.dataset.id = node.id;
761
+ label.addEventListener('click', () => {
762
+ selectedId = node.id;
763
+ updateMeshStyles();
764
+ renderTree();
765
+ renderProps();
766
+ });
767
+ labelContainer.appendChild(label);
768
+ labelEls.push({ el: label, node });
769
+ }
770
+
771
+ if (!hasAutoFit && sceneNodes.length > 0) {
772
+ hasAutoFit = true;
773
+ autoFitCamera();
774
+ }
775
+ }
776
+
777
+ function updateMeshStyles() {
778
+ for (const m of sceneMeshes) {
779
+ const sel = m.node.id === selectedId;
780
+ m.fillMat.opacity = sel ? 0.25 : 0.1;
781
+ m.edgeMat.opacity = sel ? 1.0 : 0.5;
782
+ }
783
+ for (const l of labelEls) {
784
+ l.el.classList.toggle('selected', l.node.id === selectedId);
785
+ }
786
+ }
787
+
788
+ function updateLabelPositions() {
789
+ if (!camera3d || !renderer3d) return;
790
+ const w = renderer3d.domElement.clientWidth;
791
+ const h = renderer3d.domElement.clientHeight;
792
+
793
+ const labelData = [];
794
+ for (const { el, node } of labelEls) {
795
+ const m = sceneMeshes.find(m => m.node.id === node.id);
796
+ if (!m) { el.style.display = 'none'; continue; }
797
+
798
+ tmpVec.copy(m.group.position);
799
+ const sy = Math.max(Math.abs(node.scale[1]), 0.3);
800
+ tmpVec.y += sy / 2;
801
+ tmpVec.project(camera3d);
802
+
803
+ if (tmpVec.z > 1) { el.style.display = 'none'; continue; }
804
+
805
+ const x = (tmpVec.x * 0.5 + 0.5) * w;
806
+ const y = (-tmpVec.y * 0.5 + 0.5) * h;
807
+ const dist = camera3d.position.distanceTo(m.group.position);
808
+ const maxDist = 150;
809
+
810
+ if (dist > maxDist && node.id !== selectedId) { el.style.display = 'none'; continue; }
811
+
812
+ labelData.push({ el, node, x, y, dist, sel: node.id === selectedId });
813
+ }
814
+
815
+ labelData.sort((a, b) => {
816
+ if (a.sel !== b.sel) return a.sel ? -1 : 1;
817
+ return a.dist - b.dist;
818
+ });
819
+
820
+ const placed = [];
821
+ const labelH = 14;
822
+ const pad = 4;
823
+
824
+ for (const d of labelData) {
825
+ const labelW = d.node.name.length * 6.5 + 8;
826
+ const candidates = [
827
+ { x: d.x - labelW / 2, y: d.y - labelH - 8 },
828
+ { x: d.x - labelW - 4, y: d.y - labelH - 4 },
829
+ { x: d.x + 4, y: d.y - labelH - 4 },
830
+ { x: d.x - labelW / 2, y: d.y + 4 },
831
+ ];
324
832
 
325
- // --- Flatten ---
326
- function flattenTree(roots, source) {
833
+ let ok = false;
834
+ for (const c of candidates) {
835
+ const overlaps = placed.some(p =>
836
+ c.x < p.x + p.w + pad && c.x + labelW + pad > p.x &&
837
+ c.y < p.y + p.h + pad && c.y + labelH + pad > p.y
838
+ );
839
+ if (!overlaps || d.sel) {
840
+ d.el.style.display = '';
841
+ d.el.style.left = (c.x + labelW / 2) + 'px';
842
+ d.el.style.top = (c.y + labelH) + 'px';
843
+ placed.push({ x: c.x, y: c.y, w: labelW, h: labelH });
844
+ ok = true;
845
+ break;
846
+ }
847
+ }
848
+
849
+ if (!ok) {
850
+ d.el.style.display = d.sel ? '' : 'none';
851
+ if (d.sel) {
852
+ d.el.style.left = d.x + 'px';
853
+ d.el.style.top = (d.y - labelH - 8) + 'px';
854
+ }
855
+ }
856
+ }
857
+ }
858
+
859
+ // --- Adaptive Graduations ---
860
+ let gradLabelEls = [];
861
+ let gradGroup = null;
862
+ let lastGradSpacing = 0;
863
+
864
+ const GRAD_AXES = [
865
+ { name: 'X', color: 0xe03020, css: '#e03020', dir: new THREE.Vector3(1,0,0), perp: new THREE.Vector3(0,1,0) },
866
+ { name: 'Y', color: 0xe0b010, css: '#e0b010', dir: new THREE.Vector3(0,1,0), perp: new THREE.Vector3(1,0,0) },
867
+ { name: 'Z', color: 0x1878e0, css: '#1878e0', dir: new THREE.Vector3(0,0,1), perp: new THREE.Vector3(0,1,0) },
868
+ ];
869
+
870
+ function niceNum(v) {
871
+ const exp = Math.floor(Math.log10(v));
872
+ const frac = v / Math.pow(10, exp);
873
+ const nice = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
874
+ return nice * Math.pow(10, exp);
875
+ }
876
+
877
+ function formatCm(v, spacing) {
878
+ if (v === 0) return '0';
879
+ if (spacing >= 100) return (v / 100) + ' m';
880
+ if (spacing < 1) return Math.round(v * 10) + ' mm';
881
+ if (Number.isInteger(v)) return v + ' cm';
882
+ return +v.toFixed(1) + ' cm';
883
+ }
884
+
885
+ function rebuildGraduations() {
886
+ if (!camera3d || !controls3d) return;
887
+ const dist = camera3d.position.distanceTo(controls3d.target);
888
+ const majorSpacing = niceNum(dist * 0.25);
889
+
890
+ if (gridMat) gridMat.uniforms.uCamDist.value = dist;
891
+
892
+ if (majorSpacing === lastGradSpacing) return;
893
+ lastGradSpacing = majorSpacing;
894
+
895
+ if (gradGroup) {
896
+ scene3d.remove(gradGroup);
897
+ gradGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); });
898
+ }
899
+ for (const gl of gradLabelEls) gl.el.remove();
900
+ gradLabelEls = [];
901
+ gradGroup = new THREE.Group();
902
+
903
+ const minorSpacing = majorSpacing / 5;
904
+ const range = majorSpacing * 10;
905
+ const nTicks = Math.round(range / minorSpacing);
906
+ const majorTickSize = Math.max(majorSpacing * 0.06, 0.5);
907
+ const minorTickSize = majorTickSize * 0.5;
908
+
909
+ for (const ax of GRAD_AXES) {
910
+ const lineMat = new THREE.LineBasicMaterial({ color: ax.color, transparent: true, opacity: 0.2 });
911
+ const majorTickMat = new THREE.LineBasicMaterial({ color: ax.color, transparent: true, opacity: 0.5 });
912
+ const minorTickMat = new THREE.LineBasicMaterial({ color: ax.color, transparent: true, opacity: 0.2 });
913
+
914
+ gradGroup.add(new THREE.Line(
915
+ new THREE.BufferGeometry().setFromPoints([ax.dir.clone().multiplyScalar(-range), ax.dir.clone().multiplyScalar(range)]),
916
+ lineMat
917
+ ));
918
+
919
+ const majorPts = [], minorPts = [];
920
+ for (let i = -nTicks; i <= nTicks; i++) {
921
+ if (i === 0) continue;
922
+ const v = i * minorSpacing;
923
+ const isMajor = (i % 5) === 0;
924
+ const ts = isMajor ? majorTickSize : minorTickSize;
925
+ const center = ax.dir.clone().multiplyScalar(v);
926
+ majorPts.push(
927
+ ...(isMajor ? [center.clone().add(ax.perp.clone().multiplyScalar(ts)), center.clone().sub(ax.perp.clone().multiplyScalar(ts))] : [])
928
+ );
929
+ if (!isMajor) {
930
+ minorPts.push(center.clone().add(ax.perp.clone().multiplyScalar(ts)), center.clone().sub(ax.perp.clone().multiplyScalar(ts)));
931
+ }
932
+
933
+ if (isMajor) {
934
+ const label = document.createElement('div');
935
+ label.className = 'grad-label';
936
+ label.style.color = ax.css;
937
+ label.textContent = formatCm(v, majorSpacing);
938
+ labelContainer.appendChild(label);
939
+ const lPos = center.clone().add(ax.perp.clone().multiplyScalar(ts + Math.max(majorSpacing * 0.04, 0.8)));
940
+ gradLabelEls.push({ el: label, pos: lPos });
941
+ }
942
+ }
943
+
944
+ if (majorPts.length) gradGroup.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(majorPts), majorTickMat));
945
+ if (minorPts.length) gradGroup.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(minorPts), minorTickMat));
946
+
947
+ const endLabel = document.createElement('div');
948
+ endLabel.className = 'grad-label grad-label-unit';
949
+ endLabel.style.color = ax.css;
950
+ endLabel.textContent = ax.name;
951
+ labelContainer.appendChild(endLabel);
952
+ gradLabelEls.push({ el: endLabel, pos: ax.dir.clone().multiplyScalar(range + majorSpacing * 0.5) });
953
+ }
954
+
955
+ scene3d.add(gradGroup);
956
+ }
957
+
958
+ function updateGradLabels() {
959
+ if (!camera3d || !renderer3d) return;
960
+ const w = renderer3d.domElement.clientWidth;
961
+ const h = renderer3d.domElement.clientHeight;
962
+
963
+ const items = [];
964
+ for (const gl of gradLabelEls) {
965
+ tmpVec.copy(gl.pos);
966
+ tmpVec.project(camera3d);
967
+ if (tmpVec.z > 1) { gl.el.style.display = 'none'; continue; }
968
+ const x = (tmpVec.x * 0.5 + 0.5) * w;
969
+ const y = (-tmpVec.y * 0.5 + 0.5) * h;
970
+ if (x < -30 || x > w + 30 || y < -30 || y > h + 30) { gl.el.style.display = 'none'; continue; }
971
+ const dist = camera3d.position.distanceTo(gl.pos);
972
+ items.push({ gl, x, y, dist });
973
+ }
974
+ items.sort((a, b) => a.dist - b.dist);
975
+
976
+ const placed = [];
977
+ const lh = 12, pad = 3;
978
+ for (const it of items) {
979
+ const lw = it.gl.el.textContent.length * 6 + 4;
980
+ const rx = it.x - lw / 2, ry = it.y - lh / 2;
981
+ const overlaps = placed.some(p =>
982
+ rx < p.x + p.w + pad && rx + lw + pad > p.x &&
983
+ ry < p.y + p.h + pad && ry + lh + pad > p.y
984
+ );
985
+ if (overlaps) { it.gl.el.style.display = 'none'; continue; }
986
+ it.gl.el.style.display = '';
987
+ it.gl.el.style.left = it.x + 'px';
988
+ it.gl.el.style.top = it.y + 'px';
989
+ placed.push({ x: rx, y: ry, w: lw, h: lh });
990
+ }
991
+ }
992
+
993
+ // --- Classify a node from its components ---
994
+ function classifyNode(node, components) {
995
+ let category = node.category || 'empty';
996
+ let hasVisual = node.hasVisual || false;
997
+ if (components.length > 0 && (category === 'empty' || category === 'unknown')) {
998
+ const types = components.map(c => c.type || '');
999
+ if (types.some(t => t.includes('Camera'))) category = 'camera';
1000
+ else if (types.some(t => t.includes('Script') || t.includes('Inspector') || t.includes('Spawner'))) category = 'script';
1001
+ else if (types.some(t => t.includes('Light') || t === 'LightSource' || t.includes('DirectionalLight') || t.includes('PointLight') || t.includes('AmbientLight'))) category = 'light';
1002
+ else if (types.some(t => t.includes('Audio'))) category = 'audio';
1003
+ else if (types.some(t => t.includes('Animation') || t.includes('BlendShapes'))) category = 'animation';
1004
+ else if (types.some(t => t.includes('Body') || t.includes('Collider') || t.includes('Physics'))) category = 'physics';
1005
+ else if (types.some(t => t.includes('VFX') || t.includes('Particle'))) category = 'vfx';
1006
+ else if (types.some(t => t.includes('Interaction') || t.includes('Manipulate'))) category = 'interaction';
1007
+ else if (types.some(t => t.includes('Tracking') || t.includes('DeviceTracking') || t.includes('ObjectTracking') || t.includes('Head'))) category = 'tracking';
1008
+ else if (types.some(t => t.includes('ScreenTransform') || t.includes('Canvas') || t.includes('ScreenRegion'))) category = 'ui';
1009
+ else if (types.some(t => t.includes('ML') || t.includes('SnapML'))) category = 'ml';
1010
+ else if (types.some(t => t.includes('RenderMeshVisual') || t.includes('Image') || t.includes('FaceMask') || t.includes('SpriteVisual'))) { category = 'visual'; hasVisual = true; }
1011
+ else if (types.some(t => t.includes('Text'))) category = 'text';
1012
+ }
1013
+ return { category, hasVisual };
1014
+ }
1015
+
1016
+ // --- Flatten tree ---
1017
+ // respectCollapse=true for tree display, false for 3D scene (shows everything)
1018
+ function flattenTree(roots, source, respectCollapse) {
327
1019
  const result = [];
328
1020
  function walk(node, depth, path) {
329
1021
  const id = source + ':' + path;
330
1022
  const hasChildren = node.children && node.children.length > 0;
331
1023
  const collapsed = collapsedPaths.has(id);
332
- let category = node.category || 'empty';
333
- if (category === 'empty' && node.components && node.components.length > 0) {
334
- const types = node.components.map(c => c.type || '');
335
- if (types.some(t => t.includes('Camera'))) category = 'camera';
336
- else if (types.some(t => t.includes('Script') || t.includes('Inspector') || t.includes('Spawner'))) category = 'script';
337
- else if (types.some(t => t.includes('Light'))) category = 'light';
338
- else if (types.some(t => t.includes('Audio'))) category = 'audio';
339
- else if (types.some(t => t.includes('Animation'))) category = 'animation';
340
- else if (types.some(t => t.includes('Body') || t.includes('Collider') || t.includes('Physics'))) category = 'physics';
341
- else if (types.some(t => t.includes('VFX') || t.includes('Particle'))) category = 'vfx';
342
- else if (types.some(t => t.includes('Interaction') || t.includes('Manipulate'))) category = 'interaction';
343
- else if (types.some(t => t.includes('Tracking') || t.includes('DeviceTracking') || t.includes('Head'))) category = 'tracking';
344
- else if (types.some(t => t.includes('ScreenTransform') || t.includes('Canvas') || t.includes('ScreenRegion'))) category = 'ui';
345
- else if (types.some(t => t.includes('ML'))) category = 'ml';
346
- else if (node.hasVisual) category = 'visual';
347
- else if (node.text) category = 'text';
1024
+
1025
+ // Get components: live data first, MCP enrichment if empty
1026
+ let components = node.components || [];
1027
+ if (components.length === 0 && mcpComponentMap && mcpComponentMap[node.name]) {
1028
+ components = mcpComponentMap[node.name].components;
348
1029
  }
1030
+
1031
+ const { category, hasVisual } = classifyNode(node, components);
1032
+
349
1033
  const isPrefab = node.isPrefab || (node.name && node.name.startsWith('[Prefab]'));
350
1034
  result.push({
351
1035
  id, name: node.name || '(unnamed)', depth, hasChildren, collapsed,
352
1036
  enabled: node.enabled !== false, category, source, isPrefab,
353
1037
  text: node.text || null,
354
1038
  pos: node.pos || [0,0,0], scale: node.scale || [1,1,1], rot: node.rot || [0,0,0,1],
355
- components: node.components || [], hasVisual: node.hasVisual || false,
1039
+ components, hasVisual,
356
1040
  color: node.color || null, layer: node.layer || null,
357
1041
  childCount: node.children ? node.children.length : 0,
358
1042
  });
359
- if (hasChildren && !collapsed) {
1043
+ if (hasChildren && (!respectCollapse || !collapsed)) {
360
1044
  for (let i = 0; i < node.children.length; i++) walk(node.children[i], depth+1, path+'/'+(node.children[i].name||i));
361
1045
  }
362
1046
  }
@@ -366,20 +1050,31 @@ function flattenTree(roots, source) {
366
1050
 
367
1051
  function rebuildFlat() {
368
1052
  const tree = liveTree || mcpTree;
369
- if (!tree) { flatNodes = []; return; }
370
- flatNodes = flattenTree(tree, liveTree ? 'live' : 'mcp');
371
- if (searchFilter) {
1053
+ if (!tree) { flatNodes = []; sceneNodes = []; return; }
1054
+ const source = liveTree ? 'live' : 'mcp';
1055
+
1056
+ // Full walk for 3D scene (ignores tree collapse state)
1057
+ sceneNodes = flattenTree(tree, source, false);
1058
+
1059
+ // Tree walk (respects collapse for display)
1060
+ flatNodes = flattenTree(tree, source, true);
1061
+
1062
+ // Apply text search + category filter to tree display only
1063
+ if (searchFilter || activeCategoryFilter !== 'all') {
372
1064
  const q = searchFilter.toLowerCase();
373
- flatNodes = flatNodes.filter(n =>
374
- n.name.toLowerCase().includes(q) || (n.text && n.text.toLowerCase().includes(q)) ||
375
- n.components.some(c => (c.type||'').toLowerCase().includes(q)) || n.category.includes(q)
376
- );
1065
+ flatNodes = flatNodes.filter(n => {
1066
+ const matchSearch = !searchFilter ||
1067
+ n.name.toLowerCase().includes(q) || (n.text && n.text.toLowerCase().includes(q)) ||
1068
+ n.components.some(c => (c.type||'').toLowerCase().includes(q)) || n.category.includes(q);
1069
+ const matchCat = activeCategoryFilter === 'all' || n.category === activeCategoryFilter;
1070
+ return matchSearch && matchCat;
1071
+ });
377
1072
  }
378
- document.getElementById('tree-count').textContent = flatNodes.length;
379
- // Update stats
1073
+
1074
+ document.getElementById('tree-count').textContent = flatNodes.length + ' / ' + sceneNodes.length;
380
1075
  let totalComp = 0;
381
- flatNodes.forEach(n => totalComp += n.components.length);
382
- document.getElementById('stat-objects').textContent = flatNodes.length;
1076
+ sceneNodes.forEach(n => totalComp += n.components.length);
1077
+ document.getElementById('stat-objects').textContent = sceneNodes.length;
383
1078
  document.getElementById('stat-components').textContent = totalComp;
384
1079
  }
385
1080
 
@@ -395,30 +1090,51 @@ function renderTree() {
395
1090
  const textPreview = n.text ? `<span class="tree-text-preview">"${esc(n.text.substring(0,14))}"</span>` : '';
396
1091
  const sel = n.id === selectedId ? ' selected' : '';
397
1092
  const dis = n.enabled ? '' : ' disabled';
1093
+ const visHidden = hiddenPaths.has(n.id);
1094
+ const visClass = visHidden ? ' hidden-vis' : '';
398
1095
  const cc = n.components.length > 1 ? `<span class="tree-comp-count">${n.components.length}</span>` : '';
399
- return `<div class="tree-node${sel}${dis}" data-id="${esc(n.id)}" style="padding-left:${indent+4}px">
1096
+ const eye = `<span class="tree-vis${visHidden ? ' vis-off' : ''}" data-vis="${esc(n.id)}">${visHidden ? ICON_EYE_OFF : ICON_EYE}</span>`;
1097
+ return `<div class="tree-node${sel}${dis}${visClass}" data-id="${esc(n.id)}" style="padding-left:${indent+4}px">
400
1098
  <span class="tree-arrow" data-toggle="${esc(n.id)}">${arrowChar}</span>
401
1099
  <span class="tree-icon" style="color:${iconColor}">${ICONS[cat]||ICONS.empty}</span>
402
- <span class="${nameClass}">${esc(n.name)}</span>${textPreview}${cc}
1100
+ <span class="${nameClass}">${esc(n.name)}</span>${textPreview}${cc}${eye}
403
1101
  </div>`;
404
1102
  }).join('');
405
1103
  list.innerHTML = html || '<p class="prop-hint">No scene data</p>';
406
1104
  list.onclick = (e) => {
1105
+ // Eye toggle (visibility)
1106
+ const vis = e.target.closest('[data-vis]');
1107
+ if (vis) {
1108
+ const id = vis.dataset.vis;
1109
+ if (hiddenPaths.has(id)) hiddenPaths.delete(id); else hiddenPaths.add(id);
1110
+ renderTree();
1111
+ buildScene3D();
1112
+ return;
1113
+ }
1114
+ // Arrow toggle (collapse)
407
1115
  const toggle = e.target.closest('[data-toggle]');
408
- if (toggle) { const id = toggle.dataset.toggle; if (collapsedPaths.has(id)) collapsedPaths.delete(id); else collapsedPaths.add(id); rebuildFlat(); renderTree(); renderAllViews(); return; }
1116
+ if (toggle) {
1117
+ const id = toggle.dataset.toggle;
1118
+ if (collapsedPaths.has(id)) collapsedPaths.delete(id); else collapsedPaths.add(id);
1119
+ rebuildFlat(); renderTree();
1120
+ // NOTE: collapsing no longer affects 3D scene
1121
+ return;
1122
+ }
1123
+ // Row click (select)
409
1124
  const row = e.target.closest('.tree-node');
410
- if (row) { selectedId = row.dataset.id; renderTree(); renderProps(); renderAllViews(); }
1125
+ if (row) { selectedId = row.dataset.id; updateMeshStyles(); renderTree(); renderProps(); }
411
1126
  };
412
1127
  }
413
1128
 
414
1129
  // --- Properties ---
415
1130
  function renderProps() {
416
1131
  const el = document.getElementById('props-content');
417
- const node = flatNodes.find(n => n.id === selectedId);
1132
+ // Look in both flatNodes (tree) and sceneNodes (full) since tree might be filtered
1133
+ const node = flatNodes.find(n => n.id === selectedId) || sceneNodes.find(n => n.id === selectedId);
418
1134
  if (!node) { el.innerHTML = '<p class="prop-hint">Select a node</p>'; return; }
419
1135
  const cat = node.isPrefab ? 'prefab' : node.category;
420
1136
  const cc = CAT_COLORS[cat] || CAT_COLORS.empty;
421
- let h = `<div style="margin-bottom:10px;display:flex;align-items:center;gap:6px;animation:fadeIn 0.2s ease">
1137
+ let h = `<div style="margin-bottom:10px;display:flex;align-items:center;gap:6px">
422
1138
  <span class="tree-icon" style="color:${cc}">${ICONS[cat]||ICONS.empty}</span>
423
1139
  <span style="font-weight:600;font-size:12px">${esc(node.name)}</span>
424
1140
  </div>`;
@@ -448,213 +1164,340 @@ function renderProps() {
448
1164
  el.innerHTML = h;
449
1165
  }
450
1166
 
451
- // --- Scene View ---
452
- function renderScene() {
453
- const v = viewDefs[activeView];
454
- const dpr = window.devicePixelRatio || 1;
455
- const w = canvas.clientWidth, h = canvas.clientHeight;
456
- if (w <= 0 || h <= 0) return;
457
- canvas.width = w * dpr; canvas.height = h * dpr;
458
- ctx.scale(dpr, dpr);
459
-
460
- const cx = w/2 + v.offset.x, cy = h/2 + v.offset.y;
461
- const [ax, ay] = v.axes;
462
- const flipY = ay === 1;
463
-
464
- // Background
465
- ctx.fillStyle = '#f8f8fa';
466
- ctx.fillRect(0, 0, w, h);
467
-
468
- // Grid
469
- const gs = v.scale * 5;
470
- if (gs > 3) {
471
- ctx.strokeStyle = '#e4e4ea';
472
- ctx.lineWidth = 1;
473
- for (let x = cx % gs; x < w; x += gs) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
474
- for (let y = cy % gs; y < h; y += gs) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
1167
+ // --- Server URL ---
1168
+ function getServerHost() {
1169
+ return localStorage.getItem('lens-inspector-host') || location.host || 'localhost:8200';
1170
+ }
1171
+ function applyServerUrl() {
1172
+ const input = document.getElementById('welcome-url');
1173
+ let host = input.value.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
1174
+ if (!host) host = 'localhost:8200';
1175
+ localStorage.setItem('lens-inspector-host', host);
1176
+ applyMcpConfig();
1177
+ input.value = host;
1178
+ document.getElementById('ws-url').value = 'ws://' + host;
1179
+ document.getElementById('mcp-base').value = 'http://' + host;
1180
+ liveTree = null; mcpTree = null; mcpComponentMap = null; hasFetchedMCP = false;
1181
+ dataSource = null;
1182
+ connectWS();
1183
+ }
1184
+ // Set initial values from storage
1185
+ {
1186
+ const h = getServerHost();
1187
+ document.getElementById('ws-url').value = 'ws://' + h;
1188
+ document.getElementById('mcp-base').value = 'http://' + h;
1189
+ const welcomeInput = document.getElementById('welcome-url');
1190
+ if (welcomeInput) welcomeInput.value = h;
1191
+ const savedConfig = localStorage.getItem('lens-inspector-mcp-config');
1192
+ const configArea = document.getElementById('mcp-config');
1193
+ if (savedConfig && configArea) {
1194
+ configArea.value = savedConfig;
1195
+ applyMcpConfig();
475
1196
  }
1197
+ }
476
1198
 
477
- // Origin axes
478
- ctx.lineWidth = 1.5;
479
- ctx.strokeStyle = '#d0d0d8'; ctx.beginPath(); ctx.moveTo(0,cy); ctx.lineTo(w,cy); ctx.stroke();
480
- ctx.strokeStyle = '#d0d0d8'; ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,h); ctx.stroke();
481
-
482
- // Axis labels
483
- ctx.font = '600 9px "IBM Plex Mono", monospace'; ctx.fillStyle = '#a0a0aa';
484
- ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
485
- ctx.fillText('+' + v.label[0], w-6, cy-4);
486
- ctx.textAlign = 'left'; ctx.textBaseline = 'top';
487
- ctx.fillText('+' + v.label[1], cx+4, 4);
488
-
489
- // Nodes: first pass draws boxes, second pass draws labels with collision avoidance
490
- const nodeRects = [];
491
- for (const node of flatNodes) {
492
- const px = cx + node.pos[ax] * v.scale;
493
- const py = flipY ? cy - node.pos[ay] * v.scale : cy + node.pos[ay] * v.scale;
494
- const sw = Math.max(Math.abs(node.scale[ax]), 0.4) * v.scale;
495
- const sh = Math.max(Math.abs(node.scale[ay]), 0.4) * v.scale;
496
- const sel = node.id === selectedId;
497
- const cat = node.isPrefab ? 'prefab' : node.category;
498
- const catColor = CAT_COLORS[cat] || '#909098';
499
-
500
- // Fill
501
- if (node.hasVisual && node.color) {
502
- const [r,g,b,a] = node.color;
503
- ctx.fillStyle = `rgba(${r},${g},${b},${Math.max((a||255)/255*0.2, 0.05)})`;
504
- ctx.fillRect(px-sw/2, py-sh/2, sw, sh);
505
- } else if (node.hasVisual || (cat !== 'empty' && cat !== 'unknown')) {
506
- ctx.fillStyle = catColor + '12';
507
- ctx.fillRect(px-sw/2, py-sh/2, sw, sh);
508
- }
509
-
510
- // Outline
511
- if (sel) { ctx.strokeStyle = '#1850d0'; ctx.lineWidth = 2.5; ctx.setLineDash([]); }
512
- else if (node.hasVisual || (cat !== 'empty' && cat !== 'unknown')) { ctx.strokeStyle = catColor + '70'; ctx.lineWidth = 1.5; ctx.setLineDash([]); }
513
- else { ctx.strokeStyle = '#c8c8d0'; ctx.lineWidth = 1; ctx.setLineDash([3,3]); }
514
- ctx.strokeRect(px-sw/2, py-sh/2, sw, sh);
515
- ctx.setLineDash([]);
1199
+ // --- MCP enrichment ---
1200
+ let mcpComponentMap = null;
1201
+ let hasFetchedMCP = false;
1202
+ let parsedMcpConfig = null;
516
1203
 
517
- if (sw > 12 || sel) nodeRects.push({ node, px, py, sw, sh, sel, cat, catColor });
1204
+ function parseMcpConfig(raw) {
1205
+ try {
1206
+ const obj = JSON.parse(raw);
1207
+ const servers = obj.servers || obj;
1208
+ const key = Object.keys(servers)[0];
1209
+ if (!key) return null;
1210
+ const srv = servers[key];
1211
+ if (!srv.url) return null;
1212
+ return { url: srv.url, headers: srv.headers || {}, name: key };
1213
+ } catch (e) {
1214
+ return null;
518
1215
  }
1216
+ }
519
1217
 
520
- // Labels: collision-aware placement
521
- const placedLabels = []; // [{x,y,w,h}]
522
- function labelsOverlap(lx, ly, lw, lh) {
523
- for (const p of placedLabels) {
524
- if (lx < p.x + p.w && lx + lw > p.x && ly < p.y + p.h && ly + lh > p.y) return true;
525
- }
526
- return false;
527
- }
528
- // Selected nodes get priority
529
- nodeRects.sort((a, b) => (b.sel ? 1 : 0) - (a.sel ? 1 : 0));
530
- for (const { node, px, py, sw, sh, sel, cat, catColor } of nodeRects) {
531
- ctx.font = sel ? '600 8px "IBM Plex Mono", monospace' : '8px "IBM Plex Mono", monospace';
532
- const tw = ctx.measureText(node.name).width;
533
- const th = 10;
534
- // Try positions: above-left, above-right, below-left, below-right
535
- const candidates = [
536
- { x: px - sw/2, y: py - sh/2 - th - 2 },
537
- { x: px + sw/2 - tw, y: py - sh/2 - th - 2 },
538
- { x: px - sw/2, y: py + sh/2 + 2 },
539
- { x: px + sw/2 - tw, y: py + sh/2 + 2 },
540
- ];
541
- let placed = false;
542
- for (const c of candidates) {
543
- if (!labelsOverlap(c.x, c.y, tw, th)) {
544
- ctx.fillStyle = sel ? catColor : '#80808c';
545
- ctx.textAlign = 'left'; ctx.textBaseline = 'top';
546
- ctx.fillText(node.name, c.x, c.y);
547
- placedLabels.push({ x: c.x, y: c.y, w: tw, h: th });
548
- placed = true;
549
- break;
550
- }
551
- }
552
- // If all positions overlap, selected nodes always draw (force top-left)
553
- if (!placed && sel) {
554
- const c = candidates[0];
555
- ctx.fillStyle = catColor;
556
- ctx.textAlign = 'left'; ctx.textBaseline = 'top';
557
- ctx.fillText(node.name, c.x, c.y);
558
- placedLabels.push({ x: c.x, y: c.y, w: tw, h: th });
559
- }
1218
+ function applyMcpConfig() {
1219
+ const textarea = document.getElementById('mcp-config');
1220
+ const status = document.getElementById('mcp-config-status');
1221
+ const raw = textarea.value.trim();
1222
+ if (!raw) { parsedMcpConfig = null; status.style.color = 'var(--text-muted)'; status.textContent = 'Copy from Lens Studio: AI Assistant > MCP config.'; return; }
1223
+ const parsed = parseMcpConfig(raw);
1224
+ if (parsed) {
1225
+ parsedMcpConfig = parsed;
1226
+ localStorage.setItem('lens-inspector-mcp-config', raw);
1227
+ status.style.color = '#0a8020';
1228
+ status.textContent = 'MCP configured: ' + parsed.url;
1229
+ if (liveTree && hasFetchedMCP) { hasFetchedMCP = false; fetchMCPComponents(); }
1230
+ } else {
1231
+ parsedMcpConfig = null;
1232
+ status.style.color = '#c03020';
1233
+ status.textContent = 'Invalid config. Paste the full JSON from Lens Studio.';
560
1234
  }
561
-
562
- document.getElementById('view-info').textContent = `${v.scale.toFixed(1)}x`;
563
1235
  }
564
1236
 
565
- function renderAllViews() { renderScene(); }
1237
+ async function fetchMCPComponents() {
1238
+ if (hasFetchedMCP) return;
1239
+ hasFetchedMCP = true;
1240
+
1241
+ let fetchUrl, fetchHeaders;
1242
+ if (parsedMcpConfig) {
1243
+ const base = document.getElementById('mcp-base').value;
1244
+ const params = new URLSearchParams();
1245
+ params.set('url', parsedMcpConfig.url);
1246
+ const authHeader = parsedMcpConfig.headers['Authorization'] || parsedMcpConfig.headers['authorization'] || '';
1247
+ const token = authHeader.replace(/^Bearer\s+/i, '');
1248
+ if (token) params.set('token', token);
1249
+ fetchUrl = base + '/mcp?' + params.toString();
1250
+ fetchHeaders = { 'Content-Type': 'application/json' };
1251
+ } else {
1252
+ const base = document.getElementById('mcp-base').value;
1253
+ fetchUrl = base + '/mcp';
1254
+ fetchHeaders = { 'Content-Type': 'application/json' };
1255
+ }
566
1256
 
567
- // --- Pan/Zoom ---
568
- function setupViewport() {
569
- canvas = document.getElementById('scene-canvas');
570
- ctx = canvas.getContext('2d');
571
- let dragStart = {x:0,y:0};
1257
+ try {
1258
+ const resp = await fetch(fetchUrl, {
1259
+ method: 'POST',
1260
+ headers: fetchHeaders,
1261
+ body: JSON.stringify({
1262
+ jsonrpc: '2.0', id: 1, method: 'tools/call',
1263
+ params: { name: 'GetLensStudioSceneGraph', arguments: {} }
1264
+ })
1265
+ });
1266
+ const data = await resp.json();
1267
+ const sceneText = data.result?.content?.[0]?.text;
1268
+ if (!sceneText) { console.log('[inspector] MCP returned no scene data'); return; }
1269
+ const scene = JSON.parse(sceneText);
1270
+ const roots = scene.sceneTree?.children || scene.children || [];
572
1271
 
573
- canvas.addEventListener('mousedown', e => { dragging = true; const v = viewDefs[activeView]; dragStart = {x: e.clientX - v.offset.x, y: e.clientY - v.offset.y}; });
574
- window.addEventListener('mousemove', e => { if (dragging) { const v = viewDefs[activeView]; v.offset.x = e.clientX - dragStart.x; v.offset.y = e.clientY - dragStart.y; renderScene(); }});
575
- window.addEventListener('mouseup', () => { dragging = false; });
576
- canvas.addEventListener('wheel', e => { e.preventDefault(); const v = viewDefs[activeView]; v.scale = Math.max(1, Math.min(100, v.scale * (1 - e.deltaY*0.002))); renderScene(); }, {passive:false});
1272
+ // Extract project name from scene data
1273
+ const projName = scene.projectName || scene.name || (parsedMcpConfig ? parsedMcpConfig.name : '');
1274
+ if (projName) {
1275
+ document.getElementById('project-name').textContent = projName;
1276
+ }
577
1277
 
578
- // View toggle
579
- document.getElementById('view-bar').addEventListener('click', e => {
580
- const btn = e.target.closest('.view-btn');
581
- if (!btn) return;
582
- activeView = btn.dataset.view;
583
- document.querySelectorAll('.view-btn').forEach(b => b.classList.toggle('active', b === btn));
584
- renderScene();
585
- });
1278
+ mcpComponentMap = {};
1279
+ function walkMcp(obj, path) {
1280
+ const name = obj.name || '';
1281
+ const key = path + '/' + name;
1282
+ const components = (obj.components || []).map(c => ({
1283
+ type: c.name || c.type || 'Unknown',
1284
+ enabled: c.enabled !== false,
1285
+ category: null,
1286
+ scriptName: c.properties?.scriptAsset?.name || null,
1287
+ meshName: c.properties?.mesh?.name || null,
1288
+ materialName: c.properties?.mainMaterial?.name || null,
1289
+ text: c.properties?.text || null,
1290
+ }));
1291
+ if (components.length > 0) {
1292
+ mcpComponentMap[name] = { components, path: key };
1293
+ }
1294
+ for (const child of (obj.children || [])) walkMcp(child, key);
1295
+ }
1296
+ for (const r of roots) walkMcp(r, '');
1297
+ console.log('[inspector] MCP enrichment: got components for', Object.keys(mcpComponentMap).length, 'objects');
1298
+ if (liveTree) { rebuildFlat(); renderTree(); buildScene3D(); if (selectedId) renderProps(); }
1299
+ } catch (e) {
1300
+ console.log('[inspector] MCP enrichment failed:', e.message);
1301
+ }
586
1302
  }
587
1303
 
588
1304
  // --- WebSocket ---
1305
+ function updateSourceUI() {
1306
+ const dot = document.getElementById('ws-dot');
1307
+ const text = document.getElementById('status-text');
1308
+ const badge = document.getElementById('source-badge');
1309
+ if (dataSource === 'example') {
1310
+ dot.className = 'status-dot dot-example';
1311
+ text.textContent = 'connected';
1312
+ badge.className = 'badge-example';
1313
+ badge.textContent = 'Example scene';
1314
+ } else if (dataSource === 'live') {
1315
+ dot.className = 'status-dot dot-live';
1316
+ text.textContent = 'live';
1317
+ badge.className = 'badge-live';
1318
+ badge.textContent = 'Lens Studio';
1319
+ } else {
1320
+ dot.className = 'status-dot dot-off';
1321
+ text.textContent = 'connecting...';
1322
+ badge.className = 'badge-off';
1323
+ }
1324
+ }
1325
+
589
1326
  function connectWS() {
590
1327
  const url = document.getElementById('ws-url').value.trim();
591
1328
  if (ws) { ws.close(); ws = null; }
592
1329
  ws = new WebSocket(url);
593
- ws.onopen = () => { document.getElementById('ws-dot').className = 'status-dot dot-live'; document.getElementById('status-text').textContent = 'live'; };
1330
+ ws.onopen = () => updateSourceUI();
594
1331
  ws.onmessage = (e) => {
595
1332
  try {
596
1333
  const msg = JSON.parse(e.data);
1334
+ if (msg.event === 'reload') { location.reload(); return; }
597
1335
  if (msg.event === 'scene_snapshot' && msg.roots) {
1336
+ const wasEmpty = !liveTree;
1337
+ const oldCount = sceneNodes.length;
1338
+ const newSource = msg.source === 'example' ? 'example' : 'live';
1339
+
1340
+ if (dataSource === 'example' && newSource === 'live') {
1341
+ selectedId = null;
1342
+ }
1343
+ dataSource = newSource;
598
1344
  liveTree = msg.roots;
599
- rebuildFlat(); renderTree(); renderAllViews();
1345
+ rebuildFlat(); renderTree(); buildScene3D();
1346
+ if (wasEmpty || Math.abs(sceneNodes.length - oldCount) > oldCount * 0.5) {
1347
+ hasAutoFit = false;
1348
+ autoFitCamera();
1349
+ hasAutoFit = true;
1350
+ lastGradSpacing = 0;
1351
+ }
600
1352
  if (selectedId) renderProps();
601
- hideWelcome();
1353
+ updateSourceUI();
1354
+ if (wasEmpty) hideWelcome();
1355
+ if (newSource === 'live' && !hasFetchedMCP) fetchMCPComponents();
602
1356
  }
603
1357
  } catch (err) {}
604
1358
  };
605
1359
  ws.onclose = () => {
606
- document.getElementById('ws-dot').className = 'status-dot dot-off';
607
- document.getElementById('status-text').textContent = 'reconnecting...';
1360
+ dataSource = null;
1361
+ updateSourceUI();
608
1362
  ws = null; setTimeout(connectWS, 2000);
609
1363
  };
610
1364
  }
611
- document.getElementById('ws-btn').addEventListener('click', connectWS);
612
-
613
- // --- MCP ---
614
- async function fetchMCP() {
615
- const token = document.getElementById('mcp-token').value.trim();
616
- const dot = document.getElementById('mcp-dot');
617
- dot.className = 'status-dot dot-off';
618
- try {
619
- const proxyUrl = `http://${location.host}/mcp` + (token ? `?token=${encodeURIComponent(token)}` : '');
620
- const resp = await fetch(proxyUrl, { method:'POST', headers:{'Content-Type':'application/json'},
621
- body: JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name:'GetLensStudioSceneGraph',arguments:{}}})});
622
- const data = await resp.json();
623
- const sceneText = data.result?.content?.[0]?.text;
624
- if (!sceneText) throw new Error('No scene data');
625
- const scene = JSON.parse(sceneText);
626
- const roots = scene.sceneTree?.children || scene.children || [];
627
- function convertMcp(obj) {
628
- const pos = obj.localTransform?.position||{x:0,y:0,z:0};
629
- const scl = obj.localTransform?.scale||{x:1,y:1,z:1};
630
- const rot = obj.localTransform?.rotation||{x:0,y:0,z:0,w:1};
631
- const components = (obj.components||[]).map(c => ({type:c.name||c.type||'Unknown',enabled:c.enabled!==false,text:c.properties?.text||null,category:null}));
632
- return { name:obj.name, enabled:obj.enabled!==false, category:'empty',
633
- pos:[pos.x,pos.y,pos.z], scale:[scl.x,scl.y,scl.z], rot:[rot.x,rot.y,rot.z,rot.w],
634
- components, text:components.find(c=>c.text)?.text||null,
635
- hasVisual:components.some(c=>c.type==='RenderMeshVisual'||c.type==='Image'), color:null,
636
- children:(obj.children||[]).map(convertMcp) };
637
- }
638
- mcpTree = roots.map(convertMcp);
639
- if (!liveTree) { rebuildFlat(); renderTree(); renderAllViews(); }
640
- dot.className = 'status-dot dot-mcp'; hideWelcome();
641
- } catch (e) { dot.className = 'status-dot dot-off'; alert('MCP error: '+e.message); }
642
- }
643
- document.getElementById('mcp-btn').addEventListener('click', fetchMCP);
644
1365
 
645
1366
  // --- Search ---
646
1367
  document.getElementById('tree-search').addEventListener('input', (e) => {
647
1368
  searchFilter = e.target.value.trim(); rebuildFlat(); renderTree();
648
1369
  });
649
1370
 
1371
+ // --- Category filter chips ---
1372
+ document.getElementById('tree-filters').addEventListener('click', (e) => {
1373
+ const chip = e.target.closest('.filter-chip');
1374
+ if (!chip) return;
1375
+ activeCategoryFilter = chip.dataset.cat;
1376
+ document.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c.dataset.cat === activeCategoryFilter));
1377
+ rebuildFlat(); renderTree();
1378
+ });
1379
+
650
1380
  // --- Utils ---
651
1381
  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
652
- function hideWelcome() { document.getElementById('welcome').classList.add('hidden'); }
1382
+ function hideWelcome() {
1383
+ const w = document.getElementById('welcome');
1384
+ if (dataSource === 'example') {
1385
+ document.getElementById('welcome-status').style.display = 'none';
1386
+ document.getElementById('welcome-example').style.display = 'flex';
1387
+ }
1388
+ w.classList.add('hidden');
1389
+ }
1390
+ function showWelcome() {
1391
+ const w = document.getElementById('welcome');
1392
+ if (dataSource === 'example') {
1393
+ document.getElementById('welcome-status').style.display = 'none';
1394
+ document.getElementById('welcome-example').style.display = 'flex';
1395
+ } else if (dataSource === 'live') {
1396
+ document.getElementById('welcome-status').style.display = 'none';
1397
+ document.getElementById('welcome-example').style.display = 'none';
1398
+ } else {
1399
+ document.getElementById('welcome-status').style.display = 'flex';
1400
+ document.getElementById('welcome-example').style.display = 'none';
1401
+ }
1402
+ w.classList.remove('hidden');
1403
+ }
1404
+ document.getElementById('setup-btn').addEventListener('click', showWelcome);
1405
+ document.getElementById('server-connect-btn').addEventListener('click', applyServerUrl);
1406
+ document.getElementById('welcome-url').addEventListener('keydown', (e) => { if (e.key === 'Enter') applyServerUrl(); });
1407
+ document.getElementById('mcp-config').addEventListener('input', applyMcpConfig);
1408
+ document.getElementById('welcome').addEventListener('click', (e) => {
1409
+ if (e.target === document.getElementById('welcome') && dataSource) hideWelcome();
1410
+ });
1411
+
1412
+ // --- Resizable panes ---
1413
+ function initResize() {
1414
+ const treePanel = document.getElementById('tree-panel');
1415
+ const propsPanel = document.getElementById('props-panel');
1416
+ const handleL = document.getElementById('resize-left');
1417
+ const handleR = document.getElementById('resize-right');
1418
+
1419
+ function makeDraggable(handle, getTarget, isLeft) {
1420
+ let startX, startW;
1421
+ function onDown(e) {
1422
+ e.preventDefault();
1423
+ const target = getTarget();
1424
+ startX = e.clientX;
1425
+ startW = target.offsetWidth;
1426
+ handle.classList.add('active');
1427
+ document.body.classList.add('resizing');
1428
+ document.addEventListener('mousemove', onMove);
1429
+ document.addEventListener('mouseup', onUp);
1430
+ }
1431
+ function onMove(e) {
1432
+ const dx = e.clientX - startX;
1433
+ const target = getTarget();
1434
+ const newW = isLeft ? startW + dx : startW - dx;
1435
+ target.style.width = Math.max(150, newW) + 'px';
1436
+ onResize();
1437
+ }
1438
+ function onUp() {
1439
+ handle.classList.remove('active');
1440
+ document.body.classList.remove('resizing');
1441
+ document.removeEventListener('mousemove', onMove);
1442
+ document.removeEventListener('mouseup', onUp);
1443
+ }
1444
+ handle.addEventListener('mousedown', onDown);
1445
+ }
1446
+
1447
+ makeDraggable(handleL, () => treePanel, true);
1448
+ makeDraggable(handleR, () => propsPanel, false);
1449
+ }
1450
+
1451
+ // --- WASD fly controls (uses e.code for keyboard-layout independence) ---
1452
+ const keysDown = new Set();
1453
+ const flySpeed = { base: 0.5, shift: 2.5 };
1454
+ const FLY_CODES = new Set(['KeyW','KeyA','KeyS','KeyD','KeyQ','KeyE','ShiftLeft','ShiftRight']);
1455
+
1456
+ function initFlyControls() {
1457
+ const canvas = document.getElementById('scene-canvas');
1458
+ canvas.tabIndex = 0;
1459
+ canvas.style.outline = 'none';
1460
+ canvas.addEventListener('mousedown', () => canvas.focus());
1461
+
1462
+ canvas.addEventListener('keydown', (e) => {
1463
+ if (FLY_CODES.has(e.code)) {
1464
+ keysDown.add(e.code);
1465
+ e.preventDefault();
1466
+ }
1467
+ });
1468
+ canvas.addEventListener('keyup', (e) => {
1469
+ keysDown.delete(e.code);
1470
+ });
1471
+ canvas.addEventListener('blur', () => keysDown.clear());
1472
+ }
1473
+
1474
+ function updateFlyMovement() {
1475
+ if (keysDown.size === 0) return;
1476
+ const speed = (keysDown.has('ShiftLeft') || keysDown.has('ShiftRight')) ? flySpeed.shift : flySpeed.base;
1477
+ const dist = camera3d.position.distanceTo(controls3d.target);
1478
+ const step = speed * dist * 0.01;
1479
+
1480
+ const forward = new THREE.Vector3();
1481
+ camera3d.getWorldDirection(forward);
1482
+ const right = new THREE.Vector3().crossVectors(forward, camera3d.up).normalize();
1483
+ const up = new THREE.Vector3(0, 1, 0);
1484
+
1485
+ const move = new THREE.Vector3();
1486
+ if (keysDown.has('KeyW')) move.add(forward.clone().multiplyScalar(step));
1487
+ if (keysDown.has('KeyS')) move.add(forward.clone().multiplyScalar(-step));
1488
+ if (keysDown.has('KeyD')) move.add(right.clone().multiplyScalar(step));
1489
+ if (keysDown.has('KeyA')) move.add(right.clone().multiplyScalar(-step));
1490
+ if (keysDown.has('KeyE')) move.add(up.clone().multiplyScalar(step));
1491
+ if (keysDown.has('KeyQ')) move.add(up.clone().multiplyScalar(-step));
1492
+
1493
+ camera3d.position.add(move);
1494
+ controls3d.target.add(move);
1495
+ }
653
1496
 
654
1497
  // --- Init ---
655
- window.addEventListener('resize', renderScene);
656
- setupViewport();
657
- renderScene();
1498
+ initScene3D();
1499
+ initResize();
1500
+ initFlyControls();
658
1501
  connectWS();
659
1502
  </script>
660
1503
  </body>