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 +113 -8
- package/dist/issues.js +72 -0
- package/dist/issues.test.js +114 -0
- package/dist/migration.js +248 -0
- package/dist/migration.test.js +50 -0
- package/dist/samples.js +6 -0
- package/dist/samples.test.js +119 -0
- package/dist/validator.js +251 -0
- package/dist/validator.test.js +246 -0
- package/package.json +5 -3
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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: [
|
|
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
|
+
});
|