sceneview-mcp 3.0.1 → 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.
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { readFileSync } from "fs";
6
6
  import { dirname, resolve } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { getSample, SAMPLE_IDS, SAMPLES } from "./samples.js";
9
+ import { validateCode, formatValidationReport } from "./validator.js";
10
+ import { MIGRATION_GUIDE } from "./migration.js";
11
+ import { fetchKnownIssues } from "./issues.js";
9
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
13
  let API_DOCS;
11
14
  try {
@@ -14,7 +17,8 @@ try {
14
17
  catch {
15
18
  API_DOCS = "SceneView API docs not found. Run `npm run prepare` to bundle llms.txt.";
16
19
  }
17
- const server = new Server({ name: "@sceneview/mcp", version: "3.0.1" }, { capabilities: { resources: {}, tools: {} } });
20
+ const server = new Server({ name: "@sceneview/mcp", version: "3.0.2" }, { capabilities: { resources: {}, tools: {} } });
21
+ // ─── Resources ───────────────────────────────────────────────────────────────
18
22
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
19
23
  resources: [
20
24
  {
@@ -23,21 +27,36 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
23
27
  description: "Complete SceneView 3.0.0 API — Scene, ARScene, SceneScope DSL, ARSceneScope DSL, node types, resource loading, camera, gestures, math types, threading rules, and common patterns. Read this before writing any SceneView code.",
24
28
  mimeType: "text/markdown",
25
29
  },
30
+ {
31
+ uri: "sceneview://known-issues",
32
+ name: "SceneView Open GitHub Issues",
33
+ description: "Live list of open issues from the SceneView GitHub repository. Check this before reporting a bug or when something isn't working — there may already be a known workaround.",
34
+ mimeType: "text/markdown",
35
+ },
26
36
  ],
27
37
  }));
28
38
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
29
- if (request.params.uri === "sceneview://api") {
30
- return {
31
- contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
32
- };
39
+ switch (request.params.uri) {
40
+ case "sceneview://api":
41
+ return {
42
+ contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
43
+ };
44
+ case "sceneview://known-issues": {
45
+ const issues = await fetchKnownIssues();
46
+ return {
47
+ contents: [{ uri: "sceneview://known-issues", mimeType: "text/markdown", text: issues }],
48
+ };
49
+ }
50
+ default:
51
+ throw new Error(`Unknown resource: ${request.params.uri}`);
33
52
  }
34
- throw new Error(`Unknown resource: ${request.params.uri}`);
35
53
  });
54
+ // ─── Tools ───────────────────────────────────────────────────────────────────
36
55
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
37
56
  tools: [
38
57
  {
39
58
  name: "get_sample",
40
- description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising.",
59
+ description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising. Call `list_samples` first if you are unsure which scenario fits.",
41
60
  inputSchema: {
42
61
  type: "object",
43
62
  properties: {
@@ -50,6 +69,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
69
  required: ["scenario"],
51
70
  },
52
71
  },
72
+ {
73
+ name: "list_samples",
74
+ description: "Lists all available SceneView code samples with their IDs, descriptions, and tags. Use this to find the right sample before calling `get_sample`, or to show the user what SceneView can do.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ tag: {
79
+ type: "string",
80
+ description: "Optional tag to filter by (e.g. \"ar\", \"3d\", \"anchor\", \"face-tracking\", \"geometry\", \"animation\"). Omit to list all samples.",
81
+ },
82
+ },
83
+ required: [],
84
+ },
85
+ },
53
86
  {
54
87
  name: "get_setup",
55
88
  description: "Returns the Gradle dependency and AndroidManifest snippet required to use SceneView in an Android project.",
@@ -65,16 +98,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
98
  required: ["type"],
66
99
  },
67
100
  },
