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 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
+ ![Live scene with node selected](media/02-selected.png)
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
+ ![Live overview](media/01-live.png)
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
+ ![Runtime-spawned objects appearing live](media/04-runtime.png)
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
+ ![Front view showing spatial layout](media/03-front.png)
50
+
51
+ ![Search filter narrowing the tree](media/05-search.png)
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
+ }