@gripforgeai/mcp 0.1.10 → 0.1.12
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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/ability-tools.js +21 -0
- package/dist/architecture-tools.js +83 -0
- package/dist/asset-production-tools.js +101 -0
- package/dist/creature-rig-tools.js +41 -0
- package/dist/game-concept-tools.js +56 -0
- package/dist/game-creation-tools.js +45 -0
- package/dist/gamekit-deliver-local.js +5 -7
- package/dist/gamekit-tools.js +133 -24
- package/dist/hero-ability-tools.js +48 -0
- package/dist/joystick-kit/godot.js +146 -0
- package/dist/joystick-kit/index.js +32 -0
- package/dist/joystick-kit/threejs.js +93 -0
- package/dist/joystick-kit/unity.js +182 -0
- package/dist/joystick-kit/unreal.js +231 -0
- package/dist/joystick-tools.js +26 -0
- package/dist/look.js +6 -0
- package/dist/map-unreal-export-local.js +285 -0
- package/dist/map-unreal-import-local.js +421 -0
- package/dist/map-unreal-local.js +75 -0
- package/dist/motion-tools.js +50 -0
- package/dist/projectile-tools.js +28 -0
- package/dist/scene-tools.js +79 -13
- package/dist/server-tools.js +3 -3
- package/dist/server.js +139 -13
- package/dist/shield-vfx-tools.js +29 -0
- package/dist/studio-tools-tools.js +106 -0
- package/dist/unreal-export-package.js +212 -0
- package/dist/vfx-project-tools.js +2 -1
- package/dist/weapon-motion-tools.js +34 -0
- package/package.json +6 -1
- package/runtime/unreal-scene-export.py +341 -0
- package/runtime/unreal-scene-import.py +622 -0
- package/runtime/unreal-scene-upload.mjs +7 -0
- package/runtime/unreal_scene_math.py +96 -0
- package/runtime/unreal_spawn_review.py +135 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
/**
|
|
3
|
+
* Les outils MCP des outils chaînés (studios → outils → étapes), enregistrés par le MCP hébergé
|
|
4
|
+
* (`apps/web/src/lib/mcp-server.ts`) et par le client npm (`server.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Même contrat que la library : tout passe par `/api/v1/tools*`, ouvert aux membres du workspace de
|
|
7
|
+
* la clé (lecture pour tous, lancer et corriger pour ceux qui écrivent dans le casier). Quand une
|
|
8
|
+
* chaîne s'arrête, la réponse porte l'étape, la phrase qui dit quoi
|
|
9
|
+
* faire et l'URL où le faire — on ne décrit pas le problème, on donne l'endroit où le régler.
|
|
10
|
+
*/
|
|
11
|
+
export const STUDIO_TOOLS_TOOL_NAMES = ['gripforge_studios', 'gripforge_tool_run', 'gripforge_tool_runs', 'gripforge_mesh_passport'];
|
|
12
|
+
export function registerStudioToolsTools(register, options, schema = z) {
|
|
13
|
+
const libId = schema.string().regex(/^lib_[A-Za-z0-9_-]{8,64}$/);
|
|
14
|
+
const ws = schema.string().max(100).optional().describe('Workspace id (defaults to the key’s workspace).');
|
|
15
|
+
const shapes = {
|
|
16
|
+
studios: { item_id: libId.optional().describe('A locker item: the answer says which tools are possible on it right now.'), workspace_id: ws },
|
|
17
|
+
run: {
|
|
18
|
+
tool: schema.string().min(1).max(40).describe('Tool id from gripforge_studios (passport, rig, mannequin).'),
|
|
19
|
+
item_id: libId.optional().describe('Locker item the chain works on. mannequin without item = the adult canon.'),
|
|
20
|
+
step: schema.string().max(40).optional().describe('Replay ONE step (the one you just corrected) instead of the whole chain.'),
|
|
21
|
+
force: schema.boolean().optional().describe('Recompute fresh blocks too. Default: fresh blocks are skipped.'),
|
|
22
|
+
workspace_id: ws,
|
|
23
|
+
},
|
|
24
|
+
runs: {
|
|
25
|
+
item_id: libId.optional(),
|
|
26
|
+
tool: schema.string().max(40).optional(),
|
|
27
|
+
limit: schema.number().int().min(1).max(100).optional(),
|
|
28
|
+
workspace_id: ws,
|
|
29
|
+
},
|
|
30
|
+
passport: {
|
|
31
|
+
item_id: libId,
|
|
32
|
+
manual: schema.record(schema.string(), schema.unknown()).optional().describe('Partial PassportManual to MERGE (e.g. {"orientation":{"up":[0,1,0],"front":[0,0,1],"toCanonical":[0,0,0,1],"confidence":1,"source":"manual"}}). Omit to read.'),
|
|
33
|
+
clear: schema.string().max(60).optional().describe('Remove one correction (orientation, joints.elbow.L, fits.helm) or "*" for all.'),
|
|
34
|
+
workspace_id: ws,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
async function call(method, path, args, body, signal) {
|
|
38
|
+
const key = options.getApiKey();
|
|
39
|
+
if (!key)
|
|
40
|
+
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
41
|
+
const ws = typeof args.workspace_id === 'string' ? args.workspace_id : null;
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(options.apiUrl.replace(/\/$/, '') + path, {
|
|
44
|
+
method,
|
|
45
|
+
headers: { 'content-type': 'application/json', 'x-gripforge-client': 'mcp', 'x-api-key': key, ...(ws ? { 'x-workspace-id': ws } : {}) },
|
|
46
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
47
|
+
signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(signal ? [signal] : [])]),
|
|
48
|
+
});
|
|
49
|
+
const data = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
|
50
|
+
return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'GripForge tools request failed.' }] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const q = (o) => {
|
|
57
|
+
const p = new URLSearchParams();
|
|
58
|
+
for (const [k, v] of Object.entries(o))
|
|
59
|
+
if (v !== undefined && v !== null && v !== '')
|
|
60
|
+
p.set(k, String(v));
|
|
61
|
+
const s = p.toString();
|
|
62
|
+
return s ? `?${s}` : '';
|
|
63
|
+
};
|
|
64
|
+
const tools = [
|
|
65
|
+
{
|
|
66
|
+
name: 'gripforge_studios', title: 'Studios · tools map', readOnly: true, shape: shapes.studios,
|
|
67
|
+
description: 'The map of GripForge studios and their chained tools (steps, kind code/ai/tool/analysis, manual equivalent of each step). Call it BEFORE guessing a tool name. With item_id, `available` says what is possible on that item now.',
|
|
68
|
+
run: (a, s) => call('GET', `/api/v1/tools${q({ item: a.item_id })}`, a, undefined, s),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'gripforge_tool_run', title: 'Studios · run a chained tool', readOnly: false, shape: shapes.run,
|
|
72
|
+
description: 'Run a chained tool server-side (computation happens in the GripForge compute service). Returns the step-by-step run. status "needs-manual" = the chain stopped on purpose: read manual_hint, fix it (gripforge_mesh_passport) or send the user to `url`, then replay with step=<that step>. mannequin with an item = chain passport → rig → mannequin, a new locker item.',
|
|
73
|
+
run: (a, s) => call('POST', '/api/v1/tools/run', a, { tool: a.tool, id: a.item_id ?? null, step: a.step ?? null, force: a.force === true, source: 'mcp' }, s),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'gripforge_tool_runs', title: 'Studios · run history', readOnly: true, shape: shapes.runs,
|
|
77
|
+
description: 'The shared history of chained-tool runs (web page and agents alike), newest first: which tool, on which item, each step, where it stopped and the URL to resume.',
|
|
78
|
+
run: (a, s) => call('GET', `/api/v1/tools/runs${q({ item: a.item_id, tool: a.tool, limit: a.limit })}`, a, undefined, s),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'gripforge_mesh_passport', title: 'Studios · mesh passport', readOnly: false, shape: shapes.passport,
|
|
82
|
+
description: 'Read or correct the passport of a locker mesh (size, orientation, reference skeleton, colliders). `manual` merges a human correction (it always wins and survives regeneration); `clear` removes one. Measurements themselves come from gripforge_tool_run tool=passport.',
|
|
83
|
+
run: (a, s) => {
|
|
84
|
+
const path = `/api/v1/tools/passport/${a.item_id}`;
|
|
85
|
+
if (typeof a.clear === 'string')
|
|
86
|
+
return call('DELETE', `${path}${a.clear === '*' ? '' : q({ field: a.clear })}`, a, undefined, s);
|
|
87
|
+
if (a.manual)
|
|
88
|
+
return call('PUT', path, a, { manual: a.manual }, s);
|
|
89
|
+
return call('GET', path, a, undefined, s);
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
for (const tool of tools) {
|
|
94
|
+
register(tool.name, {
|
|
95
|
+
title: tool.title,
|
|
96
|
+
description: tool.description,
|
|
97
|
+
inputSchema: tool.shape,
|
|
98
|
+
annotations: { readOnlyHint: tool.readOnly, destructiveHint: false, idempotentHint: tool.readOnly, openWorldHint: false },
|
|
99
|
+
}, async (args, extra) => {
|
|
100
|
+
const parsed = schema.object(tool.shape).strict().safeParse(args);
|
|
101
|
+
if (!parsed.success)
|
|
102
|
+
return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
|
|
103
|
+
return tool.run(parsed.data, extra?.signal);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile, rename, stat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { resolve, sep } from 'node:path';
|
|
4
|
+
import { inflateSync, deflateSync } from 'node:zlib';
|
|
5
|
+
export const sha256 = (b) => createHash('sha256').update(b).digest('hex');
|
|
6
|
+
export async function atomicJson(file, value) { const tmp = file + '.' + process.pid + '.tmp'; await writeFile(tmp, JSON.stringify(value)); await rename(tmp, file); }
|
|
7
|
+
export async function readJson(file) { if ((await stat(file)).size > 64 * 1024 * 1024)
|
|
8
|
+
throw Error('Manifest exceeds 64 MB.'); return JSON.parse(await readFile(file, 'utf8')); }
|
|
9
|
+
export async function exportFile(dir, file) {
|
|
10
|
+
const root = await realpath(dir), path = await realpath(resolve(root, file));
|
|
11
|
+
if (!path.startsWith(root + sep))
|
|
12
|
+
throw Error('Export file escapes its job directory.');
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
15
|
+
const signature = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
16
|
+
function crc32(data) { let crc = 0xffffffff; for (const byte of data) {
|
|
17
|
+
crc ^= byte;
|
|
18
|
+
for (let i = 0; i < 8; i++)
|
|
19
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
20
|
+
} return (crc ^ 0xffffffff) >>> 0; }
|
|
21
|
+
/** Native RGBA8 data, without gamma conversion (height is packed in RG). */
|
|
22
|
+
export function decodeExportPng(bytes) {
|
|
23
|
+
if (!bytes.subarray(0, 8).equals(signature))
|
|
24
|
+
throw Error('Invalid PNG signature.');
|
|
25
|
+
let width = 0, height = 0, channels = 0, ended = false;
|
|
26
|
+
const parts = [];
|
|
27
|
+
for (let at = 8; at + 12 <= bytes.length;) {
|
|
28
|
+
const length = bytes.readUInt32BE(at), end = at + length + 12;
|
|
29
|
+
if (end > bytes.length)
|
|
30
|
+
throw Error('Truncated PNG.');
|
|
31
|
+
const type = bytes.toString('ascii', at + 4, at + 8), data = bytes.subarray(at + 8, end - 4);
|
|
32
|
+
if (crc32(bytes.subarray(at + 4, end - 4)) !== bytes.readUInt32BE(end - 4))
|
|
33
|
+
throw Error('PNG checksum mismatch.');
|
|
34
|
+
if (type === 'IHDR') {
|
|
35
|
+
if (width || length !== 13 || data[8] !== 8 || ![2, 6].includes(data[9]) || data[10] || data[11] || data[12])
|
|
36
|
+
throw Error('Expected non-interlaced RGB/RGBA8 PNG.');
|
|
37
|
+
width = data.readUInt32BE(0);
|
|
38
|
+
height = data.readUInt32BE(4);
|
|
39
|
+
channels = data[9] === 6 ? 4 : 3;
|
|
40
|
+
if (!width || !height || width > 8192 || height > 8192)
|
|
41
|
+
throw Error('PNG dimensions exceed export budget.');
|
|
42
|
+
}
|
|
43
|
+
else if (type === 'IDAT')
|
|
44
|
+
parts.push(data);
|
|
45
|
+
else if (type === 'IEND') {
|
|
46
|
+
ended = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
at = end;
|
|
50
|
+
}
|
|
51
|
+
if (!width || !ended)
|
|
52
|
+
throw Error('Incomplete PNG.');
|
|
53
|
+
const stride = width * channels, expected = (stride + 1) * height, raw = inflateSync(Buffer.concat(parts), { maxOutputLength: expected });
|
|
54
|
+
if (raw.length !== expected)
|
|
55
|
+
throw Error('Invalid PNG data length.');
|
|
56
|
+
const pixels = Buffer.alloc(stride * height);
|
|
57
|
+
for (let y = 0; y < height; y++) {
|
|
58
|
+
const filter = raw[y * (stride + 1)];
|
|
59
|
+
if (filter > 4)
|
|
60
|
+
throw Error('Invalid PNG filter.');
|
|
61
|
+
for (let x = 0; x < stride; x++) {
|
|
62
|
+
const at = y * stride + x, a = x >= channels ? pixels[at - channels] : 0, b = y ? pixels[at - stride] : 0, c = y && x >= channels ? pixels[at - stride - channels] : 0;
|
|
63
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
64
|
+
pixels[at] = raw[y * (stride + 1) + x + 1] + (filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? Math.floor((a + b) / 2) : pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { width, height, channels, pixels };
|
|
68
|
+
}
|
|
69
|
+
export function encodeExportPng(width, height, pixels, channels = 4) {
|
|
70
|
+
const chunk = (name, data) => { const out = Buffer.alloc(data.length + 12); out.writeUInt32BE(data.length); out.write(name, 4); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, out.length - 4)), out.length - 4); return out; };
|
|
71
|
+
const header = Buffer.alloc(13);
|
|
72
|
+
header.writeUInt32BE(width);
|
|
73
|
+
header.writeUInt32BE(height, 4);
|
|
74
|
+
header[8] = 8;
|
|
75
|
+
header[9] = channels === 4 ? 6 : 2;
|
|
76
|
+
const stride = width * channels, scan = Buffer.alloc(height * (stride + 1));
|
|
77
|
+
for (let y = 0; y < height; y++)
|
|
78
|
+
pixels.copy(scan, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
79
|
+
return Buffer.concat([signature, chunk('IHDR', header), chunk('IDAT', deflateSync(scan)), chunk('IEND', Buffer.alloc(0))]);
|
|
80
|
+
}
|
|
81
|
+
const vec = (x = 0, y = 0, z = 0) => ({ x, y, z });
|
|
82
|
+
const identity = () => ({ translation: vec(), rotation: { ...vec(), w: 1 }, scale: vec(1, 1, 1) });
|
|
83
|
+
const base = (id, name = id) => ({ id, name, parentId: null, transform: identity(), visible: true, locked: false });
|
|
84
|
+
const round = (n) => Math.round(n * 1e6) / 1e6;
|
|
85
|
+
const refFor = (file) => ({ assetId: 'ue_' + file.sha256.slice(0, 24), revisionId: 'rev_' + file.sha256.slice(0, 24) });
|
|
86
|
+
export function decodeUnrealTerrain(t, bytes, resolution, visibility) {
|
|
87
|
+
const png = decodeExportPng(bytes), mask = visibility ? decodeExportPng(visibility) : undefined;
|
|
88
|
+
if (png.width !== t.width || png.height !== t.height || mask && (mask.width !== t.width || mask.height !== t.height))
|
|
89
|
+
throw Error('Landscape grid does not match its PNG.');
|
|
90
|
+
const spanX = t.width - 1, spanZ = t.height - 1, longest = Math.max(spanX, spanZ), sx = Math.max(1, Math.round((resolution - 1) * spanX / longest)), sz = Math.max(1, Math.round((resolution - 1) * spanZ / longest));
|
|
91
|
+
const q = t.component_quads, occupied = new Set(t.components.map(([x, y]) => x + ':' + y));
|
|
92
|
+
const present = (x, z) => occupied.has((Math.floor((t.base[0] + Math.min(spanX - .00001, Math.max(0, x))) / q) * q) + ':' + (Math.floor((t.base[1] + Math.min(spanZ - .00001, Math.max(0, z))) / q) * q));
|
|
93
|
+
const hidden = (x, z) => mask ? mask.pixels[(Math.round(z) * mask.width + Math.round(x)) * mask.channels] >= 128 : false;
|
|
94
|
+
const sample = (x, z) => { const at = (Math.min(t.height - 1, Math.max(0, z)) * t.width + Math.min(t.width - 1, Math.max(0, x))) * png.channels; return (png.pixels[at] * 256 + png.pixels[at + 1] - 32768) / 128 * t.scale[2] * .01; };
|
|
95
|
+
const heights = [], holes = [];
|
|
96
|
+
for (let z = 0; z <= sz; z++)
|
|
97
|
+
for (let x = 0; x <= sx; x++) {
|
|
98
|
+
const px = x / sx * spanX, pz = z / sz * spanZ, ix = Math.floor(px), iz = Math.floor(pz), u = px - ix, v = pz - iz;
|
|
99
|
+
const a = sample(ix, iz) * (1 - u) + sample(ix + 1, iz) * u, b = sample(ix, iz + 1) * (1 - u) + sample(ix + 1, iz + 1) * u;
|
|
100
|
+
heights.push(present(px, pz) ? Math.round((a * (1 - v) + b * v) * 1000) / 1000 : 0);
|
|
101
|
+
}
|
|
102
|
+
for (let z = 0; z < sz; z++)
|
|
103
|
+
for (let x = 0; x < sx; x++) {
|
|
104
|
+
let hole = false;
|
|
105
|
+
// Every source sample overlapped by this quad is checked, so small painted holes survive downsampling.
|
|
106
|
+
for (let j = Math.floor(z / sz * spanZ); j <= Math.ceil((z + 1) / sz * spanZ) && !hole; j++)
|
|
107
|
+
for (let i = Math.floor(x / sx * spanX); i <= Math.ceil((x + 1) / sx * spanX); i++)
|
|
108
|
+
if (!present(i, j) || hidden(i, j)) {
|
|
109
|
+
hole = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
holes.push(hole ? 1 : 0);
|
|
113
|
+
}
|
|
114
|
+
return { size: [spanX * t.scale[0] * .01, spanZ * t.scale[1] * .01], segments: [sx, sz], heights, holes,
|
|
115
|
+
center: vec((t.origin[0] + (t.base[0] + spanX / 2) * t.scale[0]) * .01, t.origin[2] * .01, (t.origin[1] + (t.base[1] + spanZ / 2) * t.scale[1]) * .01) };
|
|
116
|
+
}
|
|
117
|
+
export async function buildUnrealStudioPackage(directory) {
|
|
118
|
+
const dir = await realpath(directory), manifest = await readJson(resolve(dir, 'native.json')), request = await readJson(resolve(dir, 'request.json'));
|
|
119
|
+
if (manifest.schema !== 'gripforge.unreal-export.v1' || manifest.status !== 'exported-awaiting-gripforge-review' || sha256(await readFile(resolve(dir, 'request.json'))) !== manifest.request_hash)
|
|
120
|
+
throw Error('Native export must finish successfully before assembly.');
|
|
121
|
+
const assets = [], nodes = [], assetRefs = new Map();
|
|
122
|
+
async function addFile(file, name, kind, mime) {
|
|
123
|
+
const path = await exportFile(dir, file.file), bytes = await readFile(path);
|
|
124
|
+
if (bytes.length > 128 * 1024 * 1024 || bytes.length !== file.bytes || sha256(bytes) !== file.sha256)
|
|
125
|
+
throw Error('Changed or oversized export: ' + file.file);
|
|
126
|
+
const ref = refFor(file);
|
|
127
|
+
if (!assets.some(a => a.sha256 === file.sha256))
|
|
128
|
+
assets.push({ ...file, ...ref, name, kind, mime });
|
|
129
|
+
return ref;
|
|
130
|
+
}
|
|
131
|
+
for (const [key, file] of Object.entries(manifest.assets))
|
|
132
|
+
assetRefs.set(key, await addFile(file, file.name, 'prop', 'model/gltf-binary'));
|
|
133
|
+
nodes.push({ ...base('props', 'Props & buildings'), type: 'group' }, { ...base('vegetation', 'Vegetation'), type: 'group' }, { ...base('terrain', 'Terrain'), type: 'group' });
|
|
134
|
+
const batches = new Map();
|
|
135
|
+
let zeroScaleInstances = 0;
|
|
136
|
+
for (const instance of manifest.instances) {
|
|
137
|
+
const asset = assetRefs.get(instance.asset);
|
|
138
|
+
if (!asset)
|
|
139
|
+
throw Error('Missing exported mesh: ' + instance.asset);
|
|
140
|
+
const t = instance.transform, p = t.translation, q = t.rotation, s = t.scale;
|
|
141
|
+
// Unreal foliage retains removed placements as zero-scale tombstones. They have no visible geometry.
|
|
142
|
+
if ([s.x, s.y, s.z].every(n => n === 0)) {
|
|
143
|
+
zeroScaleInstances++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const row = [p.x, p.y, p.z, q.x, q.y, q.z, q.w, s.x, s.y, s.z].map(round);
|
|
147
|
+
if (!row.every(Number.isFinite) || Math.abs(Math.hypot(q.x, q.y, q.z, q.w) - 1) > .001 || [s.x, s.y, s.z].some(n => Math.abs(n) < .00001))
|
|
148
|
+
throw Error('Invalid native transform: ' + instance.id);
|
|
149
|
+
if (instance.batch && [s.x, s.y, s.z].every(n => n > 0)) {
|
|
150
|
+
const cx = Math.floor(p.x / 64), cz = Math.floor(p.z / 64), prefix = instance.asset + ':' + cx + ':' + cz;
|
|
151
|
+
let part = 0, key = prefix;
|
|
152
|
+
while ((batches.get(key)?.instanceBatch.ids.length ?? 0) >= 4096)
|
|
153
|
+
key = prefix + ':' + (++part);
|
|
154
|
+
if (!batches.has(key)) {
|
|
155
|
+
const b = { ...base('batch_' + sha256(key).slice(0, 24), instance.name.slice(0, 110) + ` [${cx},${cz}]`), type: 'asset', parentId: 'vegetation', asset, role: 'vegetation', materials: {}, parameters: { source: 'unreal-instancing' }, instanceBatch: { ids: [], transforms: [] } };
|
|
156
|
+
batches.set(key, b);
|
|
157
|
+
nodes.push(b);
|
|
158
|
+
}
|
|
159
|
+
const b = batches.get(key);
|
|
160
|
+
b.instanceBatch.ids.push(instance.id);
|
|
161
|
+
b.instanceBatch.transforms.push(row);
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
nodes.push({ ...base(instance.id, instance.name), type: 'asset', parentId: instance.batch ? 'vegetation' : 'props', asset, role: 'prop', transform: t, materials: {}, parameters: { unrealSource: instance.source } });
|
|
165
|
+
}
|
|
166
|
+
let overview;
|
|
167
|
+
for (const t of manifest.terrains) {
|
|
168
|
+
await addFile(t.heightmap, t.id + ' source height', 'texture', 'image/png');
|
|
169
|
+
if (t.visibility)
|
|
170
|
+
await addFile(t.visibility, t.id + ' source holes', 'texture', 'image/png');
|
|
171
|
+
const terrain = decodeUnrealTerrain(t, await readFile(await exportFile(dir, t.heightmap.file)), request.terrain_resolution ?? 513, t.visibility ? await readFile(await exportFile(dir, t.visibility.file)) : undefined);
|
|
172
|
+
let albedo = t.albedo;
|
|
173
|
+
if (Math.abs(terrain.size[0] - terrain.size[1]) > .001) {
|
|
174
|
+
const source = decodeExportPng(await readFile(await exportFile(dir, albedo.file)));
|
|
175
|
+
const w = Math.max(1, Math.round(source.width * terrain.size[0] / (t.albedo_span_cm * .01))), h = Math.max(1, Math.round(source.height * terrain.size[1] / (t.albedo_span_cm * .01))), data = Buffer.alloc(w * h * source.channels);
|
|
176
|
+
if (w > source.width || h > source.height)
|
|
177
|
+
throw Error('Invalid Landscape albedo extent.');
|
|
178
|
+
const ox = Math.floor((source.width - w) / 2), oy = Math.floor((source.height - h) / 2);
|
|
179
|
+
for (let y = 0; y < h; y++)
|
|
180
|
+
source.pixels.copy(data, y * w * source.channels, ((y + oy) * source.width + ox) * source.channels, ((y + oy) * source.width + ox + w) * source.channels);
|
|
181
|
+
const bytes = encodeExportPng(w, h, data, source.channels), file = t.id + '-albedo-cropped.png';
|
|
182
|
+
await writeFile(resolve(dir, file), bytes);
|
|
183
|
+
albedo = { file, bytes: bytes.length, sha256: sha256(bytes) };
|
|
184
|
+
}
|
|
185
|
+
const map = await addFile(albedo, t.id + ' albedo', 'texture', 'image/png');
|
|
186
|
+
nodes.push({ ...base('terrain_' + sha256(t.id).slice(0, 24), t.id), type: 'terrain', parentId: 'terrain', transform: { ...identity(), translation: terrain.center }, size: terrain.size, segments: terrain.segments, heights: terrain.heights, holes: terrain.holes, layers: [], material: { color: '#ffffff', roughness: 1, metalness: 0, map } });
|
|
187
|
+
overview ??= terrain;
|
|
188
|
+
}
|
|
189
|
+
const placements = manifest.instances;
|
|
190
|
+
if (!overview && placements.length) {
|
|
191
|
+
const axes = ['x', 'y', 'z'], min = axes.map(k => Math.min(...placements.map(p => p.transform.translation[k]))), max = axes.map(k => Math.max(...placements.map(p => p.transform.translation[k])));
|
|
192
|
+
overview = { center: vec(...min.map((v, i) => (v + max[i]) / 2)), size: [Math.max(10, max[0] - min[0]), Math.max(10, max[2] - min[2])] };
|
|
193
|
+
}
|
|
194
|
+
if (!overview)
|
|
195
|
+
throw Error('Nothing portable was found in the selected map.');
|
|
196
|
+
const center = overview.center, span = Math.max(...overview.size), sun = manifest.environment.sun ?? [1, 2, 1];
|
|
197
|
+
nodes.push({ ...base('environment', 'Environment (portable approximation)'), type: 'environment', preset: 'outdoor', background: '#9dc4e1', intensity: .8, exposure: 1, rotation: 0, fog: null, sky: { mode: 'physical', sun: vec(...sun), turbidity: 3, rayleigh: 1.5, intensity: .8 } });
|
|
198
|
+
nodes.push({ ...base('camera_overview', 'Overview'), type: 'camera', projection: 'perspective', transform: { ...identity(), translation: vec(center.x + span * .55, center.y + span * .7, center.z + span * .65) }, target: center, fov: 55, near: .1, far: Math.max(3000, span * 6), zoom: 1 });
|
|
199
|
+
for (const [i, c] of manifest.cameras.entries())
|
|
200
|
+
nodes.push({ ...base('camera_native_' + i, c.name.slice(0, 160)), type: 'camera', projection: 'perspective', transform: { ...identity(), translation: vec(...c.position) }, target: vec(...c.target), fov: Math.min(175, Math.max(1, c.fov)), near: .1, far: Math.max(3000, span * 6), zoom: 1 });
|
|
201
|
+
const warnings = [...manifest.warnings, 'Repeated foliage is editable by spatial patch. Member IDs and transforms are retained; per-blade gizmos are not implemented.', 'Portable environment lighting is an approximation; compare against Unreal before validation.'];
|
|
202
|
+
if (zeroScaleInstances)
|
|
203
|
+
warnings.push(`${zeroScaleInstances} zero-scale native placements have no visible geometry and were omitted; their original transforms remain in native.json.`);
|
|
204
|
+
const document = { schemaVersion: 1, id: 'unreal_' + manifest.request_hash.slice(0, 24), workspaceId: 'local-preview', name: manifest.project + ' — ' + manifest.source_level.split('/').pop(), purpose: 'map', revision: 0, nodes, activeCameraId: 'camera_overview', metadata: { unrealSource: { level: manifest.source_level, sha256: manifest.source_sha256, omitted: manifest.omitted }, lifecycle: 'work', warnings } };
|
|
205
|
+
const input = { schema: 'gripforge.unreal-studio-import.v1', source: { engine: 'unreal', level: manifest.source_level, sha256: manifest.source_sha256, warnings }, document };
|
|
206
|
+
if (nodes.length > 20000 || Buffer.byteLength(JSON.stringify(input)) > 16 * 1024 * 1024)
|
|
207
|
+
throw Error('Portable scene exceeds the Studio budget; export smaller regions.');
|
|
208
|
+
await atomicJson(resolve(dir, 'scene.json'), document);
|
|
209
|
+
const portable = { schema: 'gripforge.scene-package.v1', scene: 'scene.json', sceneHash: sha256(JSON.stringify(document)), assets, cameras: ['camera_overview', ...nodes.filter(n => n.id.startsWith('camera_native_')).map(n => n.id)], source: input.source, instanceCount: manifest.instances.length - zeroScaleInstances, zeroScaleInstances, nodeCount: nodes.length, lifecycle: 'work' };
|
|
210
|
+
await atomicJson(resolve(dir, 'manifest.json'), portable);
|
|
211
|
+
return { directory: dir, input, manifest: portable };
|
|
212
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// mcp-handler requires v4 field schemas for raw shapes. zod 3.25 ships v4
|
|
3
3
|
// alongside v3, so existing stdio tools can retain their current v3 schemas.
|
|
4
4
|
import { z } from 'zod/v4';
|
|
5
|
+
import { LOOK_DESCRIPTION, LOOK_VALUES } from './look.js';
|
|
5
6
|
// The hosted SDK uses a newer Zod v4 serializer than the npm SDK. Inject its
|
|
6
7
|
// matching factory so schemas remain compatible with each host's serializer.
|
|
7
8
|
export function registerVfxProjectTools(register, options, schema = z) {
|
|
@@ -44,5 +45,5 @@ export function registerVfxProjectTools(register, options, schema = z) {
|
|
|
44
45
|
tool('gripforge_vfx_project_render', 'Render actual VFX frames for inspection', 'Queue a durable render of an owned id OR an unsaved project at 1–4 timestamps. Returns job_id immediately. Poll gripforge_generation_read with include_preview for the actual rendered image, coverage, clipping and shader diagnostics. Uses the shared GripForge SceneViewport. Saves preview evidence only, no project revision. Requires workspace write access because the job and preview consume storage. Render timings are not browser FPS.', { idempotency_key: schema.string().min(8).max(160).optional(), id: id.optional(), project: project.optional(), times }, false, (args, extra) => api('/render', args, 'POST', extra?.signal));
|
|
45
46
|
tool('gripforge_vfx_project_review', 'Review an existing work revision', 'Queue a fresh GripForge capture and independent visual review without writing another source revision. Poll gripforge_generation_read; inspect its preview, then explicitly promote if approved.', { id, expected_revision: schema.number().int().positive(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => api(`/${args.id}/review`, args, 'POST', extra?.signal));
|
|
46
47
|
tool('gripforge_vfx_project_promote', 'Set the visually approved VFX version', 'Explicitly promote work after a trusted positive review matching this source, dependencies and renderer. expectedCurrent is the existing validated revision, or null.', { id, revision: schema.number().int().positive(), expectedCurrent: schema.number().int().positive().nullable() }, false, (args, extra) => api(`/${args.id}/promote`, args, 'POST', extra?.signal));
|
|
47
|
-
tool('gripforge_vfx_project_generate', 'Create, render and refine a custom VFX', 'Text/image -> durable generation job -> original geometry/shaders -> actual GripForge rendering -> visual critique/correction -> workspace work revision. Returns immediately with job_id, status_url and studio_url. Poll gripforge_generation_read, then open its source_url to inspect the saved source and review. Closing the request does not cancel the job. Completed steps survive worker restart. Pass id + expected_revision to refine; image_id must belong to this workspace. A visually rejected candidate never replaces the validated version.', { idempotency_key: schema.string().min(8).max(160).optional(), prompt: schema.string().max(2000).optional(), visual_style: schema.enum(
|
|
48
|
+
tool('gripforge_vfx_project_generate', 'Create, render and refine a custom VFX', 'Text/image -> durable generation job -> original geometry/shaders -> actual GripForge rendering -> visual critique/correction -> workspace work revision. Returns immediately with job_id, status_url and studio_url. Poll gripforge_generation_read, then open its source_url to inspect the saved source and review. Closing the request does not cancel the job. Completed steps survive worker restart. Pass id + expected_revision to refine; image_id must belong to this workspace. A visually rejected candidate never replaces the validated version.', { idempotency_key: schema.string().min(8).max(160).optional(), prompt: schema.string().max(2000).optional(), visual_style: schema.enum(LOOK_VALUES).optional().describe('Rendering look of the effect (same vocabulary as look). Default stylized.'), look: schema.enum(LOOK_VALUES).optional().describe(LOOK_DESCRIPTION), image_mode: schema.enum(['spatial', 'animated-artwork']).optional().describe('Default spatial: build a 3D effect. animated-artwork explicitly opts into a flat animated illustration.'), image_id: id.optional(), image_data: schema.string().max(12 * 1024 * 1024).optional(), id: id.optional(), expected_revision: schema.number().int().positive().optional() }, false, (args, extra) => api('/generate', args, 'POST', extra?.signal), true);
|
|
48
49
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod/v4';
|
|
2
|
+
export const WEAPON_MOTION_TOOL_NAMES = ['gripforge_weapon_profile', 'gripforge_weapon_motion', 'gripforge_weapon_motion_review'];
|
|
3
|
+
export function registerWeaponMotionTools(register, options, s = z) {
|
|
4
|
+
const v = s.tuple([s.number(), s.number(), s.number()]);
|
|
5
|
+
const common = { source_id: s.string().regex(/^lib_[A-Za-z0-9_-]{8,64}$/), workspace_id: s.string().max(100).optional() };
|
|
6
|
+
const profile = { ...common, family: s.enum(['axe', 'sword', 'hammer', 'staff']).optional().describe('Required on the first call. Never infer axe vs sword from geometry alone.'), hand: s.enum(['left', 'right']).optional(), hands: s.union([s.literal(1), s.literal(2)]).optional(), weaponNode: s.string().max(150).optional(), primaryAnchor: v.optional(), secondaryAnchor: v.optional().describe('Required for two hands; weapon-node local coordinates.'), handleAxis: v.optional(), edgeAxis: v.optional() };
|
|
7
|
+
const generate = { ...profile, slots: s.array(s.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge', 'block', 'jump_start', 'jump_loop', 'jump_land'])).min(1).max(13).optional(), name: s.string().max(150).optional(), idempotency_key: s.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional() };
|
|
8
|
+
const definitions = [
|
|
9
|
+
{ name: WEAPON_MOTION_TOOL_NAMES[0], action: 'profile', shape: profile, description: 'Measure an owned armed GLB instance and return its editable weapon/motion profile: explicit family, holding hand, dimensions, grip anchors, handle and cutting-edge axes. Use gripforge_attach first. Default axes are +Y handle and +Z edge; inspect/correct them. Does not mutate the Library source or certify the initial seating.' },
|
|
10
|
+
{ name: WEAPON_MOTION_TOOL_NAMES[1], action: 'generate', shape: generate, description: 'Queue a persistent weapon-aware motion draft from an owned armed GLB. Axe/hammer get distinct procedural cleave, backhand and overhead attacks; sword uses the explicit UAL sword pack. Staff attacks fail with compatible_motion_missing; no silent sword fallback. Holds Grip finger poses, supports a second-hand anchor with IK. Poll gripforge_generation_read, cancel/retry with generation tools. Returns a NEW work asset and Character Studio URL; never replaces the game/source or visually validates a result. 0 generation credits.' },
|
|
11
|
+
{ name: WEAPON_MOTION_TOOL_NAMES[2], action: 'review', shape: common, description: 'Independently sample the saved weapon-motion GLB at 60 Hz: grip drift, finger pose, wrist rotation, joint discontinuities, second-hand reach and torso overlaps. Reports technicalPassed separately from visualStatus=not_reviewed. Inspect idle, wind-up, impact, recovery and locomotion in GripForge before using the draft. No promotion or mutation.' },
|
|
12
|
+
];
|
|
13
|
+
for (const d of definitions)
|
|
14
|
+
register(d.name, { title: d.name, description: d.description, inputSchema: d.shape, annotations: { readOnlyHint: d.action !== 'generate', destructiveHint: false, idempotentHint: d.action !== 'generate', openWorldHint: false } }, async (args, extra) => {
|
|
15
|
+
const key = options.getApiKey();
|
|
16
|
+
if (!key)
|
|
17
|
+
return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
|
|
18
|
+
const parsed = s.object(d.shape).strict().safeParse(args);
|
|
19
|
+
if (!parsed.success)
|
|
20
|
+
return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
|
|
21
|
+
const { workspace_id, ...body } = parsed.data;
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(options.apiUrl.replace(/\/$/, '') + '/api/v1/weapon-motion', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': key, ...(workspace_id ? { 'x-workspace-id': workspace_id } : {}) }, body: JSON.stringify({ action: d.action, ...body }), signal: AbortSignal.any([AbortSignal.timeout(150000), ...(extra?.signal ? [extra.signal] : [])]) });
|
|
24
|
+
const data = await response.json();
|
|
25
|
+
for (const k of ['studio_url', 'status_url'])
|
|
26
|
+
if (typeof data[k] === 'string' && data[k].startsWith('/'))
|
|
27
|
+
data[k] = new URL(data[k], options.apiUrl).href;
|
|
28
|
+
return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Weapon motion request failed.' }] };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gripforgeai/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "The tools your AI needs to make your games. Turn prompts into production-ready game assets — animated characters with their weapons attached, seamless textures, terrain, VFX, HUDs — and playable game kits Unity, Godot, Unreal or Three.js can load.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"runtime/unreal-scene-import.py",
|
|
13
|
+
"runtime/unreal-scene-export.py",
|
|
14
|
+
"runtime/unreal-scene-upload.mjs",
|
|
15
|
+
"runtime/unreal_scene_math.py",
|
|
16
|
+
"runtime/unreal_spawn_review.py",
|
|
12
17
|
"README.md"
|
|
13
18
|
],
|
|
14
19
|
"scripts": {
|