sceneview-mcp 3.0.0 → 3.0.2

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.
@@ -0,0 +1,248 @@
1
+ export const MIGRATION_GUIDE = `# SceneView 2.x → 3.0 Migration Guide
2
+
3
+ SceneView 3.0 is a full rewrite from Android Views to **Jetpack Compose**. Nearly every public API changed. This guide covers every breaking change and how to fix it.
4
+
5
+ ---
6
+
7
+ ## 1. Gradle dependency
8
+
9
+ | 2.x | 3.0 |
10
+ |-----|-----|
11
+ | \`io.github.sceneview:sceneview:2.x.x\` | \`io.github.sceneview:sceneview:3.0.0\` |
12
+ | \`io.github.sceneview:arsceneview:2.x.x\` | \`io.github.sceneview:arsceneview:3.0.0\` |
13
+
14
+ ---
15
+
16
+ ## 2. Root composable names
17
+
18
+ | 2.x | 3.0 |
19
+ |-----|-----|
20
+ | \`SceneView(…)\` | \`Scene(…)\` |
21
+ | \`ArSceneView(…)\` | \`ARScene(…)\` |
22
+
23
+ **Before:**
24
+ \`\`\`kotlin
25
+ SceneView(modifier = Modifier.fillMaxSize())
26
+ \`\`\`
27
+
28
+ **After:**
29
+ \`\`\`kotlin
30
+ val engine = rememberEngine()
31
+ Scene(modifier = Modifier.fillMaxSize(), engine = engine)
32
+ \`\`\`
33
+
34
+ ---
35
+
36
+ ## 3. Engine lifecycle
37
+
38
+ In 2.x the engine was managed internally. In 3.0 you own it — use \`rememberEngine()\` which ties it to the composition lifecycle.
39
+
40
+ | 2.x | 3.0 |
41
+ |-----|-----|
42
+ | Engine implicit | \`val engine = rememberEngine()\` |
43
+ | Never destroy manually | Never call \`engine.destroy()\` — \`rememberEngine\` does it |
44
+
45
+ ---
46
+
47
+ ## 4. Model loading
48
+
49
+ | 2.x | 3.0 |
50
+ |-----|-----|
51
+ | \`modelLoader.loadModelAsync(path) { … }\` | \`rememberModelInstance(modelLoader, path)\` (returns \`null\` while loading) |
52
+ | \`modelLoader.loadModel(path)\` | \`modelLoader.loadModelInstanceAsync(path)\` (imperative) |
53
+ | \`ModelRenderable.builder()\` | Removed — use GLB/glTF assets |
54
+
55
+ **Before:**
56
+ \`\`\`kotlin
57
+ var modelInstance by remember { mutableStateOf<ModelInstance?>(null) }
58
+ LaunchedEffect(Unit) {
59
+ modelInstance = modelLoader.loadModelAsync("models/chair.glb")
60
+ }
61
+ \`\`\`
62
+
63
+ **After:**
64
+ \`\`\`kotlin
65
+ Scene(engine = engine, modelLoader = modelLoader) {
66
+ rememberModelInstance(modelLoader, "models/chair.glb")?.let { instance ->
67
+ ModelNode(modelInstance = instance, scaleToUnits = 1.0f)
68
+ }
69
+ }
70
+ \`\`\`
71
+
72
+ ---
73
+
74
+ ## 5. Node hierarchy — imperative → declarative DSL
75
+
76
+ In 2.x nodes were added imperatively (\`scene.addChild(node)\`). In 3.0 nodes are declared as composables inside \`Scene { }\` or \`ARScene { }\`.
77
+
78
+ **Before:**
79
+ \`\`\`kotlin
80
+ val modelNode = ModelNode().apply {
81
+ loadModelGlbAsync(
82
+ glbFileLocation = "models/chair.glb",
83
+ scaleToUnits = 1f,
84
+ )
85
+ }
86
+ sceneView.addChildNode(modelNode)
87
+ \`\`\`
88
+
89
+ **After:**
90
+ \`\`\`kotlin
91
+ Scene(engine = engine, modelLoader = modelLoader) {
92
+ rememberModelInstance(modelLoader, "models/chair.glb")?.let { instance ->
93
+ ModelNode(modelInstance = instance, scaleToUnits = 1f)
94
+ }
95
+ }
96
+ \`\`\`
97
+
98
+ ---
99
+
100
+ ## 6. Removed nodes and replacements
101
+
102
+ | 2.x | 3.0 replacement |
103
+ |-----|-----------------|
104
+ | \`TransformableNode\` | Set \`isEditable = true\` on \`ModelNode\` |
105
+ | \`PlacementNode\` | \`AnchorNode(anchor = hitResult.createAnchor())\` + \`HitResultNode\` |
106
+ | \`ViewRenderable\` | \`ViewNode\` with a \`@Composable\` content lambda |
107
+ | \`AnchorNode()\` (no-arg) | \`AnchorNode(anchor = hitResult.createAnchor())\` |
108
+
109
+ **TransformableNode:**
110
+ \`\`\`kotlin
111
+ // Before
112
+ val node = TransformableNode(transformationSystem).apply {
113
+ setParent(anchorNode)
114
+ }
115
+
116
+ // After
117
+ ModelNode(
118
+ modelInstance = instance,
119
+ scaleToUnits = 1f,
120
+ isEditable = true // enables pinch-to-scale + drag-to-rotate
121
+ )
122
+ \`\`\`
123
+
124
+ **ViewRenderable → ViewNode:**
125
+ \`\`\`kotlin
126
+ // Before
127
+ ViewRenderable.builder()
128
+ .setView(context, R.layout.my_layout)
129
+ .build()
130
+ .thenAccept { renderable -> … }
131
+
132
+ // After
133
+ val windowManager = rememberViewNodeManager()
134
+ Scene(viewNodeWindowManager = windowManager) {
135
+ ViewNode(windowManager = windowManager) {
136
+ Card { Text("Hello 3D World!") }
137
+ }
138
+ }
139
+ \`\`\`
140
+
141
+ ---
142
+
143
+ ## 7. Light configuration
144
+
145
+ LightNode's \`apply\` is a **named parameter** (not a trailing lambda). This is the most common silent breakage after migrating.
146
+
147
+ \`\`\`kotlin
148
+ // WRONG — trailing lambda is silently ignored
149
+ LightNode(engine = engine, type = LightManager.Type.DIRECTIONAL) {
150
+ intensity(100_000f)
151
+ }
152
+
153
+ // CORRECT
154
+ LightNode(
155
+ engine = engine,
156
+ type = LightManager.Type.DIRECTIONAL,
157
+ apply = {
158
+ intensity(100_000f)
159
+ castShadows(true)
160
+ }
161
+ )
162
+ \`\`\`
163
+
164
+ ---
165
+
166
+ ## 8. Environment / IBL loading
167
+
168
+ | 2.x | 3.0 |
169
+ |-----|-----|
170
+ | \`environmentLoader.loadEnvironment(path)\` | \`environmentLoader.createHDREnvironment(path)\` |
171
+
172
+ \`\`\`kotlin
173
+ // 3.0
174
+ val environmentLoader = rememberEnvironmentLoader(engine)
175
+ Scene(
176
+ environment = rememberEnvironment(environmentLoader) {
177
+ environmentLoader.createHDREnvironment("environments/sky_2k.hdr")!!
178
+ }
179
+ ) { … }
180
+ \`\`\`
181
+
182
+ ---
183
+
184
+ ## 9. AR session configuration
185
+
186
+ In 3.0 \`sessionConfiguration\` is a lambda parameter on \`ARScene\` (not a separate builder).
187
+
188
+ \`\`\`kotlin
189
+ ARScene(
190
+ engine = engine,
191
+ modelLoader = modelLoader,
192
+ sessionConfiguration = { session, config ->
193
+ config.depthMode =
194
+ if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC))
195
+ Config.DepthMode.AUTOMATIC else Config.DepthMode.DISABLED
196
+ config.instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
197
+ config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
198
+ }
199
+ ) { … }
200
+ \`\`\`
201
+
202
+ ---
203
+
204
+ ## 10. AR anchors (no more worldPosition hacks)
205
+
206
+ | 2.x pattern | 3.0 |
207
+ |-------------|-----|
208
+ | \`node.worldPosition = hitResult.hitPose.position\` | \`AnchorNode(anchor = hitResult.createAnchor())\` |
209
+
210
+ Plain nodes whose \`worldPosition\` is set manually will drift when ARCore remaps its coordinate system. \`AnchorNode\` compensates automatically.
211
+
212
+ ---
213
+
214
+ ## 11. Shadows
215
+
216
+ In 3.0, \`ARScene\` has shadows enabled by default via \`createARView()\`. For \`Scene\` (3D only), shadows are disabled by default — enable with:
217
+
218
+ \`\`\`kotlin
219
+ Scene(
220
+ view = rememberView(engine).also { it.setShadowingEnabled(true) },
221
+
222
+ )
223
+ \`\`\`
224
+
225
+ ---
226
+
227
+ ## 12. Camera
228
+
229
+ | 2.x | 3.0 |
230
+ |-----|-----|
231
+ | \`CameraManipulator\` set on the View | \`cameraManipulator = rememberCameraManipulator()\` on \`Scene\` |
232
+ | Custom camera via \`setCameraNode\` | \`cameraNode = rememberCameraNode(engine) { … }\` on \`Scene\` |
233
+
234
+ ---
235
+
236
+ ## Checklist
237
+
238
+ - [ ] Replace \`SceneView(…)\` → \`Scene(engine = rememberEngine(), …)\`
239
+ - [ ] Replace \`ArSceneView(…)\` → \`ARScene(engine = rememberEngine(), …)\`
240
+ - [ ] Replace \`modelLoader.loadModelAsync\` → \`rememberModelInstance\`
241
+ - [ ] Add null-check on every \`rememberModelInstance\` result
242
+ - [ ] Replace \`TransformableNode\` → \`isEditable = true\`
243
+ - [ ] Replace \`PlacementNode\` → \`AnchorNode\` + \`HitResultNode\`
244
+ - [ ] Replace \`ViewRenderable\` → \`ViewNode\` with Compose lambda
245
+ - [ ] Fix \`LightNode { … }\` → \`LightNode(apply = { … })\`
246
+ - [ ] Remove manual \`engine.destroy()\` calls
247
+ - [ ] Replace manual \`worldPosition\` in AR → \`AnchorNode\`
248
+ `;
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { MIGRATION_GUIDE } from "./migration.js";
3
+ describe("MIGRATION_GUIDE", () => {
4
+ it("is a non-empty string", () => {
5
+ expect(typeof MIGRATION_GUIDE).toBe("string");
6
+ expect(MIGRATION_GUIDE.length).toBeGreaterThan(500);
7
+ });
8
+ it("covers composable renames (SceneView → Scene, ArSceneView → ARScene)", () => {
9
+ expect(MIGRATION_GUIDE).toContain("SceneView");
10
+ expect(MIGRATION_GUIDE).toContain("Scene");
11
+ expect(MIGRATION_GUIDE).toContain("ArSceneView");
12
+ expect(MIGRATION_GUIDE).toContain("ARScene");
13
+ });
14
+ it("covers model loading migration (loadModelAsync → rememberModelInstance)", () => {
15
+ expect(MIGRATION_GUIDE).toContain("loadModelAsync");
16
+ expect(MIGRATION_GUIDE).toContain("rememberModelInstance");
17
+ });
18
+ it("covers removed nodes", () => {
19
+ expect(MIGRATION_GUIDE).toContain("TransformableNode");
20
+ expect(MIGRATION_GUIDE).toContain("PlacementNode");
21
+ expect(MIGRATION_GUIDE).toContain("ViewRenderable");
22
+ });
23
+ it("covers LightNode named apply parameter gotcha", () => {
24
+ expect(MIGRATION_GUIDE).toContain("apply");
25
+ expect(MIGRATION_GUIDE).toContain("LightNode");
26
+ expect(MIGRATION_GUIDE).toContain("trailing lambda");
27
+ });
28
+ it("covers engine lifecycle (rememberEngine)", () => {
29
+ expect(MIGRATION_GUIDE).toContain("rememberEngine");
30
+ expect(MIGRATION_GUIDE).toContain("engine.destroy");
31
+ });
32
+ it("covers AR anchor drift (worldPosition → AnchorNode)", () => {
33
+ expect(MIGRATION_GUIDE).toContain("worldPosition");
34
+ expect(MIGRATION_GUIDE).toContain("AnchorNode");
35
+ expect(MIGRATION_GUIDE).toContain("drift");
36
+ });
37
+ it("covers gradle dependency changes", () => {
38
+ expect(MIGRATION_GUIDE).toContain("io.github.sceneview:sceneview:3.0.0");
39
+ expect(MIGRATION_GUIDE).toContain("io.github.sceneview:arsceneview:3.0.0");
40
+ });
41
+ it("includes a migration checklist", () => {
42
+ expect(MIGRATION_GUIDE).toContain("Checklist");
43
+ expect(MIGRATION_GUIDE).toContain("- [ ]");
44
+ });
45
+ it("includes before/after code examples", () => {
46
+ expect(MIGRATION_GUIDE).toContain("Before");
47
+ expect(MIGRATION_GUIDE).toContain("After");
48
+ expect(MIGRATION_GUIDE).toContain("```kotlin");
49
+ });
50
+ });
package/dist/samples.js CHANGED
@@ -3,6 +3,7 @@ export const SAMPLES = {
3
3
  id: "model-viewer",
4
4
  title: "3D Model Viewer",
5
5
  description: "Full-screen 3D scene with a GLB model, HDR environment, and orbit camera",
6
+ tags: ["3d", "model", "environment", "camera"],
6
7
  dependency: "io.github.sceneview:sceneview:3.0.0",
7
8
  prompt: "Create an Android Compose screen called `ModelViewerScreen` that loads a GLB file from assets/models/my_model.glb and displays it in a full-screen 3D scene with an orbit camera (drag to rotate, pinch to zoom). Add an HDR environment from assets/environments/sky_2k.hdr for realistic lighting. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
8
9
  code: `@Composable
@@ -30,12 +31,69 @@ fun ModelViewerScreen() {
30
31
  )
31
32
  }
32
33
  }
34
+ }`,
35
+ },
36
+ "geometry-scene": {
37
+ id: "geometry-scene",
38
+ title: "3D Geometry Scene",
39
+ description: "Procedural 3D scene using primitive geometry nodes (cube, sphere, plane) — no GLB required",
40
+ tags: ["3d", "geometry", "animation"],
41
+ dependency: "io.github.sceneview:sceneview:3.0.0",
42
+ prompt: "Create an Android Compose screen called `GeometrySceneScreen` that renders a full-screen 3D scene with a red rotating cube, a metallic blue sphere, and a green floor plane. No model files — use SceneView built-in geometry nodes. Orbit camera. Use SceneView `io.github.sceneview:sceneview:3.0.0`.",
43
+ code: `@Composable
44
+ fun GeometrySceneScreen() {
45
+ val engine = rememberEngine()
46
+ val materialLoader = rememberMaterialLoader(engine)
47
+ val t = rememberInfiniteTransition(label = "spin")
48
+ val angle by t.animateFloat(
49
+ initialValue = 0f, targetValue = 360f,
50
+ animationSpec = infiniteRepeatable(tween(4_000, easing = LinearEasing)),
51
+ label = "angle"
52
+ )
53
+
54
+ Scene(
55
+ modifier = Modifier.fillMaxSize(),
56
+ engine = engine,
57
+ materialLoader = materialLoader,
58
+ mainLightNode = rememberMainLightNode(engine) { intensity(80_000f) },
59
+ cameraManipulator = rememberCameraManipulator()
60
+ ) {
61
+ // Rotating red cube
62
+ CubeNode(
63
+ engine,
64
+ size = Size(0.5f, 0.5f, 0.5f),
65
+ materialInstance = materialLoader.createColorInstance(
66
+ Color.Red, metallic = 0f, roughness = 0.5f
67
+ ),
68
+ position = Position(x = -0.6f),
69
+ rotation = Rotation(y = angle)
70
+ )
71
+ // Metallic blue sphere
72
+ SphereNode(
73
+ engine,
74
+ radius = 0.3f,
75
+ materialInstance = materialLoader.createColorInstance(
76
+ Color.Blue, metallic = 0.8f, roughness = 0.2f
77
+ ),
78
+ position = Position(x = 0.6f)
79
+ )
80
+ // Floor plane
81
+ PlaneNode(
82
+ engine,
83
+ size = Size(2f, 0f, 2f),
84
+ materialInstance = materialLoader.createColorInstance(
85
+ Color(0xFF4CAF50), metallic = 0f, roughness = 0.9f
86
+ ),
87
+ position = Position(y = -0.35f)
88
+ )
89
+ }
33
90
  }`,
34
91
  },
