sceneview-mcp 4.0.12 → 4.0.14
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/README.md +46 -10
- package/dist/android-docs.js +206 -0
- package/dist/examples.js +136 -0
- package/dist/generate-model.js +402 -0
- package/dist/generated/llms-txt.js +1 -1
- package/dist/generated/version.js +2 -2
- package/dist/guides.js +1 -1
- package/dist/index.js +29 -0
- package/dist/issues.js +49 -2
- package/dist/platform-setup.js +1 -2
- package/dist/telemetry.js +178 -2
- package/dist/tiers.js +28 -3
- package/dist/tools/definitions.js +66 -0
- package/dist/tools/handler.js +47 -0
- package/package.json +10 -5
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate_3d_model — Tripo BYOK text/image→GLB generation tool.
|
|
3
|
+
*
|
|
4
|
+
* `search_models` finds assets that already exist; this tool closes the other
|
|
5
|
+
* half of the agentic asset loop: when no existing model fits, the assistant
|
|
6
|
+
* generates a brand-new GLB from a text prompt or a source image using the
|
|
7
|
+
* Tripo AI API (api.tripo3d.ai), then loads it with
|
|
8
|
+
* `rememberModelInstance(modelLoader, ...)` and places it in AR.
|
|
9
|
+
*
|
|
10
|
+
* The tool is BYOK — users bring their own `TRIPO_API_KEY` (create one at
|
|
11
|
+
* platform.tripo3d.ai/api-keys). We never ship or proxy a key, so there is no
|
|
12
|
+
* server-side key custody, no cost to us, and no rate-limit sharing across
|
|
13
|
+
* users — exactly the `SKETCHFAB_API_KEY` pattern used by `search_models`.
|
|
14
|
+
*
|
|
15
|
+
* Quality tiers (July 2026 Tripo model catalog):
|
|
16
|
+
* - "fast" (default) → P1 low-poly (`P1-20260311`) — AR-ready meshes,
|
|
17
|
+
* ~25–30 s, roughly $0.10–0.25 of Tripo credits per generation.
|
|
18
|
+
* - "hd" → H3.1 (`v3.1-20260211`) with quad topology + detailed geometry
|
|
19
|
+
* and textures — up to ~100 s, roughly $0.41 of Tripo credits.
|
|
20
|
+
*
|
|
21
|
+
* API contract (grounded against docs.tripo3d.ai, July 2026):
|
|
22
|
+
* 1. POST https://api.tripo3d.ai/v2/openapi/task
|
|
23
|
+
* Authorization: Bearer <key>
|
|
24
|
+
* { "type": "text_to_model", "prompt": "...", "model_version": "..." }
|
|
25
|
+
* or
|
|
26
|
+
* { "type": "image_to_model", "file": { "type": "jpg|png|...", "url": "..." }, ... }
|
|
27
|
+
* → { "code": 0, "data": { "task_id": "..." } }
|
|
28
|
+
* 2. GET https://api.tripo3d.ai/v2/openapi/task/{task_id} — poll until the
|
|
29
|
+
* status is finalized. Status enum: queued | running (ongoing) and
|
|
30
|
+
* success | failed | banned | expired | cancelled | unknown (finalized).
|
|
31
|
+
* On success, `data.output` carries the model URLs (`pbr_model`,
|
|
32
|
+
* `model`, `base_model`) plus `rendered_image`. Download URLs expire
|
|
33
|
+
* after ~5 minutes — the caller must download the GLB immediately.
|
|
34
|
+
*
|
|
35
|
+
* All network errors, missing keys, task failures, and poll timeouts are
|
|
36
|
+
* translated to a structured `GenerateModelError` so the MCP handler can
|
|
37
|
+
* render a clear message without crashing the server.
|
|
38
|
+
*/
|
|
39
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
40
|
+
const TRIPO_TASK_ENDPOINT = "https://api.tripo3d.ai/v2/openapi/task";
|
|
41
|
+
const API_KEYS_URL = "https://platform.tripo3d.ai/api-keys";
|
|
42
|
+
const MAX_PROMPT_LENGTH = 1024; // Tripo's documented prompt limit.
|
|
43
|
+
/** How often to re-poll the task while it is queued/running. */
|
|
44
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
45
|
+
/**
|
|
46
|
+
* Bounded polling caps. "fast" (P1) typically finishes in ~25–30 s; "hd"
|
|
47
|
+
* (H3.1) can take ~100 s, so it gets a 4-minute ceiling. Beyond the cap the
|
|
48
|
+
* tool returns a `timeout` error instead of hanging the MCP call forever.
|
|
49
|
+
*/
|
|
50
|
+
const QUALITY_TIERS = {
|
|
51
|
+
fast: {
|
|
52
|
+
modelVersion: "P1-20260311",
|
|
53
|
+
label: "fast (Tripo P1 — low-poly, AR-ready)",
|
|
54
|
+
timeoutMs: 120_000,
|
|
55
|
+
},
|
|
56
|
+
hd: {
|
|
57
|
+
modelVersion: "v3.1-20260211",
|
|
58
|
+
label: "hd (Tripo H3.1 — quad topology, detailed geometry & textures)",
|
|
59
|
+
timeoutMs: 240_000,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const LICENSE_NOTE = 'Generated with your own Tripo API key — usage rights follow your Tripo plan\'s terms (https://www.tripo3d.ai/api). No third-party author attribution is required, but crediting "Made with Tripo AI" is appreciated.';
|
|
63
|
+
const ATTRIBUTION = "Tripo AI (https://www.tripo3d.ai)";
|
|
64
|
+
/** Finalized-but-not-successful Tripo task statuses. */
|
|
65
|
+
const FAILED_STATUSES = new Set(["failed", "banned", "expired", "cancelled", "unknown"]);
|
|
66
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
67
|
+
function missingKeyError() {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
code: "missing_key",
|
|
71
|
+
message: [
|
|
72
|
+
"generate_3d_model needs a Tripo API key (BYOK — generations are billed to YOUR Tripo account, nothing is charged by SceneView).",
|
|
73
|
+
"",
|
|
74
|
+
`1. Create an API key at ${API_KEYS_URL} (new accounts get free trial credits)`,
|
|
75
|
+
"2. Set the TRIPO_API_KEY environment variable in your MCP client config:",
|
|
76
|
+
"",
|
|
77
|
+
" Claude Desktop / Cursor / Windsurf:",
|
|
78
|
+
" {",
|
|
79
|
+
' "mcpServers": {',
|
|
80
|
+
' "sceneview": {',
|
|
81
|
+
' "command": "npx",',
|
|
82
|
+
' "args": ["-y", "sceneview-mcp"],',
|
|
83
|
+
' "env": { "TRIPO_API_KEY": "YOUR_KEY_HERE" }',
|
|
84
|
+
" }",
|
|
85
|
+
" }",
|
|
86
|
+
" }",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Derive the Tripo `file.type` hint from the image URL extension. Tripo
|
|
92
|
+
* documents the field as advisory ("currently not validated") — JPEG and PNG
|
|
93
|
+
* are the officially supported input formats.
|
|
94
|
+
*/
|
|
95
|
+
function imageTypeFromUrl(url) {
|
|
96
|
+
const path = url.split(/[?#]/)[0]?.toLowerCase() ?? "";
|
|
97
|
+
if (path.endsWith(".png"))
|
|
98
|
+
return "png";
|
|
99
|
+
if (path.endsWith(".webp"))
|
|
100
|
+
return "webp";
|
|
101
|
+
if (path.endsWith(".jpeg"))
|
|
102
|
+
return "jpeg";
|
|
103
|
+
return "jpg";
|
|
104
|
+
}
|
|
105
|
+
function isHttpUrl(value) {
|
|
106
|
+
return /^https?:\/\//i.test(value.trim());
|
|
107
|
+
}
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the task-submission body for the requested mode + quality tier.
|
|
113
|
+
* Exported for tests only.
|
|
114
|
+
*/
|
|
115
|
+
export function buildTaskBody(options) {
|
|
116
|
+
const tier = QUALITY_TIERS[options.quality];
|
|
117
|
+
const body = {
|
|
118
|
+
model_version: tier.modelVersion,
|
|
119
|
+
texture: true,
|
|
120
|
+
pbr: true,
|
|
121
|
+
};
|
|
122
|
+
if (options.quality === "hd") {
|
|
123
|
+
// H3.1 add-ons: quad-mesh topology + detailed geometry & textures.
|
|
124
|
+
body.quad = true;
|
|
125
|
+
body.geometry_quality = "detailed";
|
|
126
|
+
body.texture_quality = "detailed";
|
|
127
|
+
}
|
|
128
|
+
if (options.prompt !== undefined) {
|
|
129
|
+
body.type = "text_to_model";
|
|
130
|
+
body.prompt = options.prompt;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
body.type = "image_to_model";
|
|
134
|
+
body.file = {
|
|
135
|
+
type: imageTypeFromUrl(options.imageUrl ?? ""),
|
|
136
|
+
url: options.imageUrl,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return body;
|
|
140
|
+
}
|
|
141
|
+
/** Map a non-OK Tripo HTTP response to a structured error. */
|
|
142
|
+
function httpError(status, statusText) {
|
|
143
|
+
if (status === 401 || status === 403) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
code: "unauthorized",
|
|
147
|
+
message: [
|
|
148
|
+
`Tripo rejected the API key (HTTP ${status}).`,
|
|
149
|
+
`Double-check the key at ${API_KEYS_URL}, or create a new one — keys look like "tsk_...".`,
|
|
150
|
+
].join(" "),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (status === 429) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "rate_limited",
|
|
157
|
+
message: "Tripo rate limit reached (HTTP 429). Wait a minute and retry, or check your plan's concurrency limits at https://platform.tripo3d.ai.",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
code: "bad_response",
|
|
163
|
+
message: `Tripo returned HTTP ${status} ${statusText || ""}`.trim(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Generate a 3D model (GLB) from a text prompt or a source image via the
|
|
169
|
+
* Tripo API: submit a task, then poll until it finalizes or the bounded
|
|
170
|
+
* deadline expires.
|
|
171
|
+
*
|
|
172
|
+
* Reads `TRIPO_API_KEY` from the environment. All error paths return a
|
|
173
|
+
* `GenerateModelError` rather than throwing, so the MCP dispatcher can render
|
|
174
|
+
* a friendly message without wrapping the call in a try/catch.
|
|
175
|
+
*/
|
|
176
|
+
export async function generateModel(options) {
|
|
177
|
+
// ── Input validation (no network before this passes) ──────────────────────
|
|
178
|
+
const prompt = typeof options?.prompt === "string" ? options.prompt.trim() : undefined;
|
|
179
|
+
const imageUrl = typeof options?.imageUrl === "string" ? options.imageUrl.trim() : undefined;
|
|
180
|
+
const hasPrompt = prompt !== undefined && prompt.length > 0;
|
|
181
|
+
const hasImage = imageUrl !== undefined && imageUrl.length > 0;
|
|
182
|
+
if (!hasPrompt && !hasImage) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: "invalid_input",
|
|
186
|
+
message: "Provide exactly one of `prompt` (text→3D) or `imageUrl` (image→3D). Both are currently empty.",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (hasPrompt && hasImage) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
code: "invalid_input",
|
|
193
|
+
message: "Provide exactly one of `prompt` or `imageUrl`, not both. Use `prompt` for text→3D or `imageUrl` for image→3D.",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (hasPrompt && prompt.length > MAX_PROMPT_LENGTH) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "invalid_input",
|
|
200
|
+
message: `\`prompt\` is too long (${prompt.length} chars). Tripo accepts at most ${MAX_PROMPT_LENGTH} characters.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (hasImage && !isHttpUrl(imageUrl)) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
code: "invalid_input",
|
|
207
|
+
message: "`imageUrl` must be a public http(s) URL to a JPEG or PNG image (max 20 MB).",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const quality = options.quality === "hd" ? "hd" : "fast"; // default + unknown values → fast
|
|
211
|
+
const tier = QUALITY_TIERS[quality];
|
|
212
|
+
const apiKey = process.env.TRIPO_API_KEY;
|
|
213
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
214
|
+
return missingKeyError();
|
|
215
|
+
}
|
|
216
|
+
const headers = {
|
|
217
|
+
Authorization: `Bearer ${apiKey}`,
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
Accept: "application/json",
|
|
220
|
+
};
|
|
221
|
+
// ── 1. Submit the generation task ──────────────────────────────────────────
|
|
222
|
+
const body = buildTaskBody({
|
|
223
|
+
prompt: hasPrompt ? prompt : undefined,
|
|
224
|
+
imageUrl: hasImage ? imageUrl : undefined,
|
|
225
|
+
quality,
|
|
226
|
+
});
|
|
227
|
+
let submitResponse;
|
|
228
|
+
try {
|
|
229
|
+
submitResponse = await fetch(TRIPO_TASK_ENDPOINT, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers,
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: "network",
|
|
240
|
+
message: `Could not reach Tripo (${cause}). Check your internet connection and try again.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (!submitResponse.ok) {
|
|
244
|
+
return httpError(submitResponse.status, submitResponse.statusText);
|
|
245
|
+
}
|
|
246
|
+
let submitPayload;
|
|
247
|
+
try {
|
|
248
|
+
submitPayload = (await submitResponse.json());
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
code: "bad_response",
|
|
255
|
+
message: `Tripo returned invalid JSON: ${cause}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (typeof submitPayload.code === "number" && submitPayload.code !== 0) {
|
|
259
|
+
const detail = [submitPayload.message, submitPayload.suggestion].filter(Boolean).join(" — ");
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
code: "bad_response",
|
|
263
|
+
message: `Tripo rejected the task (code ${submitPayload.code})${detail ? `: ${detail}` : "."}`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const taskId = submitPayload.data?.task_id;
|
|
267
|
+
if (!taskId || typeof taskId !== "string") {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
code: "bad_response",
|
|
271
|
+
message: "Tripo accepted the request but returned no task_id.",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// ── 2. Poll until the task finalizes (bounded) ─────────────────────────────
|
|
275
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
276
|
+
const timeoutMs = options.timeoutMs ?? tier.timeoutMs;
|
|
277
|
+
const deadline = Date.now() + timeoutMs;
|
|
278
|
+
let lastStatus = "queued";
|
|
279
|
+
let lastProgress = 0;
|
|
280
|
+
for (;;) {
|
|
281
|
+
let pollResponse;
|
|
282
|
+
try {
|
|
283
|
+
pollResponse = await fetch(`${TRIPO_TASK_ENDPOINT}/${taskId}`, { headers });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: "network",
|
|
290
|
+
message: `Lost connection to Tripo while polling task ${taskId} (${cause}). The generation may still complete — retry later or check https://platform.tripo3d.ai.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (pollResponse.status === 401 || pollResponse.status === 403) {
|
|
294
|
+
return httpError(pollResponse.status, pollResponse.statusText);
|
|
295
|
+
}
|
|
296
|
+
// Transient poll hiccups (429 burst, 5xx, malformed JSON) are tolerated:
|
|
297
|
+
// keep polling until the bounded deadline instead of failing the task.
|
|
298
|
+
if (pollResponse.ok) {
|
|
299
|
+
let pollPayload;
|
|
300
|
+
try {
|
|
301
|
+
pollPayload = (await pollResponse.json());
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
pollPayload = undefined;
|
|
305
|
+
}
|
|
306
|
+
const task = pollPayload?.data;
|
|
307
|
+
const status = task?.status;
|
|
308
|
+
if (typeof status === "string") {
|
|
309
|
+
lastStatus = status;
|
|
310
|
+
if (typeof task?.progress === "number")
|
|
311
|
+
lastProgress = task.progress;
|
|
312
|
+
if (status === "success") {
|
|
313
|
+
const output = task?.output ?? {};
|
|
314
|
+
const modelUrl = output.pbr_model || output.model || output.base_model || "";
|
|
315
|
+
if (!modelUrl) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
code: "bad_response",
|
|
319
|
+
message: `Tripo task ${taskId} succeeded but returned no model URL.`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
model: {
|
|
325
|
+
taskId,
|
|
326
|
+
modelUrl,
|
|
327
|
+
previewImageUrl: output.rendered_image ?? "",
|
|
328
|
+
quality,
|
|
329
|
+
modelVersion: tier.modelVersion,
|
|
330
|
+
mode: hasPrompt ? "text" : "image",
|
|
331
|
+
input: hasPrompt ? prompt : imageUrl,
|
|
332
|
+
creditsConsumed: typeof task?.consumed_credit === "number" ? task.consumed_credit : null,
|
|
333
|
+
license: LICENSE_NOTE,
|
|
334
|
+
attribution: ATTRIBUTION,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (FAILED_STATUSES.has(status)) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
code: "task_failed",
|
|
342
|
+
message: [
|
|
343
|
+
`Tripo task ${taskId} finalized with status "${status}".`,
|
|
344
|
+
status === "failed"
|
|
345
|
+
? "Try rephrasing the prompt (or a clearer source image) and generate again."
|
|
346
|
+
: "Check the task on https://platform.tripo3d.ai for details.",
|
|
347
|
+
].join(" "),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// queued / running → keep polling.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (Date.now() >= deadline) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
code: "timeout",
|
|
357
|
+
message: [
|
|
358
|
+
`Tripo task ${taskId} did not finish within ${Math.round(timeoutMs / 1000)}s`,
|
|
359
|
+
`(last status: "${lastStatus}", progress ${lastProgress}%).`,
|
|
360
|
+
"The generation may still complete on Tripo's side — credits may be consumed.",
|
|
361
|
+
"Retry, or check the task on https://platform.tripo3d.ai.",
|
|
362
|
+
].join(" "),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
await sleep(pollIntervalMs);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Render a `GenerateModelResult` as the markdown text block the MCP
|
|
370
|
+
* dispatcher returns to the client. Kept here (not in handler.ts) so unit
|
|
371
|
+
* tests can verify formatting without touching the dispatch layer.
|
|
372
|
+
*/
|
|
373
|
+
export function formatGenerateResult(result) {
|
|
374
|
+
if (!result.ok) {
|
|
375
|
+
return result.message;
|
|
376
|
+
}
|
|
377
|
+
const m = result.model;
|
|
378
|
+
const sourceLabel = m.mode === "text" ? "Prompt" : "Source image";
|
|
379
|
+
const lines = [
|
|
380
|
+
`## Generated 3D model (${QUALITY_TIERS[m.quality].label})`,
|
|
381
|
+
"",
|
|
382
|
+
`- **GLB download:** ${m.modelUrl}`,
|
|
383
|
+
`- **⚠️ URL expiry:** the download link expires ~5 minutes after generation — download the file NOW and self-host it (e.g. copy it into your app's \`assets/models/\`).`,
|
|
384
|
+
...(m.previewImageUrl ? [`- **Preview:** ${m.previewImageUrl}`] : []),
|
|
385
|
+
`- **${sourceLabel}:** ${m.input}`,
|
|
386
|
+
`- **Model version:** ${m.modelVersion}`,
|
|
387
|
+
`- **Task ID:** \`${m.taskId}\``,
|
|
388
|
+
...(m.creditsConsumed !== null ? [`- **Tripo credits consumed:** ${m.creditsConsumed}`] : []),
|
|
389
|
+
`- **License:** ${m.license}`,
|
|
390
|
+
`- **Generator:** ${m.attribution}`,
|
|
391
|
+
"",
|
|
392
|
+
"### Load it in SceneView",
|
|
393
|
+
"",
|
|
394
|
+
"```kotlin",
|
|
395
|
+
"// After downloading the GLB into your app's assets:",
|
|
396
|
+
'val model = rememberModelInstance(modelLoader, "models/generated.glb")',
|
|
397
|
+
"```",
|
|
398
|
+
"",
|
|
399
|
+
'Place it in AR with `AnchorNode` + `ModelNode` — see the llms.txt recipe "Generate a 3D model with AI (Tripo) and place it in AR".',
|
|
400
|
+
];
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|