@gripforgeai/mcp 0.1.15 → 0.1.17
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/architecture-tools.js +13 -9
- package/dist/asset-production-tools.js +1 -1
- package/dist/game-creation-tools.js +1 -1
- package/dist/game-import-agent.js +457 -0
- package/dist/game-import-extract.js +67 -0
- package/dist/game-import-normalize.js +72 -0
- package/dist/game-import-reverse.js +122 -0
- package/dist/game-import-tools.js +138 -0
- package/dist/game-import-types.js +1 -0
- package/dist/game-import-worker.js +34 -0
- package/dist/gamekit-tools.js +110 -9
- package/dist/scene-tools.js +5 -1
- package/dist/server.js +5 -1
- package/package.json +4 -3
- package/runtime/game-import-normalize.py +40 -0
package/dist/gamekit-tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Modular Game Kits tools. Studio and MCP call the same Core HTTP API. */
|
|
2
2
|
import { z } from 'zod/v4';
|
|
3
|
+
import { LOOK_VALUES, LOOK_DESCRIPTION } from './look.js';
|
|
3
4
|
export const GAMEKIT_TOOL_NAMES = [
|
|
4
5
|
'gripforge_gamekit_search',
|
|
5
6
|
'gripforge_gamekit_get',
|
|
@@ -12,16 +13,46 @@ export const GAMEKIT_TOOL_NAMES = [
|
|
|
12
13
|
'gripforge_gamekit_deliver',
|
|
13
14
|
'gripforge_game_capabilities',
|
|
14
15
|
'gripforge_game_project',
|
|
16
|
+
'gripforge_game_art_direction',
|
|
17
|
+
'gripforge_game_asset_plan',
|
|
18
|
+
'gripforge_gamekit_creatures',
|
|
15
19
|
'gripforge_game_engine',
|
|
16
20
|
'gripforge_game_content',
|
|
17
21
|
'gripforge_game_play_url',
|
|
18
22
|
'gripforge_game_test',
|
|
23
|
+
'gripforge_game_audit',
|
|
19
24
|
'gripforge_game_web_export',
|
|
20
25
|
'gripforge_moba_roster',
|
|
21
26
|
'gripforge_ability_vfx',
|
|
22
27
|
'gripforge_moba_map',
|
|
23
28
|
'gripforge_terrain_map_use',
|
|
24
29
|
];
|
|
30
|
+
/**
|
|
31
|
+
* Where each tool stands in the making of a game, appended to its description: when to call it, and the audit it
|
|
32
|
+
* leads to. One table, so the journey reads the same from every tool — and one place to change it.
|
|
33
|
+
*/
|
|
34
|
+
export const TOOL_JOURNEY = {
|
|
35
|
+
gripforge_gamekit_search: 'Journey — first: find the preset of the genre. Its `done` says what a finished game of that genre has beyond the request, and gripforge_game_audit checks it at the end.',
|
|
36
|
+
gripforge_gamekit_installed: 'Journey — first on an existing project; gripforge_game_audit then says what stands between it and a finished game.',
|
|
37
|
+
gripforge_gamekit_install: 'Journey — while composing. A kit installed and left empty is not done (audio.core without a sound, ui.endscreen without an end condition): gripforge_game_audit names them.',
|
|
38
|
+
gripforge_gamekit_configure: 'Journey — while composing, and to fix what gripforge_game_audit reports (its defects carry the exact config to write).',
|
|
39
|
+
gripforge_game_capabilities: 'Journey — while composing; gripforge_game_audit includes this report, with the creatures, the controls, the playtest and the definition of done of the genre.',
|
|
40
|
+
gripforge_game_project: 'Journey — create, then bind a model to every creature slot (get carries the creatures report) and set the controls: action=controls applies the control scheme of the gameplay, a proposal to adapt. Before telling the user the game is done: gripforge_game_audit.',
|
|
41
|
+
gripforge_gamekit_creatures: 'Journey — right after creating or binding: no unit stays a capsule, every model gets its movement and action clips. gripforge_game_audit folds this report with the rest and applies the free steps with fix=true.',
|
|
42
|
+
gripforge_game_engine: 'Journey — the build order of the content; its last step is gripforge_game_audit, not "it loads".',
|
|
43
|
+
gripforge_game_content: 'Journey — the content of the game. Once written, gripforge_game_audit says what a finished game of the genre still lacks (a quest, a way to win and to lose, dialogues…).',
|
|
44
|
+
gripforge_game_test: 'Journey — after each change. A playtest that passes is not a finished game: gripforge_game_audit (playtest="run" runs this test) gives the verdict and everything the tester cannot see.',
|
|
45
|
+
gripforge_game_play_url: 'Journey — to look at the running game. Share it as finished only when gripforge_game_audit says complete; otherwise say what is left.',
|
|
46
|
+
gripforge_game_web_export: 'Journey — last. Run gripforge_game_audit first: export a game whose verdict is complete, or tell the user what is left.',
|
|
47
|
+
gripforge_gamekit_deliver: 'Journey — last. Run gripforge_game_audit first: deliver a game whose verdict is complete, or tell the user what is left.',
|
|
48
|
+
gripforge_moba_roster: 'Journey — a MOBA needs distinct heroes; then gripforge_game_audit for the minions, towers, jungle monsters and the rest of a finished MOBA.',
|
|
49
|
+
gripforge_ability_vfx: 'Journey — after the roster; gripforge_game_audit lists it among what a finished MOBA has.',
|
|
50
|
+
};
|
|
51
|
+
/** The tool's place in the journey, then its description (whose closing credit rule stays last). */
|
|
52
|
+
function withJourney(name, description) {
|
|
53
|
+
const journey = TOOL_JOURNEY[name];
|
|
54
|
+
return journey ? `${journey} ${description}` : description;
|
|
55
|
+
}
|
|
25
56
|
const enc = (v) => encodeURIComponent(String(v));
|
|
26
57
|
const HOSTED_DELIVERY_NOTE = 'This hosted endpoint cannot write into your project: write bundle.files following plan.actions in order, or use npx @gripforgeai/mcp with gripforge_gamekit_deliver_local { project_dir }, or the editor bridge.';
|
|
27
58
|
export function registerGameKitTools(register, options, schema = z) {
|
|
@@ -43,7 +74,7 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
43
74
|
.record(schema.string(), schema.unknown())
|
|
44
75
|
.optional()
|
|
45
76
|
.describe('Kit config values, validated against the kit configSchema returned by gripforge_gamekit_get. Merged over the current config.');
|
|
46
|
-
async function api(path, args, method, signal, query) {
|
|
77
|
+
async function api(path, args, method, signal, query, timeoutMs = 120_000) {
|
|
47
78
|
const key = options.getApiKey();
|
|
48
79
|
if (!key)
|
|
49
80
|
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
@@ -66,7 +97,7 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
66
97
|
...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}),
|
|
67
98
|
},
|
|
68
99
|
...(method === 'GET' || method === 'DELETE' ? {} : { body: JSON.stringify(body) }),
|
|
69
|
-
signal: AbortSignal.any([AbortSignal.timeout(
|
|
100
|
+
signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]),
|
|
70
101
|
});
|
|
71
102
|
const data = (await res.json());
|
|
72
103
|
return {
|
|
@@ -82,7 +113,7 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
82
113
|
function tool(name, title, description, inputSchema, opts, callback) {
|
|
83
114
|
register(name, {
|
|
84
115
|
title,
|
|
85
|
-
description,
|
|
116
|
+
description: withJourney(name, description),
|
|
86
117
|
inputSchema: { ...inputSchema, ...workspace },
|
|
87
118
|
annotations: {
|
|
88
119
|
readOnlyHint: opts.readOnly,
|
|
@@ -251,18 +282,51 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
251
282
|
}, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/capabilities`, args, 'GET', extra?.signal, {
|
|
252
283
|
goal: typeof args.goal === 'string' ? args.goal : undefined,
|
|
253
284
|
}));
|
|
254
|
-
tool('
|
|
255
|
-
|
|
256
|
-
|
|
285
|
+
tool('gripforge_game_art_direction', 'Read or update the shared art direction of a game', 'Store artistic intent on the existing Game Kit project (game.engine), separately from gameplay: look, world/theme, palette, proportions/materials/lighting notes and device budget. Read first, then set with expected_revision. Unspecified fields are preserved; null clears a field or the profile. Existing assets and rendering are not modified. Use this direction with map wizard.look, VFX look, asset prompts, lighting and UI tools, then inspect gripforge_game_asset_plan and actual-engine captures. 0 credits.', {
|
|
286
|
+
project_id: projectId,
|
|
287
|
+
action: schema.enum(['get', 'set']).describe('get reads the profile/revision; set applies a partial profile update.'),
|
|
288
|
+
expected_revision: schema.number().int().positive().optional().describe('set: revision returned by get; stale changes are refused.'),
|
|
289
|
+
art_direction: schema.object({
|
|
290
|
+
look: schema.enum(LOOK_VALUES).nullable().optional().describe(LOOK_DESCRIPTION),
|
|
291
|
+
theme: schema.string().max(120).nullable().optional(),
|
|
292
|
+
palette: schema.array(schema.string().regex(/^#[a-f\d]{6}$/i)).max(12).nullable().optional(),
|
|
293
|
+
notes: schema.string().max(1600).nullable().optional(),
|
|
294
|
+
device: schema.enum(['mobile', 'desktop']).nullable().optional(),
|
|
295
|
+
}).strict().nullable().optional().describe('set: shared project art direction patch. Null clears the profile or an individual field.'),
|
|
296
|
+
}, { readOnly: false }, (args, extra) => {
|
|
297
|
+
const path = `gamekit-projects/${enc(args.project_id)}/art-direction`;
|
|
298
|
+
if (args.action === 'get')
|
|
299
|
+
return api(path, args, 'GET', extra?.signal);
|
|
300
|
+
if (args.art_direction === undefined || args.expected_revision === undefined)
|
|
301
|
+
return Promise.resolve(fail('set needs art_direction and expected_revision from get.'));
|
|
302
|
+
return api(path, { workspace_id: args.workspace_id, artDirection: args.art_direction, expected_revision: args.expected_revision }, 'PATCH', extra?.signal);
|
|
303
|
+
});
|
|
304
|
+
tool('gripforge_game_asset_plan', 'Audit project asset roles or search compatible candidates', 'Read asset requirements from enabled Game Kits and game.engine archetypes. Without slot: report missing files/bindings, template defaults, metadata style/theme conflicts, unknown classifications and technical ranking concerns. With slot: search accessible Library/Community candidates for that role and the saved art direction; known visual conflicts are excluded. No purchase, generation or binding. Metadata checks do not replace visual/animation/gameplay review in the actual engine. 0 credits.', {
|
|
305
|
+
project_id: projectId,
|
|
306
|
+
slot: schema.string().max(160).optional().describe('Omit for the audit; pass a role id from the audit to search candidates.'),
|
|
307
|
+
q: schema.string().max(400).optional().describe('Optional candidate search terms for this slot.'),
|
|
308
|
+
source: schema.enum(['all', 'workspace', 'community']).optional().describe('Search scope; default all accessible assets.'),
|
|
309
|
+
limit: schema.number().int().min(1).max(48).optional().describe('Maximum candidates, default 12.'),
|
|
310
|
+
}, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/assets`, args, 'GET', extra?.signal, {
|
|
311
|
+
slot: args.slot, q: args.q, source: args.source, limit: args.limit,
|
|
312
|
+
}));
|
|
313
|
+
tool('gripforge_game_project', 'List, create, read, bind, feed, set the controls of or delete Game Kit projects', 'Manage Game Kit projects (gkp_…): the container holding installed kits, their lockfile, asset bindings and data collections. action=list lists the workspace projects; create needs name and takes a preset (see gripforge_gamekit_search; adventure is the reference game of game.engine) or an explicit kits map — every new project gets game.engine first (the engine of the game: scenes, archetypes, scripts, life cycle; its answer carries the engine start pack { structure, scene, blocks, order, next, rules } and added, the kits installed for dependencies), then the default kits ui.menu (title / pause / options / load and save screen), input.remap (key remapping), settings.graphics (graphics options), ui.prompts (on-screen key prompts that follow remapping and the gamepad), ui.endscreen (end screen and credits), save.persistence (save slots, quick save, autosave), audio.core (game audio: mixer, music, effects, event → sound table), i18n.text (game texts in the player’s language: translation tables in the translations collection, ICU plurals) ui.theme (the design of every interface as kit config: colours, font, shapes, key glyphs; unset = built-in look) and input.virtualpad (the on-screen gamepad of touch screens, mode auto: shown on a touch screen without a gamepad, Touch controls tab), addedBy default: keep and configure them, leave one out ({ "ui.menu": false }) or remove it only if the user asks; get returns the full ProjectDetail (installed kits, bindings, capabilities, missing, playUrl); bind maps asset slots to Library items (lib_…, null to clear); data reads a collection or upserts / removes documents validated against the kits’ schemas (replace=true swaps the whole collection); controls reads or sets the controls: without scheme it returns the control schemes of the catalogue (fps, third_person, moba_click, top_down, platformer, fighting, vehicle, rts, point_click: movement by keys or by click, what the mouse is for, expected actions and keys, touch layout), the project scheme, the one its kits suggest, every action with its effective key and the keys two actions share in one mode (warnings with a proposed reassignment, never a refusal — also in get → report.controls); with scheme and dry_run=true it returns the diff of keys the scheme would write; with scheme alone it applies it (input.actions overrides marked source: scheme, the touch layout of input.virtualpad, the help bar of ui.prompts). A scheme is a starting proposal: a preset applies its own at creation (create takes controls to pick another one, or none), and any key stays changeable with gripforge_gamekit_configure input.actions { overrides: [{ action, keys, buttons?, disabled? }] } — the action of any kit; those entries always win over the scheme. delete needs confirm=true. Call this first to create or pick the project, then gripforge_gamekit_installed (existing project) or gripforge_gamekit_install. 0 credits.', {
|
|
314
|
+
action: schema.enum(['list', 'create', 'get', 'bind', 'data', 'controls', 'delete']).describe('list | create | get | bind | data | controls | delete.'),
|
|
315
|
+
project_id: projectId.optional().describe('Game Kit project id (gkp_…). Required for get, bind, data, controls and delete.'),
|
|
257
316
|
name: schema.string().min(1).max(120).optional().describe('create: project name.'),
|
|
258
317
|
preset: schema.string().max(80).optional().describe('create: preset id whose default kits are installed (gripforge_gamekit_search returns presets).'),
|
|
259
318
|
kits: schema.record(schema.string(), schema.boolean()).optional().describe('create: explicit kit toggles { "vehicle.driveable": true, … } on top of the preset. Unchecking a required kit fails with toggle_requires. The default kits (ui.menu, input.remap, settings.graphics, ui.prompts, ui.endscreen, save.persistence, audio.core, i18n.text, ui.theme, input.virtualpad) are included unless set to false here — only when the user asked for it.'),
|
|
260
319
|
target: schema.enum(['web', 'godot', 'unity', 'unreal']).optional().describe('create: pass the confirmed engine explicitly (Three.js=web). Ask the user to choose Unity / Godot / Unreal Engine / Three.js before creating if unknown. API default web is for compatibility, not an engine choice.'),
|
|
261
|
-
bindings: schema.record(schema.string(), schema.string().nullable()).optional().describe('bind: { slot: lib_… | null } — Library item per asset slot declared by the installed kits, null clears.'),
|
|
320
|
+
bindings: schema.record(schema.string(), schema.string().nullable()).optional().describe('bind: { slot: lib_… | null } — Library item per asset slot declared by the installed kits, null clears. A model bound to a creature slot gets its animations by itself as far as that is free (the answer carries the chain in creatures); see gripforge_gamekit_creatures.'),
|
|
262
321
|
collection: schema.string().max(80).optional().describe('data: collection name declared by an installed kit (e.g. missions, npcs, zones).'),
|
|
263
322
|
documents: schema.array(schema.record(schema.string(), schema.unknown())).max(500).optional().describe('data: documents to upsert (each with its id), validated against the kit schema.'),
|
|
264
323
|
remove: schema.array(schema.string().max(120)).max(500).optional().describe('data: document ids to remove.'),
|
|
265
324
|
replace: schema.boolean().optional().describe('data: true to replace the whole collection with documents.'),
|
|
325
|
+
scheme: schema.string().max(40).optional().describe('controls: the control scheme to show (dry_run) or apply — fps, third_person, moba_click, top_down, platformer, fighting, vehicle, rts, point_click. Omit to read the schemes and the project controls.'),
|
|
326
|
+
dry_run: schema.boolean().optional().describe('controls: true returns the diff of keys without writing.'),
|
|
327
|
+
force: schema.boolean().optional().describe('controls: also replace a touch layout or a help bar the game edited (kept by default).'),
|
|
328
|
+
reset: schema.boolean().optional().describe('controls: also drop the key overrides the game wrote itself (kept by default), so the game ends exactly on the scheme — what a game made before the schemes needs to move to one. Preview with dry_run.'),
|
|
329
|
+
controls: schema.string().max(40).optional().describe('create: control scheme applied at creation instead of the preset’s own (or of the one the kits suggest); none keeps the kits’ own keys.'),
|
|
266
330
|
confirm: schema.boolean().optional().describe('delete: must be true. The project, its revisions and data are removed.'),
|
|
267
331
|
}, { readOnly: false }, (args, extra) => {
|
|
268
332
|
const action = String(args.action);
|
|
@@ -271,13 +335,14 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
271
335
|
if (action === 'create') {
|
|
272
336
|
if (typeof args.name !== 'string' || !args.name.trim())
|
|
273
337
|
return Promise.resolve(fail('create needs name.'));
|
|
274
|
-
return api('gamekit-projects', body(args, 'bindings', 'collection', 'documents', 'remove', 'replace'), 'POST', extra?.signal);
|
|
338
|
+
return api('gamekit-projects', body(args, 'bindings', 'collection', 'documents', 'remove', 'replace', 'scheme', 'dry_run', 'force', 'reset'), 'POST', extra?.signal);
|
|
275
339
|
}
|
|
276
340
|
if (typeof args.project_id !== 'string')
|
|
277
341
|
return Promise.resolve(fail(`${action} needs project_id (gkp_…).`));
|
|
278
342
|
const project = enc(args.project_id);
|
|
343
|
+
// creatures=1: the answer carries ranked models for every creature slot that would draw a capsule.
|
|
279
344
|
if (action === 'get')
|
|
280
|
-
return api(`gamekit-projects/${project}`, args, 'GET', extra?.signal);
|
|
345
|
+
return api(`gamekit-projects/${project}`, args, 'GET', extra?.signal, { creatures: 1 });
|
|
281
346
|
if (action === 'bind') {
|
|
282
347
|
if (!args.bindings || typeof args.bindings !== 'object')
|
|
283
348
|
return Promise.resolve(fail('bind needs bindings { slot: lib_… | null }.'));
|
|
@@ -291,6 +356,18 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
291
356
|
return api(`gamekit-projects/${project}/data`, args, 'GET', extra?.signal, { collection: args.collection });
|
|
292
357
|
return api(`gamekit-projects/${project}/data`, { workspace_id: args.workspace_id, collection: args.collection, upsert: args.documents, remove: args.remove, replace: args.replace }, 'PATCH', extra?.signal);
|
|
293
358
|
}
|
|
359
|
+
if (action === 'controls') {
|
|
360
|
+
if (typeof args.scheme === 'string' && args.scheme) {
|
|
361
|
+
return api(`gamekit-projects/${project}/controls`, { workspace_id: args.workspace_id, scheme: args.scheme, dry_run: args.dry_run, force: args.force, reset: args.reset }, 'POST', extra?.signal);
|
|
362
|
+
}
|
|
363
|
+
return (async () => {
|
|
364
|
+
const [schemes, current] = await Promise.all([api('gamekits/control-schemes', args, 'GET', extra?.signal), api(`gamekit-projects/${project}/controls`, args, 'GET', extra?.signal)]);
|
|
365
|
+
if (current.isError)
|
|
366
|
+
return current;
|
|
367
|
+
const data = { ...current.structuredContent, schemes: schemes.structuredContent?.schemes ?? [] };
|
|
368
|
+
return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
369
|
+
})();
|
|
370
|
+
}
|
|
294
371
|
if (action === 'delete') {
|
|
295
372
|
if (args.confirm !== true)
|
|
296
373
|
return Promise.resolve(fail('delete needs confirm=true.'));
|
|
@@ -298,6 +375,22 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
298
375
|
}
|
|
299
376
|
return Promise.resolve(fail(`Unknown action ${action}.`));
|
|
300
377
|
});
|
|
378
|
+
tool('gripforge_gamekit_creatures', 'Creatures of a project: a model and its animations for every unit', 'The living units of a Game Kit project (gkp_…) — player, heroes, enemies, bosses, minions, monsters, NPCs: the creature slots its kits declare. Two rules: NO creature ships as a capsule, and a model ships WITH its animations (movement AND action). action=report (default): every creature slot with its status (bound | fallback = a capsule in game), the model bound, the clips the slot plays (locomotion, action), the ones the model lacks, and its chain (steps: clone | rig | animate | more_clips, each done / running / proposed with its tool, free or its credits). action=propose: ranked Library models for each unbound slot — the workspace first, then the Community — with the reasons of the rank (source, slot and role words, game theme, size, rigged, animated, triangle budget). YOU judge: read the reasons, pick the model that fits the game (any monster beats a capsule), bind it with gripforge_game_project { action: "bind" }; nothing is final, rebind any time. A slot with no proposal at all carries defect creature_no_model: generate a model (paid — quote and ask first), never leave the capsule. action=animate: run the free steps of the chain now for the bound models (a free Community model is copied into the workspace and bound in place of the original; the standard clip pack is retargeted, 0 credits; wait=true waits for the result, else it runs in the background — read report again). action=fill: bind the best free proposal to every unbound creature slot, then animate (what the hosted creation does). Nothing here spends credits: the automatic rig, a priced Community asset and generated motions always come back as proposed steps for you to quote and ask about. Binding a model through gripforge_game_project already starts its chain. 0 credits.', {
|
|
379
|
+
project_id: projectId,
|
|
380
|
+
action: schema.enum(['report', 'propose', 'animate', 'fill']).optional().describe('report (default) | propose | animate | fill.'),
|
|
381
|
+
slot: schema.string().max(80).optional().describe('report / propose: only this creature slot (e.g. enemy_grunt, moba_hero_3).'),
|
|
382
|
+
slots: schema.array(schema.string().max(120)).max(64).optional().describe('animate: only these slots (slot or slot:variant). Default: every bound creature slot.'),
|
|
383
|
+
limit: schema.number().int().min(1).max(12).optional().describe('propose: models per slot (default 5).'),
|
|
384
|
+
wait: schema.boolean().optional().describe('animate / fill: wait for the retargets instead of running them in the background.'),
|
|
385
|
+
brief: schema.string().max(2000).optional().describe('fill: what the game is about, to rank the models (theme, mood).'),
|
|
386
|
+
}, { readOnly: false }, (args, extra) => {
|
|
387
|
+
const action = typeof args.action === 'string' ? args.action : 'report';
|
|
388
|
+
const path = `gamekit-projects/${enc(args.project_id)}/creatures`;
|
|
389
|
+
if (action === 'report' || action === 'propose') {
|
|
390
|
+
return api(path, args, 'GET', extra?.signal, { propose: action === 'propose' ? 1 : undefined, slot: typeof args.slot === 'string' ? args.slot : undefined, limit: typeof args.limit === 'number' ? args.limit : undefined });
|
|
391
|
+
}
|
|
392
|
+
return api(path, { workspace_id: args.workspace_id, action, slots: args.slots, wait: args.wait, brief: args.brief }, 'POST', extra?.signal);
|
|
393
|
+
});
|
|
301
394
|
tool('gripforge_game_engine', 'The engine of a game: structure, bricks, report, next steps', 'Read game.engine of a Game Kit project (gkp_…) — a direction, never a gate. action=start (default): the start pack { version, active, structure { engine, kits, content counts, code }, scene, blocks digest, order (scene → playable → assets → archetypes → story → scripts → ui → validate → deliver → code), next [{ step, tool, args, why }], code (where game code goes per engine), rules } and the full content report. action=blocks: the brick catalogue computed from the manifests — entity kinds, archetype components (kit, schema, example), script verbs (conditions / actions with arguments and shorthand), script sugars (interact, enter, leave, scene, talked, quest, state, gives), quest step types, NPC behaviours, worlds, layers, events, variables; scope=catalog adds the bricks of kits not installed (install <kit>). action=report: { ok, errors, warnings, stats } — an error means that piece is left out of the game (the rest plays), a warning that it loads with something missing; each issue may carry a fix { tool, args }. action=next: the next steps only. A game with everything in code is valid: content first, kits second, code for the rest. 0 credits.', {
|
|
302
395
|
project_id: projectId,
|
|
303
396
|
action: schema.enum(['start', 'structure', 'blocks', 'report', 'next']).optional().describe('start (default) | structure | blocks | report | next.'),
|
|
@@ -367,6 +460,14 @@ export function registerGameKitTools(register, options, schema = z) {
|
|
|
367
460
|
replay: schema.record(schema.string(), schema.unknown()).optional().describe('report.replay of an earlier run: plays the same inputs again (seed and budget come from it).'),
|
|
368
461
|
lang: schema.enum(['en', 'fr']).optional().describe('Language of text (default en).'),
|
|
369
462
|
}, { readOnly: true }, (args, extra) => api(`gamekit-projects/${enc(args.project_id)}/playtest`, body(args), 'POST', extra?.signal));
|
|
463
|
+
tool('gripforge_game_audit', 'Audit a game: what is left before it is finished', 'One report of everything that stands between a Game Kit project (gkp_…) and a FINISHED game of its genre — run it before telling the user a game is done, and again after each round of fixes. It aggregates, by severity (blocker / major / minor): creatures (slots drawn as a capsule, models without a skeleton, missing movement or action clips, the animation chain in progress), controls (no control scheme, the suggested one, keys shared by two actions in one mode, playable actions without a key), content (missing capabilities, required slots, the game.engine report), the last headless playtest (failures, warnings, the keys / models / animations probes, capabilities no probe covers) and completeness: what a finished game of the genre has beyond the literal request (MOBA: distinct heroes with Q/W/E/R, minions, towers, jungle monsters, shop, respawn; FPS: weapon, ammo, reload, crosshair, enemies; platformer: checkpoints, collectibles, end of level; racing: laps, ranking, AI opponents… and for every game a way to win AND to lose, title / pause / end screens, sound, a HUD, touch controls), checked against facts of the project, never a declaration. Each defect carries fix { tool, args, note, auto?, credits? }: call it as given. Returns { verdict: complete | playable_with_defects | incomplete, headline, genre, counts, defects, next (ordered calls), sections, text, play_url }. fix=true first applies what is free and safe (the best free Library model on each capsule — rebind at will —, the free standard clips, the suggested control scheme) and reports it in fixes. playtest="run" plays the game headless first (5–20 s); the default reads the last playtest and says when it is stale. Never refuses a write and never spends: paid fixes (a priced model, a rig, a generation) come back with their price to ask the user about. Aim for verdict complete; if you stop before, tell the user exactly what is left. 0 credits.', {
|
|
464
|
+
project_id: projectId,
|
|
465
|
+
fix: schema.boolean().optional().describe('true: apply the free and safe fixes first (free models on capsules, standard clips, suggested control scheme), then read. Nothing paid is ever done.'),
|
|
466
|
+
playtest: schema.enum(['last', 'run']).optional().describe('last (default): read the stored playtest of the project. run: play it headless now, then read.'),
|
|
467
|
+
brief: schema.string().max(2000).optional().describe('Free words about the game (theme, setting): they weigh in the model proposals.'),
|
|
468
|
+
}, { readOnly: false }, (args, extra) => (args.fix === true || args.playtest === 'run'
|
|
469
|
+
? api(`gamekit-projects/${enc(args.project_id)}/audit`, body(args), 'POST', extra?.signal, undefined, 280_000)
|
|
470
|
+
: api(`gamekit-projects/${enc(args.project_id)}/audit`, { workspace_id: args.workspace_id }, 'GET', extra?.signal, { brief: typeof args.brief === 'string' ? args.brief : undefined })));
|
|
370
471
|
tool('gripforge_moba_roster', 'Set the champions of a MOBA project', 'Turn champions into the playable roster of a moba project (preset "moba"). Pass the Library ids of 1 to 10 champions that carry an ability pack (the items made by gripforge_abilities_generate: rigged model + basic_attack and ability_q…r clips). Writes the moba_heroes and abilities collections (ids <hero>__ability_q), binds each champion to moba_hero_<n> and the player\'s champion (player, else the first) to player_character. Bots play the other champions. Returns the heroes with their attack and Q/W/E/R. Then gripforge_game_play_url to play. 0 credits.', {
|
|
371
472
|
project_id: projectId,
|
|
372
473
|
heroes: schema.array(schema.string()).min(1).max(10).describe('Library ids (lib_…) of champions with an ability pack, in roster order.'),
|
package/dist/scene-tools.js
CHANGED
|
@@ -123,6 +123,8 @@ export function registerSceneTools(register, options, schema = z) {
|
|
|
123
123
|
tool('gripforge_scene_proposal_apply', 'Apply a reviewed proposal to work', 'Explicitly accept or discard a proposal after inspecting it. Accept requires expectedRevision to match the exact base and current work revision. Atomic: concurrent changes or discard abort the save. Creates work, never promotes validated.', { id: identifier, proposal: identifier, action: schema.enum(['accept', 'discard']), expectedRevision: revision.optional() }, false, (args, extra) => call(`scenes/${args.id}/proposals/${args.proposal}`, args, 'POST', extra?.signal));
|
|
124
124
|
tool('gripforge_scene_schema', 'Shared scene engine format', 'Start here. Read the shared scene model, kinds, commands, limits and an editable example. Assets are immutable revisions; placed objects are independent instances. Scene saves create work revisions, never automatic visual approval.', {}, true, (args, extra) => call('scenes/schema', args, 'GET', extra?.signal));
|
|
125
125
|
tool('gripforge_scene_realism', 'Shared materials, automotive lighting and post-processing', 'Apply reusable architectural surface profiles and GTAO to a saved work scene. Read gripforge_scene_schema.realism first. assignments [{nodeId,material:"*" or a named slot,surface:{preset:preserve|brick|stone|metal|glass,detail:0..1,weathering:0..1,regions:[]}}]; surface:null removes only this local profile. Optional glass regions use explicit normalized mesh-local min/max bounds and interior parallax settings. Never guess masks from colours. ambientOcclusion {enabled,radius:0.02..3 metres,intensity:0..1,quality:low|medium|high}, or null to remove. Preserves transforms, source PBR maps, other instances and validated revisions. Returns Studio link for visual comparison. Optional quality {preset:performance|balanced|quality,resolutionScale:0.5..1,sharpness:0..0.5}, or null to restore the original presentation. Quality supports viewport resolution up to a bounded 4K budget. Optional automotive {look:studio|daylight,quality,lengthM,depthOfField:false,focusDistanceM} explicitly applies a whole-scene look: HDR strip reflections, contact AO, quality and restrained post-processing. Studio replaces an existing HDRI; daylight keeps it. Optional colorGrading/depthOfField overrides or null. DOF is perspective/capture only, disabled with volumetric passes. No generation charge, DLSS or automatic approval.', { id: identifier, expectedRevision: revision, assignments: schema.array(schema.object({ nodeId: identifier, material: schema.string().min(1).max(256).optional(), surface: record.nullable() })).max(100).optional(), ambientOcclusion: schema.object({ enabled: schema.boolean(), radius: schema.number().min(.02).max(3), intensity: schema.number().min(0).max(1), quality: schema.enum(['low', 'medium', 'high']) }).nullable().optional(), quality: schema.object({ preset: schema.enum(['performance', 'balanced', 'quality']), resolutionScale: schema.number().min(.5).max(1).optional(), sharpness: schema.number().min(0).max(.5).optional() }).nullable().optional(), automotive: schema.object({ look: schema.enum(['studio', 'daylight']).optional(), quality: schema.enum(['performance', 'balanced', 'quality']).optional(), lengthM: schema.number().min(1).max(25).optional(), ground: schema.boolean().optional(), depthOfField: schema.boolean().optional(), focusDistanceM: schema.number().min(.1).max(1000).optional() }).optional(), colorGrading: schema.object({ contrast: schema.number().min(.5).max(1.5), saturation: schema.number().min(0).max(1.5), temperature: schema.number().min(-1).max(1) }).nullable().optional(), depthOfField: schema.object({ enabled: schema.boolean(), focusDistanceM: schema.number().min(.1).max(1000), aperture: schema.number().min(0).max(.01), maxBlur: schema.number().min(0).max(.02) }).nullable().optional() }, false, (args, extra) => call(`scenes/${args.id}/realism`, args, 'POST', extra?.signal));
|
|
126
|
+
tool('gripforge_apply_realism', 'Orchestrate scene realism', 'Plan or apply render.realism across shared PBR materials, immutable textures, surface shaders, lighting, post-processing, environment and skinned character rendering, with a bounded render quality and render.lod configuration suggestion. Defaults to mode=plan (no save); mode=apply writes one work revision atomically, preserving assets, transforms, animation and validated versions. Explicit nodeIds restrict instance assignments; lighting/post-processing are global. materials/characters rows use {nodeId,material:"*"|exactName,override:{roughness,metalness,map,normalMap,surface,...}}; pinned map references, null removes overrides. Characters require character/enemy/boss/fps-arms role. Does not fabricate missing detail, execute paid generation, add game LOD automatically, or approve realism visually. Request a real scene_review afterwards. WEB only; not DLSS.', { id: identifier, expectedRevision: revision, mode: schema.enum(['plan', 'apply']).default('plan'), quality: schema.enum(['performance', 'balanced', 'quality']).optional(), lighting: schema.enum(['preserve', 'daylight', 'golden_hour', 'overcast', 'night']).optional(), nodeIds: schema.array(identifier).min(1).max(100).optional(), materials: schema.array(schema.object({ nodeId: identifier, material: schema.string().min(1).max(256).optional(), override: record })).max(100).optional(), characters: schema.array(schema.object({ nodeId: identifier, material: schema.string().min(1).max(256).optional(), override: record })).max(100).optional(), environment: record.optional(), postProcess: record.optional() }, false, (args, extra) => call(`scenes/${args.id}/render-realism`, args, 'POST', extra?.signal));
|
|
127
|
+
tool('gripforge_realism_schema', 'Read realism orchestration contract', 'Read render.realism systems, modes, bounds and immutable-source workflow for an accessible scene.', { id: identifier }, true, (args, extra) => call(`scenes/${args.id}/render-realism`, args, 'GET', extra?.signal));
|
|
126
128
|
tool('gripforge_scene_optimize', 'Prepare a scene for fast browser loading', 'Queue resumable browser texture variants for a saved scene. Preserve original GLBs, geometry, transforms, PBR, animations and Library heads. Color textures up to 1024px, data maps up to 512px and terrain color up to 4096px; WebP encoding. Saves a new work revision only if expectedRevision still matches. Does not promote validated. Poll generation_read for byte savings and studio_url. 0 credits; storage quota applies. New Unreal imports do this automatically.', { id: identifier, expectedRevision: revision, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scenes/${args.id}/optimize`, args, 'POST', extra?.signal));
|
|
127
129
|
tool('gripforge_scene_list', 'List workspace scenes', 'List scenes, work/validated revision pointers and links in the authenticated workspace.', {}, true, (args, extra) => call('scenes', args, 'GET', extra?.signal));
|
|
128
130
|
tool('gripforge_scene_read', 'Read scene source', 'Read the editable SceneDocument at work, validated, or a numbered revision. Keep work_revision for optimistic writes. Validated can be absent.', { id: identifier, version: schema.union([schema.enum(['work', 'validated']), schema.number().int().positive()]).optional() }, true, (args, extra) => call(`scenes/${encodeURIComponent(String(args.id))}?version=${args.version ?? 'work'}`, args, 'GET', extra?.signal));
|
|
@@ -136,7 +138,9 @@ export function registerSceneTools(register, options, schema = z) {
|
|
|
136
138
|
tool('gripforge_scene_asset_versions', 'Read asset version and review', 'Read immutable asset work/current pointers and the trusted review of work. Does not create a version.', { assetId: identifier }, true, (args, extra) => call(`scene-assets/${args.assetId}/versions`, args, 'GET', extra?.signal));
|
|
137
139
|
tool('gripforge_scene_asset_review', 'Review an immutable asset', 'Create a shared test scene and queue an independent review of real GripForge captures. Supports meshes, textures and effects. Never promotes automatically.', { assetId: identifier, revisionId: identifier, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/review`, args, 'POST', extra?.signal));
|
|
138
140
|
tool('gripforge_scene_asset_promote', 'Set the visually approved asset version', 'Promote only when the exact work revision has a matching trusted review for the current renderer. Existing scene instances keep their pinned revision.', { assetId: identifier, revisionId: identifier, expectedCurrent: identifier.nullable() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/promote`, args, 'POST', extra?.signal));
|
|
139
|
-
tool('
|
|
141
|
+
tool('gripforge_fp_hands_schema', 'First-person hand manufacturing contract', 'Read the native FPS hand template, recipe defaults, limits and reuse workflow. Geometry is manufactured from an articulated FPS template, independently of full-body character generation.', {}, true, (args, extra) => call('fp-hands', args, 'GET', extra?.signal));
|
|
142
|
+
tool('gripforge_generate_fp_hands', 'Generate dedicated first-person hands', 'Manufacture a NEW fps-arms work asset using the native FPS template: curved hand surface refinement, fitted skinned long sleeves/cuff, optional tactical glove reinforcement and embedded PBR. recipe={version:1,name,quality:high|standard,gloves:tactical|light,sleeves:long|none,colors?:{fabric:#RRGGBB,sleeve:#RRGGBB,trim:#RRGGBB},source?:{assetId,revisionId,fileRole?}}. No source gives standalone hands with native finger rig, hand_item_r/l sockets and existing starting clips. Optional owned immutable native paired source keeps weapon, camera and animation tracks; source must contain FPS_Hands or FPS_Arms_Mesh, all ten native fingers, embedded GLB <=32 MiB. This is template geometry/garment manufacture, not prompt-to-new-anatomy or arbitrary rigging. Returns job_id; poll generation_read. Inspect fingers, seams and weapon/garment contact in the actual renderer before binding. No external provider charge; never auto-promotes or overwrites.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fp-hands', args, 'POST', extra?.signal));
|
|
143
|
+
tool('gripforge_fps_animation', 'Author first-person weapon rigs and materials', 'Queue a persistent FPS work asset. Pistol recipe: {version:1,preset:"pistol",name:"Pistol FPS",tempo:1}, tempo 0.7–1.3; manufactures paired arms and articulated pistol clips plus .blend in Blender. Materials recipe: {version:1,preset:"materials",name:"Tactical gloves",source:{assetId,revisionId},node:"FPS_Hands",materials:{map:{assetId,revisionId},normalMap?:{assetId,revisionId},roughnessMap?:{assetId,revisionId},normalScale:0.35,roughness:1,metalness:0}}. Upload and pin owned textures first; they must match source UVs. Select one exact skinned node; other nodes, geometry, fingers and clips stay intact. Normal is OpenGL +Y; grayscale roughness reads G and requires metalness=0 (skin/fabric). Sources <=32 MB, images PNG/JPEG/WebP <=16 MB and 4096 pixels per side. Materials authors a reusable GLB variant, not new hand anatomy or a new .blend; no provider charge. Returns job_id: poll gripforge_generation_read for private Library ref, report and FPS Studio link. Never automatically validates or promotes. Cancel/retry supported; completed steps survive closing the page.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fps-animations', args, 'POST', extra?.signal));
|
|
140
144
|
tool('gripforge_generation_list', 'List persistent generation jobs', 'List workspace jobs. Jobs and completed steps survive closing the studio or restarting the web server and worker.', {}, true, (args, extra) => call('generation-jobs', args, 'GET', extra?.signal));
|
|
141
145
|
tool('gripforge_generation_read', 'Read generation progress and result', 'Poll a job returned by a generation tool. Includes durable steps, progress, failure/retry state and the resulting work revision with a Studio link. include_preview returns saved map/building concept images (at most 4). Set preview_view to return one chosen view. Concepts are generated illustrations, not verified 3D renders. Success does not mean visual approval or promotion.', { id: identifier, include_preview: schema.boolean().optional(), preview_view: schema.enum(['overview', 'top_down', 'entrance', 'objective', 'front_right', 'front_left', 'rear_right', 'rear_left']).optional() }, true, (args, extra) => call(`generation-jobs/${args.id}`, args, 'GET', extra?.signal));
|
|
142
146
|
tool('gripforge_generation_cancel', 'Cancel generation', 'Request worker cancellation while retaining completed steps and artifacts. Closing a page alone does not cancel a job.', { id: identifier }, false, (args, extra) => call(`generation-jobs/${args.id}`, { ...args, action: 'cancel' }, 'POST', extra?.signal));
|
package/dist/server.js
CHANGED
|
@@ -32,6 +32,8 @@ import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
|
|
|
32
32
|
import { registerMapUnrealLocalTools } from './map-unreal-local.js';
|
|
33
33
|
import { registerMapUnrealImportLocalTools } from './map-unreal-import-local.js';
|
|
34
34
|
import { registerMapUnrealExportLocalTools } from './map-unreal-export-local.js';
|
|
35
|
+
import { registerReverseTools } from './game-import-reverse.js';
|
|
36
|
+
import { registerGameImportTools } from './game-import-tools.js';
|
|
35
37
|
const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
|
|
36
38
|
const API_KEY = process.env.GRIPFORGE_API_KEY;
|
|
37
39
|
const MCP_SELF = '0.1.9';
|
|
@@ -45,6 +47,8 @@ registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, g
|
|
|
45
47
|
registerMapUnrealLocalTools(server.registerTool.bind(server));
|
|
46
48
|
registerMapUnrealImportLocalTools(server.registerTool.bind(server));
|
|
47
49
|
registerMapUnrealExportLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
50
|
+
registerReverseTools(server.registerTool.bind(server));
|
|
51
|
+
registerGameImportTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
48
52
|
registerJoystickTools(server.registerTool.bind(server));
|
|
49
53
|
registerAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
50
54
|
registerCreatureRigTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
|
|
@@ -1095,7 +1099,7 @@ server.tool('gripforge_hand_check', 'Verify that a rigged character\'s fingers f
|
|
|
1095
1099
|
return err(String(data.error ?? res.statusText));
|
|
1096
1100
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1097
1101
|
});
|
|
1098
|
-
server.tool('gripforge_animate', 'Retarget the standard clip pack onto a Mixamo-named Library character/enemy/armed bind. Base slots (idle, walk, run, attack1-3, hit, death, dodge) + extras (taunt, stinger, knockback, block, jump_start/loop/land, idle_guns, shoot, reload, aim, crouch_idle, crouch_walk, knife). archetype remaps the base slots: sword (default), claws (zombie scratch), heavy (slow + heavy combo), brawler (punches), puppet (stiff walk + throws), operator (CS-style: knife/punch strikes). Opt-in slots fire_breath (mouth exhalation) and energy_cast (two-hand azure wave) use the Studio VFX character motions. Saves kind=animation. 0 credits.', {
|
|
1102
|
+
server.tool('gripforge_animate', 'Retarget the standard clip pack onto a Mixamo-named Library character/enemy/armed bind. In a Game Kit project gripforge_gamekit_creatures runs it for free on every bound creature, and gripforge_game_audit lists the models still lacking movement or action clips. Base slots (idle, walk, run, attack1-3, hit, death, dodge) + extras (taunt, stinger, knockback, block, jump_start/loop/land, idle_guns, shoot, reload, aim, crouch_idle, crouch_walk, knife). archetype remaps the base slots: sword (default), claws (zombie scratch), heavy (slow + heavy combo), brawler (punches), puppet (stiff walk + throws), operator (CS-style: knife/punch strikes). Opt-in slots fire_breath (mouth exhalation) and energy_cast (two-hand azure wave) use the Studio VFX character motions. Saves kind=animation. 0 credits.', {
|
|
1099
1103
|
character_id: z.string().describe('Library character, enemy, or armed bind'),
|
|
1100
1104
|
archetype: z.enum(['sword', 'claws', 'heavy', 'brawler', 'puppet', 'operator']).optional().describe('Movement/strike archetype — remaps the base slots (default sword); operator = CS-style (knife/punch strikes, guns via idle_guns/shoot/reload/aim, crouch_idle/crouch_walk)'),
|
|
1101
1105
|
slots: z
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gripforgeai/mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "The tools your AI needs to make your games. Turn prompts into production-ready game assets
|
|
3
|
+
"version": "0.1.17",
|
|
4
|
+
"description": "The tools your AI needs to make your games. Turn prompts into production-ready game assets \u2014 animated characters with their weapons attached, seamless textures, terrain, VFX, HUDs \u2014 and playable game kits Unity, Godot, Unreal or Three.js can load.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"runtime/unreal-scene-upload.mjs",
|
|
15
15
|
"runtime/unreal_scene_math.py",
|
|
16
16
|
"runtime/unreal_spawn_review.py",
|
|
17
|
-
"README.md"
|
|
17
|
+
"README.md",
|
|
18
|
+
"runtime/game-import-normalize.py"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Blender manufacture worker. No source scripts, handlers or .blend files are loaded."""
|
|
2
|
+
import bpy
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
plan = json.loads(Path(sys.argv[sys.argv.index('--') + 1]).read_text())
|
|
8
|
+
source, output = plan['input'], plan['output']
|
|
9
|
+
kind = Path(source).suffix.lower()
|
|
10
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
11
|
+
bpy.ops.object.delete(use_global=False)
|
|
12
|
+
if kind in ('.dds', '.tga'):
|
|
13
|
+
image = bpy.data.images.load(source, check_existing=False)
|
|
14
|
+
if not len(image.pixels):
|
|
15
|
+
raise ValueError('Image decoder returned no pixels')
|
|
16
|
+
image.pack() # Resolve lazy pixels before changing the source filepath.
|
|
17
|
+
if not image.has_data:
|
|
18
|
+
raise ValueError('Image decoder returned no pixels')
|
|
19
|
+
image.filepath_raw = output
|
|
20
|
+
image.file_format = 'PNG'
|
|
21
|
+
image.save()
|
|
22
|
+
else:
|
|
23
|
+
if kind == '.fbx':
|
|
24
|
+
bpy.ops.import_scene.fbx(filepath=source, use_image_search=False)
|
|
25
|
+
elif kind == '.obj':
|
|
26
|
+
bpy.ops.wm.obj_import(filepath=source)
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError('Unsupported source format')
|
|
29
|
+
if not any(o.type == 'MESH' for o in bpy.context.scene.objects):
|
|
30
|
+
raise ValueError('No mesh imported')
|
|
31
|
+
# Pack only staged images. Missing textures remain an explicit conversion failure.
|
|
32
|
+
stage = Path(source).parent.resolve()
|
|
33
|
+
for image in bpy.data.images:
|
|
34
|
+
if image.source == 'FILE' and not image.packed_file:
|
|
35
|
+
path = Path(bpy.path.abspath(image.filepath)).resolve()
|
|
36
|
+
if not path.is_relative_to(stage) or not path.is_file():
|
|
37
|
+
raise ValueError('Missing or external material texture: ' + image.name)
|
|
38
|
+
image.pack()
|
|
39
|
+
bpy.ops.export_scene.gltf(filepath=output, export_format='GLB', export_animations=True)
|
|
40
|
+
Path(plan['receipt']).write_text(json.dumps({'version': bpy.app.version_string}))
|