35
92
  "ar-tap-to-place": {
36
93
  id: "ar-tap-to-place",
37
94
  title: "AR Tap-to-Place",
38
95
  description: "AR scene where each tap places a GLB model on a detected surface. Placed models are pinch-to-scale and drag-to-rotate.",
96
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "gestures"],
39
97
  dependency: "io.github.sceneview:arsceneview:3.0.0",
40
98
  prompt: "Create an Android Compose screen called `TapToPlaceScreen` that opens the camera in AR mode. Show a plane detection grid. When the user taps a detected surface, place a 3D GLB model from assets/models/chair.glb at that point. The user should be able to pinch-to-scale and drag-to-rotate after placing. Multiple taps = multiple objects. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
41
99
  code: `@Composable
@@ -79,6 +137,7 @@ fun TapToPlaceScreen() {
79
137
  id: "ar-placement-cursor",
80
138
  title: "AR Placement Cursor",
81
139
  description: "AR scene with a reticle that follows the surface at screen center. Tap to confirm placement.",
140
+ tags: ["ar", "model", "anchor", "plane-detection", "placement", "camera"],
82
141
  dependency: "io.github.sceneview:arsceneview:3.0.0",
83
142
  prompt: "Create an Android Compose AR screen called `ARCursorScreen`. Show a small reticle that snaps to the nearest detected surface at the center of the screen as the user moves the camera. When the user taps, place a GLB model from assets/models/object.glb at that position and hide the reticle. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
84
143
  code: `@Composable
