lens-inspector 0.1.1 → 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/README.md CHANGED
@@ -42,6 +42,35 @@ If you want to see it working right away, open the included `LSProject/` folder
42
42
 
43
43
  ![Demo](media/demo.gif)
44
44
 
45
+ ## Features
46
+
47
+ ### 3D Viewport with WASD Navigation
48
+
49
+ The viewer renders every SceneObject as a labeled wireframe box in a 3D viewport. Orbit with left-click drag, pan with middle-click or right-click, zoom with scroll wheel. Fly through the scene with WASD (forward/left/back/right), Q/E (down/up), and hold Shift for speed. Navigation keys use physical key positions so they work correctly on AZERTY, QWERTZ, and other keyboard layouts.
50
+
51
+ ### Hierarchy Panel
52
+
53
+ The left panel shows a collapsible tree matching your scene structure. Each node displays a color-coded icon by component category (camera, visual, script, light, physics, audio, VFX, etc.) and a component count badge. Runtime-spawned objects appear in blue.
54
+
55
+ - **Search**: type to filter nodes by name, text content, component type, or category
56
+ - **Category chips**: click Camera, Visual, Script, Light, Physics, Audio, Anim, UI, Track, or Empty to show only that type
57
+ - **Expand/Collapse**: click the arrow to fold tree branches without affecting 3D visibility
58
+ - **Eye toggle**: hover a node and click the eye icon to hide or show it in the 3D viewport independently of the tree
59
+
60
+ ### Inspector Panel
61
+
62
+ Click any node in the tree or 3D viewport to see its transform (position, scale, rotation), object properties (enabled, children count, layer, category), color, text content, and a full component list with type-specific details like mesh name, material, script asset, camera FOV, audio track, and physics mass.
63
+
64
+ ### MCP Component Enrichment
65
+
66
+ The runtime scene graph walker detects components by probing 40+ known Lens Studio types individually (Camera, ScriptComponent, RenderMeshVisual, LightSource, etc.). When the generic `getComponentCount("Component")` returns 0 for certain LS versions or object types, the fallback catches what would otherwise show as empty nodes.
67
+
68
+ For additional component detail, paste your Lens Studio MCP config JSON into the welcome screen. The viewer proxies MCP calls through the inspector server to avoid CORS, fetching the design-time scene graph and merging component data into live nodes. Copy the config from **AI Assistant > MCP** in Lens Studio.
69
+
70
+ ### Configurable Server
71
+
72
+ Change the server address in the welcome screen if you run the inspector on a different port or machine. The setting persists in localStorage.
73
+
45
74
  ## Two modes
46
75
 
47
76
  **Runtime**: A Script component inside your lens walks the full scene graph using the SceneObject API, serializes the hierarchy into JSON, and sends it over WebSocket to the browser. This captures everything: both the objects you placed in the Scene Hierarchy and anything created by Scripts at runtime.
@@ -50,9 +79,23 @@ If you want to see it working right away, open the included `LSProject/` folder
50
79
 
51
80
  **Design-time**: When your lens isn't running, the viewer can fetch the static scene structure through Lens Studio's MCP server. This is useful for inspecting your Scene Hierarchy without entering preview mode.
52
81
 
82
+ To use design-time mode:
83
+
84
+ 1. In Lens Studio, go to **AI Assistant > MCP > Configure Server** and set a port
85
+ 2. Click **Copy MCP Config** to get the JSON with server URL and auth token
86
+ 3. In the inspector welcome screen, paste the full MCP config JSON into the MCP Config field
87
+
88
+ If your MCP server runs on a non-default port, you can also pass a token on the command line:
89
+
90
+ ```bash
91
+ npx lens-inspector --mcp http://localhost:8733/mcp --mcp-token YOUR_TOKEN
92
+ ```
93
+
94
+ The MCP config from Lens Studio also works with AI coding assistants like Claude. Paste it into your assistant's MCP server config to let it read and modify your scene programmatically.
95
+
53
96
  ## The viewer
