@gripforgeai/mcp 0.1.10 → 0.1.11
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 +275 -4
- package/dist/ability-tools.js +21 -0
- package/dist/architecture-tools.js +73 -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 +127 -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/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 +77 -13
- package/dist/server-tools.js +3 -3
- package/dist/server.js +164 -22
- 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/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,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
|
+
}
|
|
@@ -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.11",
|
|
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": {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""GripForge read-only UE -> portable scene worker (Python commandlet).
|
|
2
|
+
|
|
3
|
+
Run in a separate editor process with rendering enabled and -GFExportJob=<dir>.
|
|
4
|
+
No source asset/map is saved. Atomic checkpoints survive the MCP request.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import unreal
|
|
15
|
+
|
|
16
|
+
match = re.search(r'-GFExportJob=(?:"([^"]+)"|(\S+))', unreal.SystemLibrary.get_command_line())
|
|
17
|
+
if not match:
|
|
18
|
+
raise RuntimeError('Missing -GFExportJob')
|
|
19
|
+
folder = Path(match.group(1) or match.group(2)).resolve()
|
|
20
|
+
request = json.loads((folder / 'request.json').read_text())
|
|
21
|
+
fingerprint = hashlib.sha256((folder / 'request.json').read_bytes()).hexdigest()
|
|
22
|
+
manifest_path = folder / 'native.json'
|
|
23
|
+
state_path = folder / 'status.json'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def atomic(path, value):
|
|
27
|
+
tmp = path.with_suffix(path.suffix + '.tmp')
|
|
28
|
+
tmp.write_text(json.dumps(value, separators=(',', ':')))
|
|
29
|
+
tmp.replace(path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha(path):
|
|
33
|
+
h = hashlib.sha256()
|
|
34
|
+
with path.open('rb') as f:
|
|
35
|
+
for b in iter(lambda: f.read(1024 * 1024), b''):
|
|
36
|
+
h.update(b)
|
|
37
|
+
return h.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cancelled():
|
|
41
|
+
p = folder / 'control.json'
|
|
42
|
+
if p.exists() and json.loads(p.read_text()).get('cancel'):
|
|
43
|
+
raise InterruptedError('Export cancelled; completed assets retained')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def progress(stage, **kw):
|
|
47
|
+
cancelled()
|
|
48
|
+
atomic(state_path, dict(schema='gripforge.unreal-export-job.v1', request_hash=fingerprint,
|
|
49
|
+
status='running', stage=stage, pid=os.getpid(), updated_at=time.time(), **kw))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def xyz(v):
|
|
53
|
+
return [float(v.x), float(v.y), float(v.z)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def transform(t):
|
|
57
|
+
q = t.rotation
|
|
58
|
+
p, s = t.translation, t.scale3d
|
|
59
|
+
return dict(translation=dict(x=p.x*.01, y=p.z*.01, z=p.y*.01),
|
|
60
|
+
rotation=dict(x=-q.x, y=-q.z, z=-q.y, w=q.w),
|
|
61
|
+
scale=dict(x=s.x, y=s.z, z=s.y))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def native_prop(obj, names, default=None):
|
|
65
|
+
for name in names:
|
|
66
|
+
try:
|
|
67
|
+
return obj.get_editor_property(name)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
return default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def file_record(file):
|
|
74
|
+
p = folder / file
|
|
75
|
+
return dict(file=file, sha256=sha(p), bytes=p.stat().st_size)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def done(record):
|
|
79
|
+
p = folder / record['file']
|
|
80
|
+
return p.is_file() and sha(p) == record['sha256']
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def package_file(path):
|
|
84
|
+
package = path.split('.')[0]
|
|
85
|
+
if package.startswith('/Game/'):
|
|
86
|
+
return Path(unreal.Paths.project_content_dir()) / (package[6:] + '.uasset')
|
|
87
|
+
if package.startswith('/Engine/'):
|
|
88
|
+
return Path(unreal.Paths.engine_content_dir()) / (package[8:] + '.uasset')
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def png(rt, name):
|
|
93
|
+
unreal.RenderingLibrary.export_render_target(world, rt, str(folder), name)
|
|
94
|
+
path = folder / name
|
|
95
|
+
if not path.is_file() or path.stat().st_size < 64:
|
|
96
|
+
raise RuntimeError('Render target export failed: ' + name)
|
|
97
|
+
return file_record(name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
assert request['schema'] == 'gripforge.unreal-export-request.v1'
|
|
102
|
+
assert Path(unreal.Paths.get_project_file_path()).resolve() == Path(request['project_file']).resolve()
|
|
103
|
+
source_file = Path(request['project_file']).parent / 'Content' / (request['source_level'][6:] + '.umap')
|
|
104
|
+
assert sha(source_file) == request['source_sha256'], 'Source level changed; prepare a new job'
|
|
105
|
+
progress('loading_source')
|
|
106
|
+
world = unreal.EditorLoadingAndSavingUtils.load_map(request['source_level'])
|
|
107
|
+
assert world and world.get_path_name().split('.')[0] == request['source_level']
|
|
108
|
+
actors = unreal.GameplayStatics.get_all_actors_of_class(world, unreal.Actor)
|
|
109
|
+
previous = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
|
|
110
|
+
if previous:
|
|
111
|
+
assert previous['request_hash'] == fingerprint, 'Job inputs changed'
|
|
112
|
+
manifest = dict(schema='gripforge.unreal-export.v1', request_hash=fingerprint,
|
|
113
|
+
source_level=request['source_level'], source_sha256=request['source_sha256'],
|
|
114
|
+
project=Path(request['project_file']).stem, units='metres', up='Y',
|
|
115
|
+
assets=previous.get('assets', {}), instances=[], terrains=previous.get('terrains', []),
|
|
116
|
+
environment={}, lights=[], cameras=[], omitted={}, warnings=[])
|
|
117
|
+
definitions, landscapes, dependencies = {}, [], {}
|
|
118
|
+
|
|
119
|
+
def omit(kind):
|
|
120
|
+
manifest['omitted'][kind] = manifest['omitted'].get(kind, 0) + 1
|
|
121
|
+
|
|
122
|
+
def pin(path):
|
|
123
|
+
p = package_file(path)
|
|
124
|
+
if p and p.is_file():
|
|
125
|
+
info = p.stat()
|
|
126
|
+
dependencies[str(p)] = [info.st_size, info.st_mtime_ns]
|
|
127
|
+
|
|
128
|
+
pinned_materials = set()
|
|
129
|
+
def pin_material(material):
|
|
130
|
+
if not material or material.get_path_name() in pinned_materials:
|
|
131
|
+
return
|
|
132
|
+
pinned_materials.add(material.get_path_name())
|
|
133
|
+
pin(material.get_path_name())
|
|
134
|
+
if isinstance(material, unreal.Material):
|
|
135
|
+
for tex in unreal.MaterialEditingLibrary.get_used_textures(material):
|
|
136
|
+
pin(tex.get_path_name())
|
|
137
|
+
else:
|
|
138
|
+
for value in native_prop(material, ['texture_parameter_values'], []):
|
|
139
|
+
tex = value.get_editor_property('parameter_value')
|
|
140
|
+
if tex:
|
|
141
|
+
pin(tex.get_path_name())
|
|
142
|
+
pin_material(native_prop(material, ['parent']))
|
|
143
|
+
|
|
144
|
+
for actor in actors:
|
|
145
|
+
cancelled()
|
|
146
|
+
cls = actor.get_class().get_path_name()
|
|
147
|
+
if isinstance(actor, unreal.LandscapeProxy):
|
|
148
|
+
landscapes.append(actor)
|
|
149
|
+
continue
|
|
150
|
+
if isinstance(actor, unreal.DirectionalLight):
|
|
151
|
+
comp = actor.get_component_by_class(unreal.DirectionalLightComponent)
|
|
152
|
+
direction = actor.get_actor_forward_vector()
|
|
153
|
+
color = comp.get_light_color()
|
|
154
|
+
manifest['environment'] = dict(sun=[-direction.x, -direction.z, -direction.y],
|
|
155
|
+
color=[color.r, color.g, color.b], intensity=comp.get_editor_property('intensity'))
|
|
156
|
+
continue
|
|
157
|
+
if isinstance(actor, unreal.CameraActor):
|
|
158
|
+
c = actor.get_camera_component()
|
|
159
|
+
p, d = actor.get_actor_location(), actor.get_actor_forward_vector()
|
|
160
|
+
manifest['cameras'].append(dict(name=actor.get_actor_label(), position=[p.x*.01,p.z*.01,p.y*.01],
|
|
161
|
+
target=[(p.x+d.x*1000)*.01,(p.z+d.z*1000)*.01,(p.y+d.y*1000)*.01], fov=c.field_of_view))
|
|
162
|
+
# Procedural UE skies and water are reported, not exported as opaque domes/planes.
|
|
163
|
+
if re.search(r'(?:GoodSky|UltraDynamicSky|SkySphere|SkyAtmosphere|CartoonWater)', cls, re.I):
|
|
164
|
+
omit(cls)
|
|
165
|
+
continue
|
|
166
|
+
components = actor.get_components_by_class(unreal.StaticMeshComponent)
|
|
167
|
+
supported = 0
|
|
168
|
+
for component in components:
|
|
169
|
+
mesh = component.static_mesh
|
|
170
|
+
if not mesh or not component.is_visible():
|
|
171
|
+
continue
|
|
172
|
+
if isinstance(component, unreal.SplineMeshComponent):
|
|
173
|
+
omit('SplineMeshComponent (deformed mesh requires a dedicated bake)')
|
|
174
|
+
continue
|
|
175
|
+
materials = [component.get_material(i) for i in range(component.get_num_materials())]
|
|
176
|
+
paths = [m.get_path_name() if m else None for m in materials]
|
|
177
|
+
source = dict(mesh=mesh.get_path_name(), materials=paths)
|
|
178
|
+
key = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()[:24]
|
|
179
|
+
definitions[key] = (mesh, materials, source)
|
|
180
|
+
pin(mesh.get_path_name())
|
|
181
|
+
for material in materials:
|
|
182
|
+
pin_material(material)
|
|
183
|
+
transforms = []
|
|
184
|
+
if isinstance(component, unreal.InstancedStaticMeshComponent):
|
|
185
|
+
transforms = [component.get_instance_transform(i, world_space=True) for i in range(component.get_instance_count())]
|
|
186
|
+
else:
|
|
187
|
+
transforms = [component.get_world_transform()]
|
|
188
|
+
for i, t in enumerate(transforms):
|
|
189
|
+
identifier = component.get_path_name() + ':' + str(i)
|
|
190
|
+
manifest['instances'].append(dict(id='ue_'+hashlib.sha256(identifier.encode()).hexdigest()[:24],
|
|
191
|
+
name=actor.get_actor_label()[:145], source=identifier, asset=key, transform=transform(t),
|
|
192
|
+
batch=isinstance(component, unreal.InstancedStaticMeshComponent)))
|
|
193
|
+
supported += len(transforms)
|
|
194
|
+
if not supported and not components and any(s in cls for s in ['Niagara', 'Decal', 'SpotLight', 'RectLight', 'PointLight']):
|
|
195
|
+
omit(cls)
|
|
196
|
+
manifest['source_snapshot'] = dependencies
|
|
197
|
+
if previous.get('source_snapshot') and previous['source_snapshot'] != dependencies:
|
|
198
|
+
raise RuntimeError('Source dependencies changed; use a new export job')
|
|
199
|
+
atomic(manifest_path, manifest)
|
|
200
|
+
assert len(manifest['instances']) <= 100000, 'Map exceeds 100,000 portable instances; export regions'
|
|
201
|
+
progress('inventory', instances=len(manifest['instances']), unique_assets=len(definitions), landscapes=len(landscapes))
|
|
202
|
+
|
|
203
|
+
# UE finishes compiling before render-target capture/material baking.
|
|
204
|
+
# Commandlets do not tick the editor to apply asynchronous shader results.
|
|
205
|
+
# Preload the export shader as well, before the native blocking compile command.
|
|
206
|
+
unreal.load_asset('/Engine/EditorLandscapeResources/Landscape_Heightmap_To_RenderTarget2D')
|
|
207
|
+
unreal.load_asset('/Engine/EditorLandscapeResources/Landscape_Weightmap_To_RenderTarget2D')
|
|
208
|
+
unreal.SystemLibrary.execute_console_command(world, 'Editor.AsyncAssetCompilationFinishAll')
|
|
209
|
+
for landscape in landscapes:
|
|
210
|
+
cancelled()
|
|
211
|
+
key = landscape.get_name()
|
|
212
|
+
old = next((t for t in manifest['terrains'] if t['id'] == key), None)
|
|
213
|
+
if old and old.get('bake_version') == 2 and all(done(old[k]) for k in ['heightmap', 'albedo']):
|
|
214
|
+
continue
|
|
215
|
+
progress('terrain', terrain=key)
|
|
216
|
+
rotation = landscape.get_actor_rotation()
|
|
217
|
+
assert abs(rotation.pitch)+abs(rotation.yaw)+abs(rotation.roll) < .001, 'Rotated Landscape needs a dedicated adapter'
|
|
218
|
+
components = landscape.get_components_by_class(unreal.LandscapeComponent)
|
|
219
|
+
bases = [(int(c.get_editor_property('section_base_x')), int(c.get_editor_property('section_base_y'))) for c in components]
|
|
220
|
+
q = native_prop(components[0], ['ComponentSizeQuads', 'component_size_quads'])
|
|
221
|
+
if not q:
|
|
222
|
+
diffs = [abs(b-a) for axis in [0,1] for a,b in zip(sorted(set(p[axis] for p in bases)), sorted(set(p[axis] for p in bases))[1:])]
|
|
223
|
+
assert diffs, 'Cannot determine single-component Landscape resolution'
|
|
224
|
+
q = math.gcd(*diffs)
|
|
225
|
+
minx, miny = min(p[0] for p in bases), min(p[1] for p in bases)
|
|
226
|
+
maxx, maxy = max(p[0] for p in bases)+q, max(p[1] for p in bases)+q
|
|
227
|
+
width, height = maxx-minx+1, maxy-miny+1
|
|
228
|
+
assert max(width,height) <= 8192, 'Landscape exceeds 8192 height samples; export regions'
|
|
229
|
+
rt = unreal.RenderingLibrary.create_render_target2d(world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
|
230
|
+
assert landscape.landscape_export_heightmap_to_render_target(rt, True, False)
|
|
231
|
+
heightmap = png(rt, key+'-height-rg.png')
|
|
232
|
+
unreal.RenderingLibrary.release_render_target2d(rt)
|
|
233
|
+
visibility = None
|
|
234
|
+
layer_names = [str(n) for n in landscape.get_target_layer_names(True)] if hasattr(landscape, 'get_target_layer_names') else []
|
|
235
|
+
visibility_name = next((n for n in layer_names if 'visibility' in n.lower()), None)
|
|
236
|
+
if visibility_name:
|
|
237
|
+
rt = unreal.RenderingLibrary.create_render_target2d(world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
|
238
|
+
assert landscape.landscape_export_weightmap_to_render_target(rt, visibility_name), 'Visibility mask export failed'
|
|
239
|
+
visibility = png(rt, key+'-visibility.png')
|
|
240
|
+
unreal.RenderingLibrary.release_render_target2d(rt)
|
|
241
|
+
p, s = landscape.get_actor_location(), landscape.get_actor_scale3d()
|
|
242
|
+
assert s.x > 0 and s.y > 0 and s.z > 0, 'Reflected Landscape requires a dedicated adapter'
|
|
243
|
+
center = unreal.Vector(p.x+(minx+maxx)*s.x/2, p.y+(miny+maxy)*s.y/2, p.z+200000)
|
|
244
|
+
capture = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.SceneCapture2D, center, unreal.Rotator(-90,-90,0))
|
|
245
|
+
comp = capture.get_component_by_class(unreal.SceneCaptureComponent2D)
|
|
246
|
+
size = request.get('terrain_texture_size', 4096)
|
|
247
|
+
rt = unreal.RenderingLibrary.create_render_target2d(world, size, size, unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
|
248
|
+
comp.set_editor_property('texture_target', rt)
|
|
249
|
+
comp.set_editor_property('projection_type', unreal.CameraProjectionMode.ORTHOGRAPHIC)
|
|
250
|
+
# Export a square orthographic projection; the manifest retains the crop.
|
|
251
|
+
span = max((maxx-minx)*s.x, (maxy-miny)*s.y)
|
|
252
|
+
comp.set_editor_property('ortho_width', span)
|
|
253
|
+
comp.set_editor_property('capture_source', unreal.SceneCaptureSource.SCS_BASE_COLOR)
|
|
254
|
+
comp.set_editor_property('primitive_render_mode', unreal.SceneCapturePrimitiveRenderMode.PRM_USE_SHOW_ONLY_LIST)
|
|
255
|
+
comp.show_only_actor_components(landscape)
|
|
256
|
+
comp.set_editor_property('capture_every_frame', False)
|
|
257
|
+
comp.set_editor_property('capture_on_movement', False)
|
|
258
|
+
comp.capture_scene()
|
|
259
|
+
albedo = png(rt, key+'-albedo.png')
|
|
260
|
+
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(capture)
|
|
261
|
+
unreal.RenderingLibrary.release_render_target2d(rt)
|
|
262
|
+
row = dict(id=key, bake_version=2, heightmap=heightmap, albedo=albedo, width=width, height=height,
|
|
263
|
+
base=[minx,miny], components=bases, component_quads=q, origin=xyz(p), scale=xyz(s),
|
|
264
|
+
albedo_span_cm=span, material=landscape.get_editor_property('landscape_material').get_path_name())
|
|
265
|
+
if visibility:
|
|
266
|
+
row['visibility'] = visibility
|
|
267
|
+
manifest['terrains'] = [t for t in manifest['terrains'] if t['id'] != key]+[row]
|
|
268
|
+
atomic(manifest_path, manifest)
|
|
269
|
+
|
|
270
|
+
options = unreal.GLTFExportOptions()
|
|
271
|
+
options.set_editor_property('export_uniform_scale', .01)
|
|
272
|
+
options.set_editor_property('bake_material_inputs', unreal.GLTFMaterialBakeMode.USE_MESH_DATA)
|
|
273
|
+
options.set_editor_property('default_material_bake_size', unreal.GLTFMaterialBakeSize(request.get('texture_size',1024), request.get('texture_size',1024)))
|
|
274
|
+
options.set_editor_property('texture_image_format', unreal.GLTFTextureImageFormat.PNG)
|
|
275
|
+
options.set_editor_property('adjust_normalmaps', True)
|
|
276
|
+
options.set_editor_property('export_vertex_colors', False)
|
|
277
|
+
options.set_editor_property('export_lights', False)
|
|
278
|
+
options.set_editor_property('export_cameras', False)
|
|
279
|
+
options.set_editor_property('export_level_sequences', False)
|
|
280
|
+
(folder/'assets').mkdir(exist_ok=True)
|
|
281
|
+
for index, (key, (mesh, materials, source)) in enumerate(definitions.items()):
|
|
282
|
+
progress('meshes', completed=index, total=len(definitions), asset=mesh.get_name())
|
|
283
|
+
old = manifest['assets'].get(key)
|
|
284
|
+
if old and done(old):
|
|
285
|
+
continue
|
|
286
|
+
# Export one neutral instance of each mesh/material combination. No mutation of Library/native assets.
|
|
287
|
+
temp = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.StaticMeshActor, unreal.Vector(0,0,0))
|
|
288
|
+
comp = temp.static_mesh_component
|
|
289
|
+
comp.set_static_mesh(mesh)
|
|
290
|
+
for slot, material in enumerate(materials):
|
|
291
|
+
if material:
|
|
292
|
+
comp.set_material(slot, material)
|
|
293
|
+
name = 'assets/'+key+'.glb'
|
|
294
|
+
try:
|
|
295
|
+
unreal.GLTFExporter.export_to_gltf(world, str(folder/name), options, {temp})
|
|
296
|
+
assert (folder/name).is_file(), 'GLB export failed: '+source['mesh']
|
|
297
|
+
manifest['assets'][key] = dict(file_record(name), name=mesh.get_name(), source=source)
|
|
298
|
+
atomic(manifest_path, manifest)
|
|
299
|
+
finally:
|
|
300
|
+
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(temp)
|
|
301
|
+
if manifest['terrains']:
|
|
302
|
+
progress('native_reference')
|
|
303
|
+
terrain = manifest['terrains'][0]
|
|
304
|
+
o, s, base = terrain['origin'], terrain['scale'], terrain['base']
|
|
305
|
+
sx, sy = (terrain['width']-1)*s[0], (terrain['height']-1)*s[1]
|
|
306
|
+
span = max(sx, sy)
|
|
307
|
+
target = unreal.Vector(o[0]+(base[0]+(terrain['width']-1)/2)*s[0], o[1]+(base[1]+(terrain['height']-1)/2)*s[1], o[2])
|
|
308
|
+
position = target + unreal.Vector(span*.55, span*.65, span*.7)
|
|
309
|
+
capture = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).spawn_actor_from_class(unreal.SceneCapture2D, position, unreal.MathLibrary.find_look_at_rotation(position,target))
|
|
310
|
+
try:
|
|
311
|
+
comp = capture.get_component_by_class(unreal.SceneCaptureComponent2D)
|
|
312
|
+
rt = unreal.RenderingLibrary.create_render_target2d(world, 1600, 900, unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
|
313
|
+
comp.set_editor_property('texture_target', rt)
|
|
314
|
+
comp.set_editor_property('fov_angle', math.degrees(2*math.atan(math.tan(math.radians(55)/2)*1600/900)))
|
|
315
|
+
comp.set_editor_property('capture_source', unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
|
|
316
|
+
comp.set_editor_property('capture_every_frame', False)
|
|
317
|
+
comp.set_editor_property('capture_on_movement', False)
|
|
318
|
+
unreal.SystemLibrary.execute_console_command(world, 'Editor.AsyncAssetCompilationFinishAll')
|
|
319
|
+
comp.capture_scene()
|
|
320
|
+
manifest['native_reference'] = png(rt, 'unreal-overview.png')
|
|
321
|
+
unreal.RenderingLibrary.release_render_target2d(rt)
|
|
322
|
+
finally:
|
|
323
|
+
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(capture)
|
|
324
|
+
for file, before in dependencies.items():
|
|
325
|
+
info = Path(file).stat()
|
|
326
|
+
assert [info.st_size,info.st_mtime_ns] == before, 'Source changed during export: '+file
|
|
327
|
+
assert sha(source_file) == request['source_sha256'], 'Source level changed during export'
|
|
328
|
+
manifest['warnings'] = [
|
|
329
|
+
'Native Blueprint logic, Niagara, decals, animated water and procedural sky are not portable; omitted classes are counted.',
|
|
330
|
+
'Complex static materials are baked. Time/view/world-dependent shader behavior is not retained.',
|
|
331
|
+
'Landscape base color is a top-down native bake; material paint layers remain a source provenance, not an editable UE shader graph.',
|
|
332
|
+
]
|
|
333
|
+
manifest['status'] = 'exported-awaiting-gripforge-review'
|
|
334
|
+
atomic(manifest_path,manifest)
|
|
335
|
+
atomic(state_path,dict(status='succeeded',stage='exported',request_hash=fingerprint,updated_at=time.time(),
|
|
336
|
+
instances=len(manifest['instances']),assets=len(manifest['assets']),terrains=len(manifest['terrains']),lifecycle='work'))
|
|
337
|
+
except Exception as error:
|
|
338
|
+
atomic(state_path, dict(status='cancelled' if isinstance(error,InterruptedError) else 'failed',
|
|
339
|
+
error=str(error), traceback=traceback.format_exc(), request_hash=fingerprint,updated_at=time.time()))
|
|
340
|
+
unreal.log_error(traceback.format_exc())
|
|
341
|
+
raise
|