@@ -125,6 +184,7 @@ fun ARCursorScreen() {
125
184
  id: "ar-augmented-image",
126
185
  title: "AR Augmented Image",
127
186
  description: "Detects a reference image in the camera feed and overlays a 3D model above it.",
187
+ tags: ["ar", "model", "anchor", "image-tracking"],
128
188
  dependency: "io.github.sceneview:arsceneview:3.0.0",
129
189
  prompt: "Create an Android Compose AR screen called `AugmentedImageScreen` that detects a printed reference image (from R.drawable.target_image, physical width 15 cm) and places a 3D GLB model from assets/models/overlay.glb above it, scaled to match the image width. The model should disappear when the image is lost. Use SceneView `io.github.sceneview:arsceneview:3.0.0`.",
130
190
  code: `@Composable
@@ -167,6 +227,7 @@ fun AugmentedImageScreen() {
167
227
  id: "ar-face-filter",
168
228
  title: "AR Face Filter",
169
229
  description: "Front-camera AR that detects faces and renders a 3D mesh material over them.",
230
+ tags: ["ar", "face-tracking", "camera"],
170
231
  dependency: "io.github.sceneview:arsceneview:3.0.0",
171
232
  prompt: "Create an Android Compose AR screen called `FaceFilterScreen` using the front camera. Detect all visible faces and apply a custom material from assets/materials/face_mask.filamat to the face mesh. Use SceneView `io.github.sceneview:arsceneview:3.0.0` with `Session.Feature.FRONT_CAMERA` and `AugmentedFaceMode.MESH3D`.",
172
233
  code: `@Composable
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { SAMPLES, SAMPLE_IDS, getSample } from "./samples.js";
3
+ describe("SAMPLE_IDS", () => {
4
+ it("contains all 6 expected scenarios", () => {
5
+ expect(SAMPLE_IDS).toContain("model-viewer");
6
+ expect(SAMPLE_IDS).toContain("geometry-scene");
7
+ expect(SAMPLE_IDS).toContain("ar-tap-to-place");
8
+ expect(SAMPLE_IDS).toContain("ar-placement-cursor");
9
+ expect(SAMPLE_IDS).toContain("ar-augmented-image");
10
+ expect(SAMPLE_IDS).toContain("ar-face-filter");
11
+ });
12
+ it("SAMPLE_IDS matches keys of SAMPLES", () => {
13
+ expect(SAMPLE_IDS.sort()).toEqual(Object.keys(SAMPLES).sort());
14
+ });
15
+ });
16
+ describe("every sample", () => {
17
+ for (const id of SAMPLE_IDS) {
18
+ const sample = SAMPLES[id];
19
+ it(`${id}: has all required fields`, () => {
20
+ expect(sample.id).toBe(id);
21
+ expect(sample.title).toBeTruthy();
22
+ expect(sample.description).toBeTruthy();
23
+ expect(sample.tags.length).toBeGreaterThan(0);
24
+ expect(sample.dependency).toMatch(/^io\.github\.sceneview:/);
25
+ expect(sample.prompt).toBeTruthy();
26
+ expect(sample.code).toBeTruthy();
27
+ });
28
+ it(`${id}: code is non-empty Kotlin`, () => {
29
+ expect(sample.code).toContain("@Composable");
30
+ expect(sample.code).toContain("fun ");
31
+ });
32
+ it(`${id}: dependency is a valid sceneview artifact`, () => {
33
+ expect(["io.github.sceneview:sceneview:3.0.0", "io.github.sceneview:arsceneview:3.0.0"]).toContain(sample.dependency);
34
+ });
35
+ }
36
+ });
37
+ describe("AR samples", () => {
38
+ const arIds = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("ar"));
39
+ it("all AR samples use arsceneview dependency", () => {
40
+ for (const id of arIds) {
41
+ expect(SAMPLES[id].dependency).toBe("io.github.sceneview:arsceneview:3.0.0");
42
+ }
43
+ });
44
+ it("all AR samples contain ARScene in code", () => {
45
+ for (const id of arIds) {
46
+ expect(SAMPLES[id].code).toContain("ARScene");
47
+ }
48
+ });
49
+ it("all AR samples have the 'ar' tag", () => {
50
+ for (const id of arIds) {
51
+ expect(SAMPLES[id].tags).toContain("ar");
52
+ }
53
+ });
54
+ });
55
+ describe("3D samples", () => {
56
+ const d3Ids = SAMPLE_IDS.filter((id) => SAMPLES[id].tags.includes("3d"));
57
+ it("all 3D samples use sceneview dependency", () => {
58
+ for (const id of d3Ids) {
59
+ expect(SAMPLES[id].dependency).toBe("io.github.sceneview:sceneview:3.0.0");
60
+ }
61
+ });
62
+ it("all 3D samples contain Scene in code", () => {
63
+ for (const id of d3Ids) {
64
+ expect(SAMPLES[id].code).toContain("Scene(");
65
+ }
66
+ });
67
+ });
68
+ describe("getSample", () => {
69
+ it("returns the correct sample by ID", () => {
70
+ const s = getSample("model-viewer");
71
+ expect(s).toBeDefined();
72
+ expect(s.id).toBe("model-viewer");
73
+ expect(s.title).toBe("3D Model Viewer");
74
+ });
75
+ it("returns undefined for an unknown ID", () => {
76
+ expect(getSample("nonexistent-scenario")).toBeUndefined();
77
+ });
78
+ it("returns all samples without undefined", () => {
79
+ for (const id of SAMPLE_IDS) {
80
+ expect(getSample(id)).toBeDefined();
81
+ }
82
+ });
83
+ });
84
+ describe("tag filtering (simulating list_samples tool)", () => {
85
+ const filterByTag = (tag) => Object.values(SAMPLES).filter((s) => s.tags.includes(tag));
86
+ it("tag 'ar' returns only AR samples", () => {
87
+ const results = filterByTag("ar");
88
+ expect(results.length).toBeGreaterThan(0);
89
+ results.forEach((s) => expect(s.tags).toContain("ar"));
90
+ });
91
+ it("tag '3d' returns only 3D samples", () => {
92
+ const results = filterByTag("3d");
93
+ expect(results.length).toBeGreaterThan(0);
94
+ results.forEach((s) => expect(s.tags).toContain("3d"));
95
+ });
96
+ it("tag 'face-tracking' returns only the face filter sample", () => {
97
+ const results = filterByTag("face-tracking");
98
+ expect(results).toHaveLength(1);
99
+ expect(results[0].id).toBe("ar-face-filter");
100
+ });
101
+ it("tag 'image-tracking' returns only augmented image sample", () => {
102
+ const results = filterByTag("image-tracking");
103
+ expect(results).toHaveLength(1);
104
+ expect(results[0].id).toBe("ar-augmented-image");
105
+ });
106
+ it("tag 'geometry' returns only geometry scene", () => {
107
+ const results = filterByTag("geometry");
108
+ expect(results).toHaveLength(1);
109
+ expect(results[0].id).toBe("geometry-scene");
110
+ });
111
+ it("tag 'anchor' returns AR samples that use anchors", () => {
112
+ const results = filterByTag("anchor");
113
+ expect(results.length).toBeGreaterThan(0);
114
+ results.forEach((s) => expect(s.tags).toContain("anchor"));
115
+ });
116
+ it("unknown tag returns empty array", () => {
117
+ expect(filterByTag("nonexistent-tag")).toHaveLength(0);
118
+ });
119
+ });