101
+ {
102
+ name: "validate_code",
103
+ description: "Checks a Kotlin SceneView snippet for common mistakes: threading violations, wrong destroy order, missing null-checks on rememberModelInstance, LightNode trailing-lambda bug, deprecated 2.x APIs, and more. Always call this before presenting generated SceneView code to the user.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ code: {
108
+ type: "string",
109
+ description: "The Kotlin source code to validate (composable function, class, or file).",
110
+ },
111
+ },
112
+ required: ["code"],
113
+ },
114
+ },
115
+ {
116
+ name: "get_migration_guide",
117
+ description: "Returns the full SceneView 2.x → 3.0 migration guide. Use this when a user reports code that worked in 2.x but breaks in 3.0, or when helping someone upgrade.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {},
121
+ required: [],
122
+ },
123
+ },
68
124
  ],
69
125
  }));
126
+ // ─── Tool handlers ────────────────────────────────────────────────────────────
70
127
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
71
128
  switch (request.params.name) {
129
+ // ── get_sample ────────────────────────────────────────────────────────────
72
130
  case "get_sample": {
73
131
  const scenario = request.params.arguments?.scenario;
74
132
  const sample = getSample(scenario);
75
133
  if (!sample) {
76
134
  return {
77
- content: [{ type: "text", text: `Unknown scenario "${scenario}". Available: ${SAMPLE_IDS.join(", ")}` }],
135
+ content: [
136
+ {
137
+ type: "text",
138
+ text: `Unknown scenario "${scenario}". Call \`list_samples\` to see available options.`,
139
+ },
140
+ ],
78
141
  isError: true,
79
142
  };
80
143
  }
@@ -85,6 +148,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
85
148
  text: [
86
149
  `## ${sample.title}`,
87
150
  ``,
151
+ `**Tags:** ${sample.tags.join(", ")}`,
152
+ ``,
88
153
  `**Gradle dependency:**`,
89
154
  `\`\`\`kotlin`,
90
155
  `implementation("${sample.dependency}")`,
@@ -102,6 +167,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
102
167
  ],
103
168
  };
104
169
  }