54
97
 
55
- The browser shows three synchronized panels. A collapsible tree that matches the Scene Hierarchy, where you can search and click to select nodes. A 2D spatial canvas with switchable projection planes (TOP/FRONT/SIDE) that plots object positions with pan and zoom, so you can see how things are laid out relative to each other. And an inspector panel that shows transform data, component details, text content, and colors for whatever node you've selected.
98
+ The browser shows three synchronized panels. A hierarchy tree on the left with search, category filters, and independent visibility toggles. A 3D viewport in the center with an infinite multi-level grid, axis graduations in centimeters/meters, a navigation gizmo, and WASD fly controls. And an inspector panel on the right showing transform data, component details, text content, and colors for the selected node. All three panels are resizable by dragging the borders between them.
56
99
 
57
100
  ![Front view showing spatial layout](media/03-front.png)
58
101
 
@@ -82,17 +125,19 @@ The SceneInspector component exposes three inputs in Lens Studio's Inspector pan
82
125
  The server accepts these flags:
83
126
 
84
127
  ```bash
85
- node server.js --port 9000 # custom port
86
- node server.js --no-open # don't auto-open browser
128
+ node server.js --port 9000 # custom port
129
+ node server.js --no-open # don't auto-open browser
130
+ node server.js --mcp http://localhost:8733/mcp # custom MCP URL
131
+ node server.js --mcp-token YOUR_TOKEN # MCP auth token
87
132
  ```
88
133
 
89
134
  ## Architecture
90
135
 
91
- The entire tool is three files with about 500 lines of code total and no build step or external framework:
136
+ The entire tool is three files with no build step or external framework:
92
137
 
93
- - **SceneInspector.ts** (~180 lines): LS component that walks and serializes the scene graph
94
- - **server.js** (~120 lines): WebSocket relay between LS and browser, with an MCP proxy for design-time queries
95
- - **index.html** (single file): the entire browser viewer with zero frontend dependencies
138
+ - **SceneInspector.ts** (~370 lines): LS component that walks and serializes the scene graph, with fallback type-specific component probing
139
+ - **server.js** (~200 lines): WebSocket relay between LS and browser, with an MCP proxy for design-time queries and live reload
140
+ - **index.html** (single file): the entire browser viewer with Three.js 3D viewport, hierarchy, and inspector
96
141
 
97
142
  The code is meant to be read and modified. If you want to add custom component introspection or change the viewer layout, everything is right there in plain files.
98
143
 
