sceneview-mcp 3.0.1 → 3.0.3
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/migration.js +248 -0
- package/dist/samples.js +6 -0
- package/dist/validator.js +251 -0
- package/package.json +10 -4
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,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
|
+
`;
|
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
|
|
@@ -36,6 +37,7 @@ fun ModelViewerScreen() {
|
|
|
36
37
|
id: "geometry-scene",
|
|
37
38
|
title: "3D Geometry Scene",
|
|
38
39
|
description: "Procedural 3D scene using primitive geometry nodes (cube, sphere, plane) — no GLB required",
|
|
40
|
+
tags: ["3d", "geometry", "animation"],
|
|
39
41
|
dependency: "io.github.sceneview:sceneview:3.0.0",
|
|
40
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`.",
|
|
41
43
|
code: `@Composable
|
|
@@ -91,6 +93,7 @@ fun GeometrySceneScreen() {
|
|
|
91
93
|
id: "ar-tap-to-place",
|
|
92
94
|
title: "AR Tap-to-Place",
|
|
93
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"],
|
|
94
97
|
dependency: "io.github.sceneview:arsceneview:3.0.0",
|
|
95
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`.",
|
|
96
99
|
code: `@Composable
|
|
@@ -134,6 +137,7 @@ fun TapToPlaceScreen() {
|
|
|
134
137
|
id: "ar-placement-cursor",
|
|
135
138
|
title: "AR Placement Cursor",
|
|
136
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"],
|
|
137
141
|
dependency: "io.github.sceneview:arsceneview:3.0.0",
|
|
138
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`.",
|
|
139
143
|
code: `@Composable
|
|
@@ -180,6 +184,7 @@ fun ARCursorScreen() {
|
|
|
180
184
|
id: "ar-augmented-image",
|
|
181
185
|
title: "AR Augmented Image",
|
|
182
186
|
description: "Detects a reference image in the camera feed and overlays a 3D model above it.",
|
|
187
|
+
tags: ["ar", "model", "anchor", "image-tracking"],
|
|
183
188
|
dependency: "io.github.sceneview:arsceneview:3.0.0",
|
|
184
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`.",
|
|
185
190
|
code: `@Composable
|
|
@@ -222,6 +227,7 @@ fun AugmentedImageScreen() {
|
|
|
222
227
|
id: "ar-face-filter",
|
|
223
228
|
title: "AR Face Filter",
|
|
224
229
|
description: "Front-camera AR that detects faces and renders a 3D mesh material over them.",
|
|
230
|
+
tags: ["ar", "face-tracking", "camera"],
|
|
225
231
|
dependency: "io.github.sceneview:arsceneview:3.0.0",
|
|
226
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`.",
|
|
227
233
|
code: `@Composable
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
function findLines(lines, pattern) {
|
|
2
|
+
return lines
|
|
3
|
+
.map((l, i) => (pattern.test(l) ? i + 1 : -1))
|
|
4
|
+
.filter((n) => n !== -1);
|
|
5
|
+
}
|
|
6
|
+
const RULES = [
|
|
7
|
+
// ─── Threading ────────────────────────────────────────────────────────────
|
|
8
|
+
{
|
|
9
|
+
id: "threading/filament-off-main-thread",
|
|
10
|
+
severity: "error",
|
|
11
|
+
check(code, lines) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
if (!/Dispatchers\.(IO|Default)/.test(code))
|
|
14
|
+
return issues;
|
|
15
|
+
const filamentCallPatterns = [
|
|
16
|
+
[/modelLoader\.createModel/, "modelLoader.createModel*"],
|
|
17
|
+
[/modelLoader\.loadModel/, "modelLoader.loadModel*"],
|
|
18
|
+
[/materialLoader\./, "materialLoader.*"],
|
|
19
|
+
[/Texture\.Builder/, "Texture.Builder"],
|
|
20
|
+
[/engine\.createTexture/, "engine.createTexture"],
|
|
21
|
+
];
|
|
22
|
+
for (const [pat, name] of filamentCallPatterns) {
|
|
23
|
+
if (pat.test(code)) {
|
|
24
|
+
findLines(lines, pat).forEach((line) => issues.push({
|
|
25
|
+
severity: "error",
|
|
26
|
+
rule: "threading/filament-off-main-thread",
|
|
27
|
+
message: `\`${name}\` detected alongside a background dispatcher. Filament JNI calls must run on the **main thread**. Use \`rememberModelInstance\` in composables, or wrap imperative code in \`withContext(Dispatchers.Main)\`.`,
|
|
28
|
+
line,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return issues;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
// ─── AR: plain Node worldPosition instead of AnchorNode ──────────────────
|
|
36
|
+
{
|
|
37
|
+
id: "ar/node-not-anchor",
|
|
38
|
+
severity: "warning",
|
|
39
|
+
check(code, lines) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
if (!code.includes("ARScene") && !code.includes("AnchorNode"))
|
|
42
|
+
return issues;
|
|
43
|
+
if (/\.worldPosition\s*=/.test(code)) {
|
|
44
|
+
findLines(lines, /\.worldPosition\s*=/).forEach((line) => issues.push({
|
|
45
|
+
severity: "warning",
|
|
46
|
+
rule: "ar/node-not-anchor",
|
|
47
|
+
message: "Manually setting `worldPosition` inside an AR scene causes drift — ARCore remaps coordinates during tracking and plain nodes don't compensate. Use `AnchorNode(anchor = hitResult.createAnchor())` instead.",
|
|
48
|
+
line,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
return issues;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
// ─── Missing null-check on rememberModelInstance ──────────────────────────
|
|
55
|
+
{
|
|
56
|
+
id: "composable/model-instance-null-check",
|
|
57
|
+
severity: "error",
|
|
58
|
+
check(code, lines) {
|
|
59
|
+
const issues = [];
|
|
60
|
+
const assignMatch = code.match(/val\s+(\w+)\s*=\s*rememberModelInstance\(/);
|
|
61
|
+
if (!assignMatch)
|
|
62
|
+
return issues;
|
|
63
|
+
const varName = assignMatch[1];
|
|
64
|
+
// Flag ModelNode uses where the variable is passed without null-guard (no ?. !! or ?: )
|
|
65
|
+
const unsafeUse = new RegExp(`ModelNode\\s*\\([^)]*modelInstance\\s*=\\s*${varName}(?![?!])`);
|
|
66
|
+
if (unsafeUse.test(code)) {
|
|
67
|
+
findLines(lines, new RegExp(`modelInstance\\s*=\\s*${varName}(?![?!])`)).forEach((line) => issues.push({
|
|
68
|
+
severity: "error",
|
|
69
|
+
rule: "composable/model-instance-null-check",
|
|
70
|
+
message: `\`${varName}\` from \`rememberModelInstance\` is \`null\` while the asset loads. Guard it: \`${varName}?.let { ModelNode(modelInstance = it, ...) }\`.`,
|
|
71
|
+
line,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
return issues;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
// ─── LightNode trailing lambda instead of named `apply =` ────────────────
|
|
78
|
+
{
|
|
79
|
+
id: "api/light-node-trailing-lambda",
|
|
80
|
+
severity: "error",
|
|
81
|
+
check(code, lines) {
|
|
82
|
+
const issues = [];
|
|
83
|
+
// Matches: LightNode(...) { — trailing lambda, not apply = { inside parens
|
|
84
|
+
if (/LightNode\s*\([^)]*\)\s*\{/.test(code)) {
|
|
85
|
+
findLines(lines, /LightNode\s*\([^)]*\)\s*\{/).forEach((line) => issues.push({
|
|
86
|
+
severity: "error",
|
|
87
|
+
rule: "api/light-node-trailing-lambda",
|
|
88
|
+
message: "`LightNode`'s configuration block is a **named parameter** `apply`, not a trailing lambda. Write `LightNode(engine = engine, type = ..., apply = { intensity(100_000f) })`. Without `apply =` the block is silently ignored and your light has default (zero) settings.",
|
|
89
|
+
line,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
return issues;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
// ─── Engine created manually in composable ────────────────────────────────
|
|
96
|
+
{
|
|
97
|
+
id: "lifecycle/manual-engine-create",
|
|
98
|
+
severity: "error",
|
|
99
|
+
check(code, lines) {
|
|
100
|
+
const issues = [];
|
|
101
|
+
findLines(lines, /Engine\.create\(/).forEach((line) => issues.push({
|
|
102
|
+
severity: "error",
|
|
103
|
+
rule: "lifecycle/manual-engine-create",
|
|
104
|
+
message: "`Engine.create()` called directly. In composables use `rememberEngine()` — it ties the Engine to the composition lifecycle and destroys it automatically on disposal, preventing leaks and double-destroy SIGABRTs.",
|
|
105
|
+
line,
|
|
106
|
+
}));
|
|
107
|
+
return issues;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
// ─── Manual engine.destroy() alongside rememberEngine ────────────────────
|
|
111
|
+
{
|
|
112
|
+
id: "lifecycle/manual-engine-destroy",
|
|
113
|
+
severity: "warning",
|
|
114
|
+
check(code, lines) {
|
|
115
|
+
const issues = [];
|
|
116
|
+
if (!code.includes("rememberEngine"))
|
|
117
|
+
return issues;
|
|
118
|
+
findLines(lines, /engine\.(safeD|d)estroy\(\)/).forEach((line) => issues.push({
|
|
119
|
+
severity: "warning",
|
|
120
|
+
rule: "lifecycle/manual-engine-destroy",
|
|
121
|
+
message: "`engine.destroy()` called manually alongside `rememberEngine()`. The engine is already destroyed on composition disposal — calling it again triggers a SIGABRT. Remove the manual call.",
|
|
122
|
+
line,
|
|
123
|
+
}));
|
|
124
|
+
return issues;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
// ─── Texture destroy order (issue #630) ──────────────────────────────────
|
|
128
|
+
{
|
|
129
|
+
id: "lifecycle/texture-destroy-order",
|
|
130
|
+
severity: "error",
|
|
131
|
+
check(code, lines) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
const texIdx = code.indexOf("safeDestroyTexture");
|
|
134
|
+
const matIdx = code.indexOf("destroyMaterialInstance");
|
|
135
|
+
if (texIdx !== -1 && matIdx !== -1 && texIdx < matIdx) {
|
|
136
|
+
findLines(lines, /safeDestroyTexture|engine\.destroyTexture/).forEach((line) => issues.push({
|
|
137
|
+
severity: "error",
|
|
138
|
+
rule: "lifecycle/texture-destroy-order",
|
|
139
|
+
message: "Texture destroyed **before** the MaterialInstance that references it → SIGABRT (\"Invalid texture still bound to MaterialInstance\"). Destroy the MaterialInstance first: `materialLoader.destroyMaterialInstance(instance)`, then `engine.safeDestroyTexture(texture)`.",
|
|
140
|
+
line,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
return issues;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
// ─── createModelInstance in composable (should be rememberModelInstance) ──
|
|
147
|
+
{
|
|
148
|
+
id: "composable/prefer-remember-model-instance",
|
|
149
|
+
severity: "warning",
|
|
150
|
+
check(code, lines) {
|
|
151
|
+
const issues = [];
|
|
152
|
+
findLines(lines, /modelLoader\.createModelInstance\(/).forEach((line) => issues.push({
|
|
153
|
+
severity: "warning",
|
|
154
|
+
rule: "composable/prefer-remember-model-instance",
|
|
155
|
+
message: "`modelLoader.createModelInstance()` blocks the main thread. In composables, use `rememberModelInstance(modelLoader, path)` — it loads asynchronously, returns `null` while loading, and recomposes when ready.",
|
|
156
|
+
line,
|
|
157
|
+
}));
|
|
158
|
+
return issues;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
// ─── Empty AnchorNode() ────────────────────────────────────────────────────
|
|
162
|
+
{
|
|
163
|
+
id: "ar/anchor-node-missing-anchor",
|
|
164
|
+
severity: "error",
|
|
165
|
+
check(code, lines) {
|
|
166
|
+
const issues = [];
|
|
167
|
+
findLines(lines, /AnchorNode\s*\(\s*\)/).forEach((line) => issues.push({
|
|
168
|
+
severity: "error",
|
|
169
|
+
rule: "ar/anchor-node-missing-anchor",
|
|
170
|
+
message: "`AnchorNode()` requires an `anchor` from a hit result. Use `AnchorNode(anchor = hitResult.createAnchor())` inside `onTouchEvent` or `onSessionUpdated`.",
|
|
171
|
+
line,
|
|
172
|
+
}));
|
|
173
|
+
return issues;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
// ─── 2.x → 3.0 renamed/removed APIs ─────────────────────────────────────
|
|
177
|
+
{
|
|
178
|
+
id: "migration/old-api",
|
|
179
|
+
severity: "error",
|
|
180
|
+
check(code, lines) {
|
|
181
|
+
const issues = [];
|
|
182
|
+
const renames = [
|
|
183
|
+
[/\bSceneView\s*\(/, "`SceneView(…)` → renamed to `Scene(…)` in 3.0"],
|
|
184
|
+
[/\bArSceneView\s*\(/, "`ArSceneView(…)` → renamed to `ARScene(…)` in 3.0"],
|
|
185
|
+
[/\bPlacementNode\b/, "`PlacementNode` removed → use `AnchorNode` + `HitResultNode` in 3.0"],
|
|
186
|
+
[/\bTransformableNode\b/, "`TransformableNode` removed → set `isEditable = true` on `ModelNode` in 3.0"],
|
|
187
|
+
[/\bViewRenderable\b/, "`ViewRenderable` removed → use `ViewNode` with a `@Composable` content lambda in 3.0"],
|
|
188
|
+
[/\bmodelLoader\.loadModelAsync\b/, "`loadModelAsync` removed → use `rememberModelInstance` in composables (3.0)"],
|
|
189
|
+
[/\bmodelLoader\.loadModel\b(?!Instance)/, "`loadModel` → use `rememberModelInstance` or `loadModelInstanceAsync` (3.0)"],
|
|
190
|
+
];
|
|
191
|
+
for (const [pat, msg] of renames) {
|
|
192
|
+
if (pat.test(code)) {
|
|
193
|
+
findLines(lines, pat).forEach((line) => issues.push({ severity: "error", rule: "migration/old-api", message: msg, line }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return issues;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
// ─── Scene missing engine param ───────────────────────────────────────────
|
|
200
|
+
{
|
|
201
|
+
id: "api/scene-missing-engine",
|
|
202
|
+
severity: "error",
|
|
203
|
+
check(code, lines) {
|
|
204
|
+
const issues = [];
|
|
205
|
+
// Scene( or ARScene( without engine = somewhere nearby
|
|
206
|
+
const sceneCallLines = findLines(lines, /\b(AR)?Scene\s*\(/);
|
|
207
|
+
sceneCallLines.forEach((line) => {
|
|
208
|
+
// Look at the next 10 lines for engine =
|
|
209
|
+
const block = lines.slice(line - 1, line + 10).join("\n");
|
|
210
|
+
if (!block.includes("engine")) {
|
|
211
|
+
issues.push({
|
|
212
|
+
severity: "error",
|
|
213
|
+
rule: "api/scene-missing-engine",
|
|
214
|
+
message: "`Scene` / `ARScene` requires an `engine` parameter. Create one with `val engine = rememberEngine()` and pass it: `Scene(engine = engine, …)`.",
|
|
215
|
+
line,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
return issues;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
export function validateCode(kotlinCode) {
|
|
224
|
+
const lines = kotlinCode.split("\n");
|
|
225
|
+
return RULES.flatMap((rule) => rule.check(kotlinCode, lines));
|
|
226
|
+
}
|
|
227
|
+
export function formatValidationReport(issues) {
|
|
228
|
+
if (issues.length === 0) {
|
|
229
|
+
return "✅ No issues found. The snippet follows SceneView best practices.";
|
|
230
|
+
}
|
|
231
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
232
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
233
|
+
const infos = issues.filter((i) => i.severity === "info");
|
|
234
|
+
const icon = {
|
|
235
|
+
error: "🔴",
|
|
236
|
+
warning: "🟡",
|
|
237
|
+
info: "🔵",
|
|
238
|
+
};
|
|
239
|
+
const header = `Found **${issues.length} issue(s)**: ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s).\n`;
|
|
240
|
+
const body = issues
|
|
241
|
+
.map((issue, i) => {
|
|
242
|
+
const loc = issue.line ? ` (line ${issue.line})` : "";
|
|
243
|
+
return [
|
|
244
|
+
`### ${i + 1}. ${icon[issue.severity]} ${issue.severity.toUpperCase()}${loc}`,
|
|
245
|
+
`**Rule:** \`${issue.rule}\``,
|
|
246
|
+
issue.message,
|
|
247
|
+
].join("\n");
|
|
248
|
+
})
|
|
249
|
+
.join("\n\n");
|
|
250
|
+
return header + "\n" + body;
|
|
251
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sceneview-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "MCP server for SceneView — 3D and AR with Jetpack Compose for Android. Give Claude the full SceneView SDK so it writes correct, compilable Kotlin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -31,7 +31,11 @@
|
|
|
31
31
|
"sceneview-mcp": "dist/index.js"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
|
-
"dist",
|
|
34
|
+
"dist/index.js",
|
|
35
|
+
"dist/issues.js",
|
|
36
|
+
"dist/migration.js",
|
|
37
|
+
"dist/samples.js",
|
|
38
|
+
"dist/validator.js",
|
|
35
39
|
"llms.txt"
|
|
36
40
|
],
|
|
37
41
|
"engines": {
|
|
@@ -41,7 +45,8 @@
|
|
|
41
45
|
"build": "tsc",
|
|
42
46
|
"prepare": "cp ../llms.txt ./llms.txt && tsc",
|
|
43
47
|
"start": "node dist/index.js",
|
|
44
|
-
"dev": "tsx src/index.ts"
|
|
48
|
+
"dev": "tsx src/index.ts",
|
|
49
|
+
"test": "vitest run"
|
|
45
50
|
},
|
|
46
51
|
"dependencies": {
|
|
47
52
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
@@ -49,6 +54,7 @@
|
|
|
49
54
|
"devDependencies": {
|
|
50
55
|
"@types/node": "^22.0.0",
|
|
51
56
|
"tsx": "^4.0.0",
|
|
52
|
-
"typescript": "^5.8.0"
|
|
57
|
+
"typescript": "^5.8.0",
|
|
58
|
+
"vitest": "^4.1.0"
|
|
53
59
|
}
|
|
54
60
|
}
|