170
+ // ── list_samples ──────────────────────────────────────────────────────────
171
+ case "list_samples": {
172
+ const filterTag = request.params.arguments?.tag;
173
+ const entries = Object.values(SAMPLES).filter((s) => !filterTag || s.tags.includes(filterTag));
174
+ if (entries.length === 0) {
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: `No samples found with tag "${filterTag}". Available tags: 3d, ar, model, geometry, animation, camera, environment, anchor, plane-detection, image-tracking, face-tracking, placement, gestures`,
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ const header = filterTag
185
+ ? `## SceneView samples tagged \`${filterTag}\` (${entries.length})\n`
186
+ : `## All SceneView samples (${entries.length})\n`;
187
+ const rows = entries
188
+ .map((s) => `### \`${s.id}\`\n**${s.title}**\n${s.description}\n*Tags:* ${s.tags.join(", ")}\n*Dependency:* \`${s.dependency}\`\n\nCall \`get_sample("${s.id}")\` for the full code.`)
189
+ .join("\n\n---\n\n");
190
+ return { content: [{ type: "text", text: header + rows }] };
191
+ }
192
+ // ── get_setup ─────────────────────────────────────────────────────────────
105
193
  case "get_setup": {
106
194
  const type = request.params.arguments?.type;
107
195
  if (type === "3d") {
@@ -158,6 +246,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
158
246
  isError: true,
159
247
  };
160
248
  }
249
+ // ── validate_code ─────────────────────────────────────────────────────────
250
+ case "validate_code": {
251
+ const code = request.params.arguments?.code;
252
+ if (!code || typeof code !== "string") {
253
+ return {
254
+ content: [{ type: "text", text: "Missing required parameter: `code`" }],
255
+ isError: true,
256
+ };
257
+ }
258
+ const issues = validateCode(code);
259
+ const report = formatValidationReport(issues);
260
+ return { content: [{ type: "text", text: report }] };
261
+ }
262
+ // ── get_migration_guide ───────────────────────────────────────────────────
263
+ case "get_migration_guide": {
264
+ return { content: [{ type: "text", text: MIGRATION_GUIDE }] };
265
+ }
161
266
  default:
162
267
  return {
163
268
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
package/dist/issues.js ADDED
@@ -0,0 +1,72 @@
1
+ // Cache for 10 minutes — avoids hammering the GitHub API on every tool call
2
+ const CACHE_TTL_MS = 10 * 60 * 1000;
3
+ let cache = null;
4
+ export async function fetchKnownIssues() {
5
+ const now = Date.now();
6
+ if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
7
+ return cache.data;
8
+ }
9
+ let issues = [];
10
+ let fetchError = null;
11
+ try {
12
+ const response = await fetch("https://api.github.com/repos/SceneView/sceneview-android/issues?state=open&per_page=30", {
13
+ headers: {
14
+ Accept: "application/vnd.github+json",
15
+ "User-Agent": "sceneview-mcp/3.0",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ },
18
+ });
19
+ if (!response.ok) {
20
+ fetchError = `GitHub API returned ${response.status}: ${response.statusText}`;
21
+ }
22
+ else {
23
+ issues = (await response.json());
24
+ }
25
+ }
26
+ catch (err) {
27
+ fetchError = `Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`;
28
+ }
29
+ const result = formatIssues(issues, fetchError);
30
+ cache = { data: result, fetchedAt: now };
31
+ return result;
32
+ }
33
+ function formatIssues(issues, fetchError) {
34
+ const lines = ["# SceneView — Open GitHub Issues\n"];
35
+ if (fetchError) {
36
+ lines.push(`> ⚠️ ${fetchError}. Showing cached data if available.\n`);
37
+ }
38
+ else {
39
+ lines.push(`*Fetched live from GitHub — ${issues.length} open issue(s). Cached for 10 minutes.*\n`);
40
+ }
41
+ if (issues.length === 0) {
42
+ lines.push("No open issues. 🎉");
43
+ return lines.join("\n");
44
+ }
45
+ // Group by label: bugs first, then questions/other
46
+ const bugs = issues.filter((i) => i.labels.some((l) => l.name === "bug"));
47
+ const others = issues.filter((i) => !i.labels.some((l) => l.name === "bug"));
48
+ const renderGroup = (title, group) => {
49
+ if (group.length === 0)
50
+ return;
51
+ lines.push(`## ${title}\n`);
52
+ for (const issue of group) {
53
+ const labelStr = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : "";
54
+ lines.push(`### #${issue.number} — ${issue.title}${labelStr}`);
55
+ lines.push(`*Opened by @${issue.user.login} · ${issue.updated_at.slice(0, 10)}*`);
56
+ lines.push(issue.html_url);
57
+ // Include first 300 chars of body as context
58
+ if (issue.body) {
59
+ const excerpt = issue.body
60
+ .replace(/\r\n/g, "\n")
61
+ .replace(/```[\s\S]*?```/g, "[code block]") // strip code blocks for brevity
62
+ .trim()
63
+ .slice(0, 300);
64
+ lines.push(`\n> ${excerpt.replace(/\n/g, "\n> ")}${issue.body.length > 300 ? "…" : ""}`);
65
+ }
66
+ lines.push("");
67
+ }
68
+ };
69
+ renderGroup("🐛 Bug Reports", bugs);
70
+ renderGroup("📋 Other Issues", others);
71
+ return lines.join("\n");
72
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ // We test the private formatIssues logic by calling fetchKnownIssues with a
3
+ // mocked global fetch so we never hit the real GitHub API in tests.
4
+ const mockIssues = [
5
+ {
6
+ number: 123,
7
+ title: "SIGABRT on dispose",
8
+ html_url: "https://github.com/SceneView/sceneview-android/issues/123",
9
+ body: "Calling destroy() causes a native crash.",
10
+ labels: [{ name: "bug" }],
11
+ created_at: "2026-01-01T00:00:00Z",
12
+ updated_at: "2026-01-15T00:00:00Z",
13
+ user: { login: "testuser" },
14
+ },
15
+ {
16
+ number: 124,
17
+ title: "How do I add shadows?",
18
+ html_url: "https://github.com/SceneView/sceneview-android/issues/124",
19
+ body: "I want to enable shadows for my AR scene.",
20
+ labels: [{ name: "question" }],
21
+ created_at: "2026-01-02T00:00:00Z",
22
+ updated_at: "2026-01-16T00:00:00Z",
23
+ user: { login: "anotheruser" },
24
+ },
25
+ ];
26
+ beforeEach(() => {
27
+ // Reset module cache so the in-memory cache is cleared between tests
28
+ vi.resetModules();
29
+ });
30
+ describe("fetchKnownIssues", () => {
31
+ it("returns markdown with issue titles when GitHub API succeeds", async () => {
32
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
33
+ ok: true,
34
+ json: async () => mockIssues,
35
+ }));
36
+ const { fetchKnownIssues } = await import("./issues.js");
37
+ const result = await fetchKnownIssues();
38
+ expect(result).toContain("SIGABRT on dispose");
39
+ expect(result).toContain("#123");
40
+ expect(result).toContain("How do I add shadows?");
41
+ expect(result).toContain("#124");
42
+ vi.unstubAllGlobals();
43
+ });
44
+ it("groups bug-labelled issues under Bug Reports", async () => {
45
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
46
+ ok: true,
47
+ json: async () => mockIssues,
48
+ }));
49
+ const { fetchKnownIssues } = await import("./issues.js");
50
+ const result = await fetchKnownIssues();
51
+ expect(result).toContain("Bug Reports");
52
+ expect(result).toContain("SIGABRT on dispose");
53
+ vi.unstubAllGlobals();
54
+ });
55
+ it("groups non-bug issues under Other Issues", async () => {
56
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ json: async () => mockIssues,
59
+ }));
60
+ const { fetchKnownIssues } = await import("./issues.js");
61
+ const result = await fetchKnownIssues();
62
+ expect(result).toContain("Other Issues");
63
+ expect(result).toContain("How do I add shadows?");
64
+ vi.unstubAllGlobals();
65
+ });
66
+ it("includes github URLs", async () => {
67
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
68
+ ok: true,
69
+ json: async () => mockIssues,
70
+ }));
71
+ const { fetchKnownIssues } = await import("./issues.js");
72
+ const result = await fetchKnownIssues();
73
+ expect(result).toContain("https://github.com/SceneView/sceneview-android/issues/123");
74
+ vi.unstubAllGlobals();
75
+ });
76
+ it("includes body excerpt", async () => {
77
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ json: async () => mockIssues,
80
+ }));
81
+ const { fetchKnownIssues } = await import("./issues.js");
82
+ const result = await fetchKnownIssues();
83
+ expect(result).toContain("native crash");
84
+ vi.unstubAllGlobals();
85
+ });
86
+ it("shows warning message when GitHub API fails", async () => {
87
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
88
+ ok: false,
89
+ status: 403,
90
+ statusText: "Forbidden",
91
+ }));
92
+ const { fetchKnownIssues } = await import("./issues.js");
93
+ const result = await fetchKnownIssues();
94
+ expect(result).toContain("403");
95
+ vi.unstubAllGlobals();
96
+ });
97
+ it("shows warning message when fetch throws (network error)", async () => {
98
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
99
+ const { fetchKnownIssues } = await import("./issues.js");
100
+ const result = await fetchKnownIssues();
101
+ expect(result).toContain("Network error");
102
+ vi.unstubAllGlobals();
103
+ });
104
+ it("shows celebration when there are no open issues", async () => {
105
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
106
+ ok: true,
107
+ json: async () => [],
108
+ }));
109
+ const { fetchKnownIssues } = await import("./issues.js");
110
+ const result = await fetchKnownIssues();
111
+ expect(result).toContain("No open issues");
112
+ vi.unstubAllGlobals();
113
+ });
114
+ });
@@ -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
+ });