package/SceneInspector.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  @component
20
20
  export class SceneInspector extends BaseScriptComponent {
21
21
  @input
22
- @hint("Inspector server WebSocket URL")
22
+ @hint("Inspector server URL. Change port if you ran: npx lens-inspector --port XXXX")
23
23
  public wsUrl: string = "ws://localhost:8200?role=ls";
24
24
 
25
25
  @input
@@ -145,13 +145,134 @@ export class SceneInspector extends BaseScriptComponent {
145
145
  return "unknown";
146
146
  }
147
147
 
148
+ private processComponent(comp: any, typeName: string, components: any[]) {
149
+ var category = this.classifyComponent(typeName);
150
+ var compInfo: any = {
151
+ type: typeName,
152
+ enabled: comp.enabled !== false,
153
+ category: category,
154
+ };
155
+
156
+ // Extract text content
157
+ if (typeName === "Text" || typeName === "Component.Text") {
158
+ compInfo.text = comp.text || null;
159
+ if (comp.size !== undefined) compInfo.textSize = comp.size;
160
+ }
161
+ if (typeName === "Text3D" || typeName === "Component.Text3D") {
162
+ compInfo.text = comp.text || null;
163
+ }
164
+
165
+ // Extract visual info
166
+ if (category === "visual") {
167
+ try {
168
+ var mat = comp.mainMaterial;
169
+ if (mat && mat.mainPass) {
170
+ var bc = mat.mainPass["baseColor"];
171
+ if (bc) compInfo._color = [
172
+ Math.round(bc.x * 255),
173
+ Math.round(bc.y * 255),
174
+ Math.round(bc.z * 255),
175
+ Math.round(bc.w * 255),
176
+ ];
177
+ compInfo.materialName = mat.name || null;
178
+ }
179
+ } catch (e) {}
180
+ try {
181
+ if (comp.mesh) compInfo.meshName = comp.mesh.name || null;
182
+ } catch (e) {}
183
+ }
184
+
185
+ // Extract script info
186
+ if (category === "script") {
187
+ try {
188
+ if (comp.scriptAsset) compInfo.scriptName = comp.scriptAsset.name || null;
189
+ } catch (e) {}
190
+ }
191
+
192
+ // Extract camera info
193
+ if (category === "camera") {
194
+ try {
195
+ compInfo.cameraType = comp.cameraType === 1 ? "orthographic" : "perspective";
196
+ if (comp.fov !== undefined) compInfo.fov = comp.fov;
197
+ if (comp.size !== undefined) compInfo.orthoSize = comp.size;
198
+ if (comp.near !== undefined) compInfo.near = comp.near;
199
+ if (comp.far !== undefined) compInfo.far = comp.far;
200
+ compInfo.renderOrder = comp.renderOrder || 0;
201
+ } catch (e) {}
202
+ }
203
+
204
+ // Extract physics info
205
+ if (category === "physics") {
206
+ try {
207
+ if (comp.dynamic !== undefined) compInfo.dynamic = comp.dynamic;
208
+ if (comp.mass !== undefined) compInfo.mass = comp.mass;
209
+ } catch (e) {}
210
+ }
211
+
212
+ // Extract audio info
213
+ if (category === "audio") {
214
+ try {
215
+ if (comp.volume !== undefined) compInfo.volume = comp.volume;
216
+ if (comp.audioTrack) compInfo.trackName = comp.audioTrack.name || null;
217
+ } catch (e) {}
218
+ }
219
+
220
+ components.push(compInfo);
221
+ }
222
+
223
+ // Known LS component types to probe individually when generic lookup fails
224
+ private static KNOWN_TYPES: string[] = [
225
+ "Camera",
226
+ "ScriptComponent",
227
+ "RenderMeshVisual",
228
+ "Image",
229
+ "Text",
230
+ "Text3D",
231
+ "LightSource",
232
+ "ScreenTransform",
233
+ "ScreenRegionComponent",
234
+ "Canvas",
235
+ "AnimationMixer",
236
+ "AnimationPlayer",
237
+ "AudioComponent",
238
+ "AudioListenerComponent",
239
+ "VFXComponent",
240
+ "BodyComponent",
241
+ "ColliderComponent",
242
+ "WorldComponent",
243
+ "InteractionComponent",
244
+ "ManipulateComponent",
245
+ "DeviceTracking",
246
+ "ObjectTracking3D",
247
+ "MarkerTrackingComponent",
248
+ "Head",
249
+ "BlendShapes",
250
+ "SpriteVisual",
251
+ "PostEffectVisual",
252
+ "FaceMaskVisual",
253
+ "FaceInsetVisual",
254
+ "RectangleSetter",
255
+ "PinToMeshComponent",
256
+ "LookAtComponent",
257
+ "ClothVisual",
258
+ "HintsComponent",
259
+ "SkinComponent",
260
+ "MLComponent",
261
+ "RetouchVisual",
262
+ "EyeColorVisual",
263
+ "HairVisual",
264
+ "LiquifyVisual",
265
+ "ClearScreenComponent",
266
+ "ParticlesVisual",
267
+ ];
268
+
148
269
  private walkObject(obj: SceneObject, depth: number): any {
149
270
  if (depth > this.maxDepth) return null;
150
271
 
151
272
  var t = obj.getTransform();
152
- var pos = t.getLocalPosition();
153
- var scl = t.getLocalScale();
154
- var rot = t.getLocalRotation();
273
+ var pos = t.getWorldPosition();
274
+ var scl = t.getWorldScale();
275
+ var rot = t.getWorldRotation();
155
276
 
156
277
  // Gather components
157
278
  var components: any[] = [];
@@ -160,96 +281,54 @@ export class SceneInspector extends BaseScriptComponent {
160
281
  var color: number[] | null = null;
161
282
  var primaryCategory = "empty";
162
283
 
284
+ // Try generic component lookup first
163
285
  var compCount = obj.getComponentCount("Component");
164
- for (var ci = 0; ci < compCount; ci++) {
165
- try {
166
- var comp = obj.getComponentByIndex("Component", ci) as any;
167
- var typeName = comp.getTypeName ? comp.getTypeName() : "Unknown";
168
- var category = this.classifyComponent(typeName);
169
- var compInfo: any = {
170
- type: typeName,
171
- enabled: comp.enabled !== false,
172
- category: category,
173
- };
174
-
175
- // Extract text content
176
- if (typeName === "Text" || typeName === "Component.Text") {
177
- text = comp.text || null;
178
- compInfo.text = text;
179
- if (comp.size !== undefined) compInfo.textSize = comp.size;
180
- }
181
- if (typeName === "Text3D" || typeName === "Component.Text3D") {
182
- text = comp.text || null;
183
- compInfo.text = text;
184
- }
185
-
186
- // Extract visual info
187
- if (category === "visual") {
188
- hasVisual = true;
189
- try {
190
- var mat = comp.mainMaterial;
191
- if (mat && mat.mainPass) {
192
- var bc = mat.mainPass["baseColor"];
193
- if (bc) color = [
194
- Math.round(bc.x * 255),
195
- Math.round(bc.y * 255),
196
- Math.round(bc.z * 255),
197
- Math.round(bc.w * 255),
198
- ];
199
- compInfo.materialName = mat.name || null;
200
- }
201
- } catch (e) {}
202
- try {
203
- if (comp.mesh) compInfo.meshName = comp.mesh.name || null;
204
- } catch (e) {}
205
- }
206
-
207
- // Extract script info
208
- if (category === "script") {
209
- try {
210
- if (comp.scriptAsset) compInfo.scriptName = comp.scriptAsset.name || null;
211
- } catch (e) {}
212
- }
213
-
214
- // Extract camera info
215
- if (category === "camera") {
216
- try {
217
- compInfo.cameraType = comp.cameraType === 1 ? "orthographic" : "perspective";
218
- if (comp.fov !== undefined) compInfo.fov = comp.fov;
219
- if (comp.size !== undefined) compInfo.orthoSize = comp.size;
220
- if (comp.near !== undefined) compInfo.near = comp.near;
221
- if (comp.far !== undefined) compInfo.far = comp.far;
222
- compInfo.renderOrder = comp.renderOrder || 0;
223
- } catch (e) {}
224
- }
225
-
226
- // Extract physics info
227
- if (category === "physics") {
228
- try {
229
- if (comp.dynamic !== undefined) compInfo.dynamic = comp.dynamic;
230
- if (comp.mass !== undefined) compInfo.mass = comp.mass;
231
- } catch (e) {}
232
- }
286
+ if (compCount > 0) {
287
+ for (var ci = 0; ci < compCount; ci++) {
288
+ try {
289
+ var comp = obj.getComponentByIndex("Component", ci) as any;
290
+ var typeName = comp.getTypeName ? comp.getTypeName() : "Unknown";
291
+ this.processComponent(comp, typeName, components);
292
+ } catch (e) {}
293
+ }
294
+ } else {
295
+ // Fallback: probe each known type individually.
296
+ // getComponentCount("Component") returns 0 in some LS versions
297
+ // but type-specific lookups still work.
298
+ for (var ti = 0; ti < SceneInspector.KNOWN_TYPES.length; ti++) {
299
+ var probeName = SceneInspector.KNOWN_TYPES[ti];
300
+ try {
301
+ var probeCount = obj.getComponentCount(probeName);
302
+ for (var pi = 0; pi < probeCount; pi++) {
303
+ try {
304
+ var probeComp = obj.getComponentByIndex(probeName, pi) as any;
305
+ var probeTypeName = probeComp.getTypeName ? probeComp.getTypeName() : probeName;
306
+ this.processComponent(probeComp, probeTypeName, components);
307
+ } catch (e2) {}
308
+ }
309
+ } catch (e3) {}
310
+ }
311
+ }
233
312
 
234
- // Extract audio info
235
- if (category === "audio") {
236
- try {
237
- if (comp.volume !== undefined) compInfo.volume = comp.volume;
238
- if (comp.audioTrack) compInfo.trackName = comp.audioTrack.name || null;
239
- } catch (e) {}
240
- }
313
+ // Derive category and visual flags from collected components
314
+ for (var ci2 = 0; ci2 < components.length; ci2++) {
315
+ var compInfo = components[ci2];
316
+ var category = compInfo.category;
317
+ if (compInfo.text) text = compInfo.text;
318
+ if (category === "visual") hasVisual = true;
319
+ if (compInfo._color) color = compInfo._color;
241
320
 
242
- // Determine primary category (first "interesting" component wins)
243
- if (primaryCategory === "empty" || primaryCategory === "unknown") {
244
- if (category !== "unknown") primaryCategory = category;
245
- }
246
- // But camera/script/visual always take priority
247
- if (category === "camera" || category === "script" || category === "visual" || category === "text") {
248
- if (primaryCategory !== "camera") primaryCategory = category;
249
- }
321
+ if (primaryCategory === "empty" || primaryCategory === "unknown") {
322
+ if (category !== "unknown") primaryCategory = category;
323
+ }
324
+ if (category === "camera" || category === "script" || category === "visual" || category === "text") {
325
+ if (primaryCategory !== "camera") primaryCategory = category;
326
+ }
327
+ }
250
328
 
251
- components.push(compInfo);
252
- } catch (e) {}
329
+ // Clean internal fields
330
+ for (var ci3 = 0; ci3 < components.length; ci3++) {
331
+ delete components[ci3]._color;
253
332
  }
254
333
 
255
334
  // Get layer info
@@ -262,8 +341,8 @@ export class SceneInspector extends BaseScriptComponent {
262
341
  // Walk children
263
342
  var childNodes: any[] = [];
264
343
  var childCount = obj.getChildrenCount();
265
- for (var ci2 = 0; ci2 < childCount; ci2++) {
266
- var child = obj.getChild(ci2);
344
+ for (var ki = 0; ki < childCount; ki++) {
345
+ var child = obj.getChild(ki);
267
346
  var childNode = this.walkObject(child, depth + 1);
268
347
  if (childNode) childNodes.push(childNode);
269
348
  }
package/example.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import WebSocket from "ws";
15
15
 
16
- const URL = process.argv[2] || "ws://localhost:8200?role=ls";
16
+ const URL = process.argv[2] || "ws://localhost:8200?role=ls&source=example";
17
17
 
18
18
  let frame = 0;
19
19
  let spawned = [];
@@ -84,6 +84,7 @@ function buildScene() {
84
84
 
85
85
  const scene = {
86
86
  event: "scene_snapshot",
87
+ source: "example",
87
88
  ts: Date.now(),
88
89
  roots: [
89
90
  // Perspective Camera with scripts
@@ -326,7 +327,12 @@ function connect() {
326
327
  }, 1000 / 2);
327
328
  });
328
329
 
329
- ws.on("close", () => {
330
+ ws.on("close", (code, reason) => {
331
+ const msg = reason?.toString() || '';
332
+ if (code === 1000 && msg === 'replaced') {
333
+ console.log("Real Lens Studio connected. Example exiting.");
334
+ process.exit(0);
335
+ }
330
336
  console.log("Disconnected, reconnecting in 2s...");
331
337
  setTimeout(connect, 2000);
332
338
  });