lens-inspector 0.1.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/LICENSE +21 -0
- package/README.md +93 -0
- package/SceneInspector.ts +294 -0
- package/example.js +339 -0
- package/index.html +661 -0
- package/package.json +34 -0
- package/server.js +148 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Armand Sumo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# lens-inspector
|
|
2
|
+
|
|
3
|
+
A browser-based scene graph inspector for Lens Studio. It shows you the full hierarchy of your scene while your effect runs, including objects that scripts create at runtime, so you can see what's actually happening instead of reconstructing state from log statements.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
You need [Node.js](https://nodejs.org/) installed (any recent version works).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
git clone https://github.com/a-sumo/lens-inspector.git
|
|
13
|
+
cd lens-inspector
|
|
14
|
+
npm install
|
|
15
|
+
node server.js
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The server starts and opens the viewer in your browser automatically. You'll see a waiting screen with setup steps:
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
From there:
|
|
23
|
+
|
|
24
|
+
1. Copy `SceneInspector.ts` into your Lens Studio project's `Assets/` folder
|
|
25
|
+
2. Attach the SceneInspector component to any SceneObject in your scene
|
|
26
|
+
3. In Lens Studio, go to **Project Settings > General** and enable **Experimental APIs** (required for `ws://localhost` connections)
|
|
27
|
+
4. Hit Play in Lens Studio
|
|
28
|
+
|
|
29
|
+
Once Lens Studio connects, the welcome screen disappears and your scene tree appears with live updates at about 2Hz.
|
|
30
|
+
|
|
31
|
+
## Demo project
|
|
32
|
+
|
|
33
|
+
If you want to see it working right away, open the included `LSProject/` folder in Lens Studio. It has SceneInspector and a RuntimeSpawner script already set up. Hit Play, and blocks will start appearing in both Lens Studio and the browser viewer.
|
|
34
|
+
|
|
35
|
+
https://github.com/a-sumo/lens-inspector/raw/main/media/demo.mp4
|
|
36
|
+
|
|
37
|
+
## Two modes
|
|
38
|
+
|
|
39
|
+
**Runtime**: A component inside your effect 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 editor and anything created by scripts at runtime.
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
**Design-time**: When your effect isn't running, the viewer can fetch the static scene structure through Lens Studio's MCP server. This is useful for inspecting your hierarchy without entering preview mode.
|
|
44
|
+
|
|
45
|
+
## The viewer
|
|
46
|
+
|
|
47
|
+
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 a properties panel that shows transform data, component details, text content, and colors for whatever node you've selected.
|
|
48
|
+
|
|
49
|
+

|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
## Try it without Lens Studio
|
|
54
|
+
|
|
55
|
+
An example script simulates a live scene so you can try the viewer without Lens Studio installed:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
node server.js # terminal 1
|
|
59
|
+
node example.js # terminal 2
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The browser opens automatically. The example streams a scene with blocks, columns, and new objects spawning over time, so you can see how the tree, canvas, and properties panel respond to live changes.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
The SceneInspector component exposes three inputs in Lens Studio's Inspector panel:
|
|
67
|
+
|
|
68
|
+
| Input | Default | Description |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `wsUrl` | `ws://localhost:8200?role=ls` | Inspector server address |
|
|
71
|
+
| `updateInterval` | `15` | Frames between updates (~2Hz at 30fps) |
|
|
72
|
+
| `maxDepth` | `20` | Max tree traversal depth |
|
|
73
|
+
|
|
74
|
+
The server accepts these flags:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
node server.js --port 9000 # custom port
|
|
78
|
+
node server.js --no-open # don't auto-open browser
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
The entire tool is three files with about 500 lines of code total and no build step or external framework:
|
|
84
|
+
|
|
85
|
+
- **SceneInspector.ts** (~180 lines): LS component that walks and serializes the scene graph
|
|
86
|
+
- **server.js** (~120 lines): WebSocket relay between LS and browser, with an MCP proxy for design-time queries
|
|
87
|
+
- **index.html** (single file): the entire browser viewer with zero frontend dependencies
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneInspector - Lens Studio component that streams the live scene graph
|
|
3
|
+
* to a browser-based viewer over WebSocket.
|
|
4
|
+
*
|
|
5
|
+
* Drop this onto any SceneObject in your project. It walks the entire scene
|
|
6
|
+
* tree every N frames and sends a JSON snapshot to the inspector server.
|
|
7
|
+
* Captures both design-time and runtime-created objects, including full
|
|
8
|
+
* component introspection for all standard LS types.
|
|
9
|
+
*
|
|
10
|
+
* Setup:
|
|
11
|
+
* 1. Attach to any SceneObject (e.g. Camera)
|
|
12
|
+
* 2. Set wsUrl to your inspector server (default: ws://localhost:8200?role=ls)
|
|
13
|
+
* 3. Run `node server.js` in the inspector directory
|
|
14
|
+
* 4. Open http://localhost:8200 in your browser
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// @ts-nocheck - LS types not available outside Lens Studio
|
|
18
|
+
|
|
19
|
+
@component
|
|
20
|
+
export class SceneInspector extends BaseScriptComponent {
|
|
21
|
+
@input
|
|
22
|
+
@hint("Inspector server WebSocket URL")
|
|
23
|
+
public wsUrl: string = "ws://localhost:8200?role=ls";
|
|
24
|
+
|
|
25
|
+
@input
|
|
26
|
+
@hint("How often to send snapshots (frames between updates)")
|
|
27
|
+
public updateInterval: number = 15; // ~2Hz at 30fps
|
|
28
|
+
|
|
29
|
+
@input
|
|
30
|
+
@hint("Max tree depth to traverse (prevents runaway recursion)")
|
|
31
|
+
public maxDepth: number = 20;
|
|
32
|
+
|
|
33
|
+
private internetModule: any = null;
|
|
34
|
+
private ws: any = null;
|
|
35
|
+
private connected: boolean = false;
|
|
36
|
+
private frameCounter: number = 0;
|
|
37
|
+
|
|
38
|
+
onAwake() {
|
|
39
|
+
this.createEvent("OnStartEvent").bind(() => {
|
|
40
|
+
this.connect();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.createEvent("UpdateEvent").bind(() => {
|
|
44
|
+
if (!this.connected) return;
|
|
45
|
+
this.frameCounter++;
|
|
46
|
+
if (this.frameCounter >= this.updateInterval) {
|
|
47
|
+
this.frameCounter = 0;
|
|
48
|
+
this.sendSnapshot();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.createEvent("OnDestroyEvent").bind(() => {
|
|
53
|
+
if (this.ws) try { this.ws.close(); } catch (e) {}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private connect() {
|
|
58
|
+
try {
|
|
59
|
+
this.internetModule = require("LensStudio:InternetModule") as any;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
print("[SceneInspector] ERROR: Could not load InternetModule: " + e);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
print("[SceneInspector] Connecting to " + this.wsUrl);
|
|
66
|
+
var ws = this.internetModule.createWebSocket(this.wsUrl);
|
|
67
|
+
this.ws = ws;
|
|
68
|
+
var self = this;
|
|
69
|
+
|
|
70
|
+
ws.addEventListener("open", function () {
|
|
71
|
+
self.connected = true;
|
|
72
|
+
print("[SceneInspector] Connected");
|
|
73
|
+
self.sendSnapshot(); // send immediately on connect
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
ws.addEventListener("close", function () {
|
|
77
|
+
self.connected = false;
|
|
78
|
+
print("[SceneInspector] Disconnected, reconnecting in 3s...");
|
|
79
|
+
var delay = self.createEvent("DelayedCallbackEvent") as any;
|
|
80
|
+
delay.bind(function () { self.connect(); });
|
|
81
|
+
delay.reset(3.0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
ws.addEventListener("error", function (err: any) {
|
|
85
|
+
print("[SceneInspector] WS error: " + err);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private sendSnapshot() {
|
|
90
|
+
if (!this.ws || !this.connected) return;
|
|
91
|
+
|
|
92
|
+
var scene = global.scene;
|
|
93
|
+
var rootCount = scene.getRootObjectsCount();
|
|
94
|
+
var children: any[] = [];
|
|
95
|
+
|
|
96
|
+
for (var i = 0; i < rootCount; i++) {
|
|
97
|
+
var rootObj = scene.getRootObject(i);
|
|
98
|
+
var node = this.walkObject(rootObj, 0);
|
|
99
|
+
if (node) children.push(node);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var payload = JSON.stringify({
|
|
103
|
+
event: "scene_snapshot",
|
|
104
|
+
ts: Date.now(),
|
|
105
|
+
roots: children,
|
|
106
|
+
totalObjects: this.countNodes(children),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
this.ws.send(payload);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
print("[SceneInspector] Send error: " + e);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private classifyComponent(typeName: string): string {
|
|
117
|
+
// Map LS component types to categories for the viewer
|
|
118
|
+
if (typeName.includes("Camera")) return "camera";
|
|
119
|
+
if (typeName.includes("Light") || typeName === "LightSource") return "light";
|
|
120
|
+
if (typeName.includes("RenderMeshVisual") || typeName.includes("Image") ||
|
|
121
|
+
typeName.includes("FaceMask") || typeName.includes("FaceInset") ||
|
|
122
|
+
typeName.includes("Cloth") || typeName.includes("PostEffect") ||
|
|
123
|
+
typeName.includes("Liquify") || typeName.includes("Retouch") ||
|
|
124
|
+
typeName.includes("EyeColor") || typeName.includes("Hair") ||
|
|
125
|
+
typeName.includes("SpriteVisual") || typeName.includes("ClearScreen")) return "visual";
|
|
126
|
+
if (typeName.includes("Text3D") || typeName.includes("Text")) return "text";
|
|
127
|
+
if (typeName.includes("Script") || typeName.includes("SceneInspector") ||
|
|
128
|
+
typeName.includes("RuntimeSpawner")) return "script";
|
|
129
|
+
if (typeName.includes("Audio")) return "audio";
|
|
130
|
+
if (typeName.includes("Animation") || typeName.includes("AnimationMixer") ||
|
|
131
|
+
typeName.includes("AnimationPlayer") || typeName.includes("BlendShapes")) return "animation";
|
|
132
|
+
if (typeName.includes("Body") || typeName.includes("Collider") ||
|
|
133
|
+
typeName.includes("Constraint") || typeName.includes("WorldComponent") ||
|
|
134
|
+
typeName.includes("Physics")) return "physics";
|
|
135
|
+
if (typeName.includes("VFX") || typeName.includes("Particle")) return "vfx";
|
|
136
|
+
if (typeName.includes("Interaction") || typeName.includes("Manipulate")) return "interaction";
|
|
137
|
+
if (typeName.includes("Tracking") || typeName.includes("DeviceTracking") ||
|
|
138
|
+
typeName.includes("Head") || typeName.includes("Landmarker") ||
|
|
139
|
+
typeName.includes("MarkerTracking") || typeName.includes("ObjectTracking")) return "tracking";
|
|
140
|
+
if (typeName.includes("ScreenTransform") || typeName.includes("ScreenRegion") ||
|
|
141
|
+
typeName.includes("Canvas") || typeName.includes("RectangleSetter")) return "ui";
|
|
142
|
+
if (typeName.includes("ML") || typeName.includes("SnapML")) return "ml";
|
|
143
|
+
if (typeName.includes("LookAt") || typeName.includes("PinToMesh") ||
|
|
144
|
+
typeName.includes("Hints") || typeName.includes("Skin")) return "utility";
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private walkObject(obj: SceneObject, depth: number): any {
|
|
149
|
+
if (depth > this.maxDepth) return null;
|
|
150
|
+
|
|
151
|
+
var t = obj.getTransform();
|
|
152
|
+
var pos = t.getLocalPosition();
|
|
153
|
+
var scl = t.getLocalScale();
|
|
154
|
+
var rot = t.getLocalRotation();
|
|
155
|
+
|
|
156
|
+
// Gather components
|
|
157
|
+
var components: any[] = [];
|
|
158
|
+
var text: string | null = null;
|
|
159
|
+
var hasVisual = false;
|
|
160
|
+
var color: number[] | null = null;
|
|
161
|
+
var primaryCategory = "empty";
|
|
162
|
+
|
|
163
|
+
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
|
+
}
|
|
233
|
+
|
|
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
|
+
}
|
|
241
|
+
|
|
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
|
+
}
|
|
250
|
+
|
|
251
|
+
components.push(compInfo);
|
|
252
|
+
} catch (e) {}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Get layer info
|
|
256
|
+
var layerName = null;
|
|
257
|
+
try {
|
|
258
|
+
var layer = obj.layer;
|
|
259
|
+
if (layer) layerName = layer.toString();
|
|
260
|
+
} catch (e) {}
|
|
261
|
+
|
|
262
|
+
// Walk children
|
|
263
|
+
var childNodes: any[] = [];
|
|
264
|
+
var childCount = obj.getChildrenCount();
|
|
265
|
+
for (var ci2 = 0; ci2 < childCount; ci2++) {
|
|
266
|
+
var child = obj.getChild(ci2);
|
|
267
|
+
var childNode = this.walkObject(child, depth + 1);
|
|
268
|
+
if (childNode) childNodes.push(childNode);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
name: obj.name,
|
|
273
|
+
enabled: obj.enabled,
|
|
274
|
+
category: primaryCategory,
|
|
275
|
+
pos: [+pos.x.toFixed(3), +pos.y.toFixed(3), +pos.z.toFixed(3)],
|
|
276
|
+
scale: [+scl.x.toFixed(3), +scl.y.toFixed(3), +scl.z.toFixed(3)],
|
|
277
|
+
rot: [+rot.x.toFixed(3), +rot.y.toFixed(3), +rot.z.toFixed(3), +rot.w.toFixed(3)],
|
|
278
|
+
components,
|
|
279
|
+
text,
|
|
280
|
+
hasVisual,
|
|
281
|
+
color,
|
|
282
|
+
layer: layerName,
|
|
283
|
+
children: childNodes,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private countNodes(nodes: any[]): number {
|
|
288
|
+
var count = 0;
|
|
289
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
290
|
+
count += 1 + this.countNodes(nodes[i].children || []);
|
|
291
|
+
}
|
|
292
|
+
return count;
|
|
293
|
+
}
|
|
294
|
+
}
|