ultimate-unreal-engine-mcp 0.1.23 → 0.1.25
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 +12 -18
- package/dist/tools/editor/index.js +182 -4
- package/package.json +1 -1
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPEditorStateCommands.cpp +45 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPImportExportCommands.cpp +114 -0
- package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPViewportCommands.cpp +13 -6
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Ultimate Unreal Engine MCP
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**133 tools. 26 domains. Full read/write access to Unreal Engine 5.7 — including live editor APIs, Blueprint graphs, and viewport screenshots.**
|
|
4
4
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://nodejs.org/)
|
|
7
7
|
[](https://www.unrealengine.com/)
|
|
8
8
|
[](#development)
|
|
9
|
-
[](#tool-catalog)
|
|
10
10
|
|
|
11
11
|
MCP (Model Context Protocol) is a standard that lets AI assistants call tools on external systems — this server exposes Unreal Engine as those tools.
|
|
12
12
|
|
|
@@ -52,7 +52,7 @@ npx ultimate-unreal-engine-mcp setup --project "C:/MyGame"
|
|
|
52
52
|
|
|
53
53
|
This command writes the correct config for your client. The C++ plugin is optional — file-system tools (C++ parsing, code gen, configs, builds, docs) work without it. Install the plugin for live editor tools (Blueprints, actors, viewport, screenshots).
|
|
54
54
|
|
|
55
|
-
> **Zero optional dependencies.** The C++ plugin compiles on any UE 5.7 install — binary or source. All
|
|
55
|
+
> **Zero optional dependencies.** The C++ plugin compiles on any UE 5.7 install — binary or source. All 133 tools use runtime reflection for optional UE modules (MetaSound, LiveLink, GAS, etc.), so the plugin loads regardless of which plugins your project has enabled. Handlers gracefully report when their target module isn't available.
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
@@ -546,7 +546,7 @@ ue_cleanup_screenshots(confirm=true)
|
|
|
546
546
|
|
|
547
547
|
## Automatic Self-Verification
|
|
548
548
|
|
|
549
|
-
Every mutating tool
|
|
549
|
+
Verification isn't a suggestion. It's **server-side enforced**. Every mutating tool automatically queries `actor.componentBounds` after the mutation completes and appends the verification data to the response. The AI sees bounds, positions, and component state in the same tool call result, whether it asked for it or not. If something is wrong, the data is right there and impossible to miss.
|
|
550
550
|
|
|
551
551
|
**Why this matters:** In spatial work (placing doors, aligning meshes, wiring components), a single transform can have cascading effects — rotating a mesh around a pivot shifts its world position, negative scale flips face normals making geometry invisible, collision volumes don't automatically follow mesh offsets. Without verification, the AI reports "done" while the door is floating 2 meters from the wall.
|
|
552
552
|
|
|
@@ -554,24 +554,18 @@ Every mutating tool description includes **mandatory verification guidance** —
|
|
|
554
554
|
|
|
555
555
|
```
|
|
556
556
|
ue_spawn_actor("BP_Door", location={x:1040, y:150, z:0})
|
|
557
|
-
→
|
|
558
|
-
|
|
559
|
-
ue_get_component_bounds("BP_Door", component="DoorMesh") ← automatic
|
|
560
|
-
→ bounds: X=[806, 994] ✓ — in the doorway
|
|
557
|
+
→ {actor_label: "BP_Door", _verification: {bounds: {min: {x:806}, max: {x:994}}}}
|
|
558
|
+
← bounds auto-appended by server — in the doorway ✓
|
|
561
559
|
|
|
562
560
|
ue_set_actor_property("BP_Door", "DoorMesh.RelativeRotation", ...)
|
|
563
|
-
→
|
|
564
|
-
|
|
565
|
-
ue_get_component_bounds("BP_Door", component="DoorMesh") ← automatic
|
|
566
|
-
→ bounds: X=[1040, 1228] ✗ — rotation shifted mesh out of gap
|
|
567
|
-
|
|
568
|
-
ue_set_actor_property("BP_Door", "DoorMesh.RelativeLocation", ...) ← self-correction
|
|
569
|
-
→ Offset compensated
|
|
561
|
+
→ {success: true, _verification: {bounds: {min: {x:1040}, max: {x:1228}}}}
|
|
562
|
+
← bounds shifted! rotation moved mesh out of gap ✗
|
|
570
563
|
|
|
571
|
-
|
|
572
|
-
→
|
|
564
|
+
ue_set_actor_property("BP_Door", "DoorMesh.RelativeLocation", ...)
|
|
565
|
+
→ {success: true, _verification: {bounds: {min: {x:806}, max: {x:994}}}}
|
|
566
|
+
← back in place after offset compensation ✓
|
|
573
567
|
|
|
574
|
-
ue_look_at("BP_Door", angle="front")
|
|
568
|
+
ue_look_at("BP_Door", angle="front")
|
|
575
569
|
→ [screenshot inline] door visible, handle correct side ✓
|
|
576
570
|
```
|
|
577
571
|
|
|
@@ -43,6 +43,39 @@ async function sendOrDisconnect(bridge, cmd) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
|
+
// autoVerifyBounds helper
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* Queries actor.componentBounds after a mutation and appends verification
|
|
50
|
+
* data to the original result. If the verification call fails, the original
|
|
51
|
+
* result is returned unmodified (verification is best-effort).
|
|
52
|
+
*/
|
|
53
|
+
async function autoVerifyBounds(bridge, actorLabel, originalResult) {
|
|
54
|
+
// Don't verify if the original command failed
|
|
55
|
+
if (originalResult.isError)
|
|
56
|
+
return originalResult;
|
|
57
|
+
try {
|
|
58
|
+
const verifyResponse = await bridge.sendCommand({
|
|
59
|
+
type: 'actor.componentBounds',
|
|
60
|
+
payload: { actor_label: actorLabel },
|
|
61
|
+
correlationId: '',
|
|
62
|
+
});
|
|
63
|
+
const verifyData = verifyResponse.success
|
|
64
|
+
? { _verification: { actor_label: actorLabel, bounds: verifyResponse.data } }
|
|
65
|
+
: { _verification: { actor_label: actorLabel, error: verifyResponse.error } };
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
...originalResult.content,
|
|
69
|
+
{ type: 'text', text: JSON.stringify(verifyData) },
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Verification is best-effort; don't break the original result
|
|
75
|
+
return originalResult;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
46
79
|
// registerEditorTools
|
|
47
80
|
// ---------------------------------------------------------------------------
|
|
48
81
|
/**
|
|
@@ -104,10 +137,22 @@ export function registerEditorTools(server, bridge) {
|
|
|
104
137
|
if (args.label) {
|
|
105
138
|
payload['label'] = args.label;
|
|
106
139
|
}
|
|
107
|
-
|
|
140
|
+
const result = await sendOrDisconnect(_bridge, {
|
|
108
141
|
type: 'actor.spawn',
|
|
109
142
|
payload,
|
|
110
143
|
});
|
|
144
|
+
// Auto-verify: query bounds of the spawned actor
|
|
145
|
+
const label = args.label ?? (result.content?.[0] && 'text' in result.content[0]
|
|
146
|
+
? (() => { try {
|
|
147
|
+
return JSON.parse(result.content[0].text)?.actor_label;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
} })()
|
|
152
|
+
: undefined);
|
|
153
|
+
if (label)
|
|
154
|
+
return autoVerifyBounds(_bridge, label, result);
|
|
155
|
+
return result;
|
|
111
156
|
}));
|
|
112
157
|
// --------------------------------------------------------------------------
|
|
113
158
|
// ue_set_actor_property
|
|
@@ -136,10 +181,12 @@ export function registerEditorTools(server, bridge) {
|
|
|
136
181
|
if (args.component_name) {
|
|
137
182
|
payload['component_name'] = args.component_name;
|
|
138
183
|
}
|
|
139
|
-
|
|
184
|
+
const result = await sendOrDisconnect(_bridge, {
|
|
140
185
|
type: 'actor.setProperty',
|
|
141
186
|
payload,
|
|
142
187
|
});
|
|
188
|
+
// Auto-verify: query bounds after property change
|
|
189
|
+
return autoVerifyBounds(_bridge, args.actor_label, result);
|
|
143
190
|
}));
|
|
144
191
|
// --------------------------------------------------------------------------
|
|
145
192
|
// ue_create_data_asset
|
|
@@ -235,7 +282,9 @@ export function registerEditorTools(server, bridge) {
|
|
|
235
282
|
payload['rotation'] = args.rotation;
|
|
236
283
|
if (args.scale)
|
|
237
284
|
payload['scale'] = args.scale;
|
|
238
|
-
|
|
285
|
+
const result = await sendOrDisconnect(_bridge, { type: 'actor.transform', payload });
|
|
286
|
+
// Auto-verify: query bounds after transform
|
|
287
|
+
return autoVerifyBounds(_bridge, args.actor_label, result);
|
|
239
288
|
}));
|
|
240
289
|
// --------------------------------------------------------------------------
|
|
241
290
|
// ue_delete_actor
|
|
@@ -251,10 +300,30 @@ export function registerEditorTools(server, bridge) {
|
|
|
251
300
|
destructiveHint: true,
|
|
252
301
|
},
|
|
253
302
|
}, withKnownIssues('ue_delete_actor', async (args) => {
|
|
254
|
-
|
|
303
|
+
const result = await sendOrDisconnect(_bridge, {
|
|
255
304
|
type: 'actor.delete',
|
|
256
305
|
payload: { actor_label: args.actor_label },
|
|
257
306
|
});
|
|
307
|
+
// Auto-verify: confirm actor no longer exists
|
|
308
|
+
if (!result.isError) {
|
|
309
|
+
try {
|
|
310
|
+
const verifyResponse = await _bridge.sendCommand({
|
|
311
|
+
type: 'actor.list',
|
|
312
|
+
correlationId: '',
|
|
313
|
+
});
|
|
314
|
+
if (verifyResponse.success) {
|
|
315
|
+
const actors = verifyResponse.data;
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
...result.content,
|
|
319
|
+
{ type: 'text', text: JSON.stringify({ _verification: { deleted: args.actor_label, remaining_actors: actors } }) },
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch { /* verification is best-effort */ }
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
258
327
|
}));
|
|
259
328
|
// --------------------------------------------------------------------------
|
|
260
329
|
// ue_query_assets
|
|
@@ -325,4 +394,113 @@ export function registerEditorTools(server, bridge) {
|
|
|
325
394
|
}, withKnownIssues('ue_read_level_layout', async (_args) => {
|
|
326
395
|
return sendOrDisconnect(_bridge, { type: 'level.layout' });
|
|
327
396
|
}));
|
|
397
|
+
// --------------------------------------------------------------------------
|
|
398
|
+
// ue_get_component_bounds
|
|
399
|
+
// --------------------------------------------------------------------------
|
|
400
|
+
server.registerTool('ue_get_component_bounds', {
|
|
401
|
+
title: 'Get Actor Component Bounds',
|
|
402
|
+
description: '[requires_plugin] Get the world-space bounding box and transform of an actor and its components. Use this to verify spatial placement after spawning, transforming, or setting properties. Returns min/max bounds, center, and extent for each component.',
|
|
403
|
+
inputSchema: z.object({
|
|
404
|
+
actor_label: z.string().describe('The editor label of the actor to inspect'),
|
|
405
|
+
component_name: z.string().optional().describe('Optional: inspect a specific component only (e.g., DoorMesh, DoorCollision)'),
|
|
406
|
+
}),
|
|
407
|
+
annotations: {
|
|
408
|
+
readOnlyHint: true,
|
|
409
|
+
destructiveHint: false,
|
|
410
|
+
},
|
|
411
|
+
}, withKnownIssues('ue_get_component_bounds', async (args) => {
|
|
412
|
+
const payload = { actor_label: args.actor_label };
|
|
413
|
+
if (args.component_name) {
|
|
414
|
+
payload['component_name'] = args.component_name;
|
|
415
|
+
}
|
|
416
|
+
return sendOrDisconnect(_bridge, { type: 'actor.componentBounds', payload });
|
|
417
|
+
}));
|
|
418
|
+
// --------------------------------------------------------------------------
|
|
419
|
+
// ue_rebuild
|
|
420
|
+
// --------------------------------------------------------------------------
|
|
421
|
+
server.registerTool('ue_rebuild', {
|
|
422
|
+
title: 'Save, Rebuild & Relaunch Editor',
|
|
423
|
+
description: '[requires_plugin] Saves all dirty packages, closes the editor, runs Build.bat, and relaunches the editor. Returns build output and relaunch status.',
|
|
424
|
+
inputSchema: z.object({
|
|
425
|
+
project_path: z.string().describe('Absolute path to the .uproject file'),
|
|
426
|
+
ue_path: z.string().optional().describe('UE engine install path (default: C:/Program Files/Epic Games/UE_5.7)'),
|
|
427
|
+
}),
|
|
428
|
+
annotations: {
|
|
429
|
+
readOnlyHint: false,
|
|
430
|
+
destructiveHint: true,
|
|
431
|
+
},
|
|
432
|
+
}, withKnownIssues('ue_rebuild', async (args) => {
|
|
433
|
+
const { execFileSync, spawn } = await import('child_process');
|
|
434
|
+
const net = await import('net');
|
|
435
|
+
const uePath = args.ue_path ?? 'C:/Program Files/Epic Games/UE_5.7';
|
|
436
|
+
const projectPath = args.project_path;
|
|
437
|
+
const steps = [];
|
|
438
|
+
// Step 1: Save all
|
|
439
|
+
try {
|
|
440
|
+
const saveResult = await _bridge.sendCommand({ type: 'editor.saveAll', correlationId: '' });
|
|
441
|
+
steps.push({ step: 'save', success: saveResult.success });
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
steps.push({ step: 'save', error: err.message });
|
|
445
|
+
}
|
|
446
|
+
// Step 2: Quit editor
|
|
447
|
+
try {
|
|
448
|
+
await _bridge.sendCommand({ type: 'editor.quit', correlationId: '' });
|
|
449
|
+
steps.push({ step: 'quit', sent: true });
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
steps.push({ step: 'quit', sent: true, note: 'connection dropped (expected)' });
|
|
453
|
+
}
|
|
454
|
+
// Step 3: Wait for editor to fully close (poll TCP port)
|
|
455
|
+
const waitForClose = () => new Promise((resolve) => {
|
|
456
|
+
let attempts = 0;
|
|
457
|
+
const check = () => {
|
|
458
|
+
const sock = net.createConnection({ port: 55557, host: '127.0.0.1' });
|
|
459
|
+
sock.on('connect', () => { sock.destroy(); attempts++; if (attempts < 60) setTimeout(check, 500); else resolve(false); });
|
|
460
|
+
sock.on('error', () => { resolve(true); });
|
|
461
|
+
};
|
|
462
|
+
setTimeout(check, 1000);
|
|
463
|
+
});
|
|
464
|
+
const closed = await waitForClose();
|
|
465
|
+
steps.push({ step: 'wait_close', closed });
|
|
466
|
+
// Step 4: Build (using execFileSync to avoid shell injection)
|
|
467
|
+
try {
|
|
468
|
+
const buildBat = `${uePath}/Engine/Build/BatchFiles/Build.bat`;
|
|
469
|
+
const buildArgs = ['ScaryRogueEditor', 'Win64', 'Development', `-Project=${projectPath}`, '-WaitMutex'];
|
|
470
|
+
const buildOutput = execFileSync(buildBat, buildArgs, { encoding: 'utf8', timeout: 300000, shell: true });
|
|
471
|
+
const succeeded = buildOutput.includes('Succeeded');
|
|
472
|
+
const lastLines = buildOutput.trim().split('\n').slice(-5).join('\n');
|
|
473
|
+
steps.push({ step: 'build', succeeded, output: lastLines });
|
|
474
|
+
if (!succeeded) {
|
|
475
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
steps.push({ step: 'build', error: err.message?.slice(0, 500) });
|
|
480
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
|
|
481
|
+
}
|
|
482
|
+
// Step 5: Relaunch editor
|
|
483
|
+
try {
|
|
484
|
+
const editorExe = `${uePath}/Engine/Binaries/Win64/UnrealEditor.exe`;
|
|
485
|
+
const child = spawn(editorExe, [projectPath], { detached: true, stdio: 'ignore' });
|
|
486
|
+
child.unref();
|
|
487
|
+
steps.push({ step: 'relaunch', pid: child.pid });
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
steps.push({ step: 'relaunch', error: err.message });
|
|
491
|
+
}
|
|
492
|
+
// Step 6: Wait for editor to reconnect (TCP port available)
|
|
493
|
+
const waitForReconnect = () => new Promise((resolve) => {
|
|
494
|
+
let attempts = 0;
|
|
495
|
+
const check = () => {
|
|
496
|
+
const sock = net.createConnection({ port: 55557, host: '127.0.0.1' });
|
|
497
|
+
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
|
498
|
+
sock.on('error', () => { attempts++; if (attempts < 120) setTimeout(check, 1000); else resolve(false); });
|
|
499
|
+
};
|
|
500
|
+
setTimeout(check, 5000);
|
|
501
|
+
});
|
|
502
|
+
const reconnected = await waitForReconnect();
|
|
503
|
+
steps.push({ step: 'wait_reconnect', reconnected });
|
|
504
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps }) }] };
|
|
505
|
+
}));
|
|
328
506
|
}
|
package/package.json
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
#include "Editor.h"
|
|
12
12
|
#include "Selection.h"
|
|
13
|
+
#include "FileHelpers.h"
|
|
14
|
+
#include "HAL/PlatformMisc.h"
|
|
15
|
+
#include "Containers/Ticker.h"
|
|
13
16
|
#include "EditorViewportClient.h"
|
|
14
17
|
#include "LevelEditorViewport.h"
|
|
15
18
|
#include "EngineUtils.h"
|
|
@@ -170,4 +173,46 @@ void RegisterEditorStateCommands(FMCPCommandRouter& Router)
|
|
|
170
173
|
|
|
171
174
|
SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
|
|
172
175
|
});
|
|
176
|
+
|
|
177
|
+
// -----------------------------------------------------------------------
|
|
178
|
+
// editor.saveAll
|
|
179
|
+
// Saves all dirty packages (levels, assets). Returns count of saved packages.
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
Router.RegisterHandler(TEXT("editor.saveAll"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
|
|
182
|
+
{
|
|
183
|
+
const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
|
|
184
|
+
|
|
185
|
+
const bool bSaved = FEditorFileUtils::SaveDirtyPackages(
|
|
186
|
+
false, // bPromptUserToSave
|
|
187
|
+
true, // bSaveMapPackages
|
|
188
|
+
true, // bSaveContentPackages
|
|
189
|
+
false); // bFastSave
|
|
190
|
+
|
|
191
|
+
TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
|
|
192
|
+
Data->SetBoolField(TEXT("saved"), bSaved);
|
|
193
|
+
|
|
194
|
+
SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// -----------------------------------------------------------------------
|
|
198
|
+
// editor.quit
|
|
199
|
+
// Gracefully closes the editor. Response is sent before shutdown begins.
|
|
200
|
+
// -----------------------------------------------------------------------
|
|
201
|
+
Router.RegisterHandler(TEXT("editor.quit"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
|
|
202
|
+
{
|
|
203
|
+
const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
|
|
204
|
+
|
|
205
|
+
TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
|
|
206
|
+
Data->SetBoolField(TEXT("quitting"), true);
|
|
207
|
+
|
|
208
|
+
SendResponse(BuildEditorStateSuccessResponse(CorrId, Data) + TEXT("\n"));
|
|
209
|
+
|
|
210
|
+
// Defer the actual quit to next tick so the response gets sent first.
|
|
211
|
+
FTSTicker::GetCoreTicker().AddTicker(
|
|
212
|
+
FTickerDelegate::CreateLambda([](float) -> bool
|
|
213
|
+
{
|
|
214
|
+
FPlatformMisc::RequestExit(false); // false = clean shutdown
|
|
215
|
+
return false;
|
|
216
|
+
}), 0.1f);
|
|
217
|
+
});
|
|
173
218
|
}
|
|
@@ -186,6 +186,120 @@ static UObject* CreateFbxFactory(bool bImportMaterials, bool bCombineMeshes, flo
|
|
|
186
186
|
|
|
187
187
|
void RegisterImportExportCommands(FMCPCommandRouter& Router)
|
|
188
188
|
{
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
// import.texture
|
|
191
|
+
// Imports a texture file (PNG, JPG, TGA, etc.) to a UE content path.
|
|
192
|
+
// Uses UTextureFactory via reflection. Runs synchronously (no Interchange).
|
|
193
|
+
// Payload: { source_file, dest_path }
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
Router.RegisterHandler(TEXT("import.texture"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
|
|
196
|
+
{
|
|
197
|
+
const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
|
|
198
|
+
|
|
199
|
+
TSharedPtr<FJsonObject> Payload;
|
|
200
|
+
const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
|
|
201
|
+
if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
|
|
202
|
+
{
|
|
203
|
+
Payload = (*PayloadVal)->AsObject();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
FString SourceFile, DestPath;
|
|
207
|
+
if (!Payload.IsValid()
|
|
208
|
+
|| !Payload->TryGetStringField(TEXT("source_file"), SourceFile) || SourceFile.IsEmpty()
|
|
209
|
+
|| !Payload->TryGetStringField(TEXT("dest_path"), DestPath) || DestPath.IsEmpty())
|
|
210
|
+
{
|
|
211
|
+
SendResponse(BuildImpErrorResponse(CorrId, TEXT("missing_required_fields")) + TEXT("\n"));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (SourceFile.Contains(TEXT("..")) || !FPaths::FileExists(SourceFile))
|
|
216
|
+
{
|
|
217
|
+
SendResponse(BuildImpErrorResponse(CorrId, TEXT("source_file_not_found")) + TEXT("\n"));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!IsValidAssetPath(DestPath))
|
|
222
|
+
{
|
|
223
|
+
SendResponse(BuildImpErrorResponse(CorrId, TEXT("invalid_dest_path")) + TEXT("\n"));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Split dest into path + name.
|
|
228
|
+
FString PackagePath = DestPath;
|
|
229
|
+
FString AssetName;
|
|
230
|
+
if (DestPath.EndsWith(TEXT("/")))
|
|
231
|
+
{
|
|
232
|
+
AssetName = FPaths::GetBaseFilename(SourceFile);
|
|
233
|
+
PackagePath = DestPath.LeftChop(1);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
{
|
|
237
|
+
AssetName = FPackageName::GetLongPackageAssetName(DestPath);
|
|
238
|
+
PackagePath = FPackageName::GetLongPackagePath(DestPath);
|
|
239
|
+
if (AssetName.IsEmpty())
|
|
240
|
+
{
|
|
241
|
+
AssetName = FPaths::GetBaseFilename(SourceFile);
|
|
242
|
+
PackagePath = DestPath;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Find UTextureFactory via reflection (avoids including engine-specific headers).
|
|
247
|
+
UClass* TextureFactoryClass = FindObject<UClass>(nullptr, TEXT("/Script/UnrealEd.TextureFactory"));
|
|
248
|
+
if (!TextureFactoryClass)
|
|
249
|
+
{
|
|
250
|
+
SendResponse(BuildImpErrorResponse(CorrId, TEXT("texture_factory_not_found")) + TEXT("\n"));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
UObject* TextureFactory = NewObject<UObject>(GetTransientPackage(), TextureFactoryClass);
|
|
255
|
+
if (!TextureFactory)
|
|
256
|
+
{
|
|
257
|
+
SendResponse(BuildImpErrorResponse(CorrId, TEXT("factory_creation_failed")) + TEXT("\n"));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create import task.
|
|
262
|
+
IAssetTools& AT = FModuleManager::LoadModuleChecked<FAssetToolsModule>(TEXT("AssetTools")).Get();
|
|
263
|
+
|
|
264
|
+
UAssetImportTask* Task = NewObject<UAssetImportTask>();
|
|
265
|
+
Task->Filename = SourceFile;
|
|
266
|
+
Task->DestinationPath = PackagePath;
|
|
267
|
+
Task->DestinationName = AssetName;
|
|
268
|
+
Task->bAutomated = true;
|
|
269
|
+
Task->bReplaceExisting = true;
|
|
270
|
+
Task->bSave = true;
|
|
271
|
+
|
|
272
|
+
// Set the factory.
|
|
273
|
+
FObjectProperty* FactoryProp = CastField<FObjectProperty>(
|
|
274
|
+
Task->GetClass()->FindPropertyByName(TEXT("Factory")));
|
|
275
|
+
if (FactoryProp)
|
|
276
|
+
{
|
|
277
|
+
FactoryProp->SetObjectPropertyValue_InContainer(Task, TextureFactory);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Run import via ticker (same pattern as FBX — avoids task graph recursion).
|
|
281
|
+
FTSTicker::GetCoreTicker().AddTicker(
|
|
282
|
+
FTickerDelegate::CreateLambda([CorrId, SendResponse, Task, &AT](float) -> bool
|
|
283
|
+
{
|
|
284
|
+
TArray<UAssetImportTask*> Tasks;
|
|
285
|
+
Tasks.Add(Task);
|
|
286
|
+
AT.ImportAssetTasks(Tasks);
|
|
287
|
+
|
|
288
|
+
TArray<TSharedPtr<FJsonValue>> AssetsArray;
|
|
289
|
+
for (const FString& ImportedPath : Task->ImportedObjectPaths)
|
|
290
|
+
{
|
|
291
|
+
AssetsArray.Add(MakeShared<FJsonValueString>(ImportedPath));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
|
|
295
|
+
Data->SetArrayField(TEXT("assets"), AssetsArray);
|
|
296
|
+
Data->SetNumberField(TEXT("count"), static_cast<double>(AssetsArray.Num()));
|
|
297
|
+
|
|
298
|
+
SendResponse(BuildImpSuccessResponse(CorrId, Data) + TEXT("\n"));
|
|
299
|
+
return false;
|
|
300
|
+
}), 0.0f);
|
|
301
|
+
});
|
|
302
|
+
|
|
189
303
|
// -----------------------------------------------------------------------
|
|
190
304
|
// import.fbx (IMP-01)
|
|
191
305
|
// Imports an FBX file to a UE content path.
|
|
@@ -208,9 +208,7 @@ static FString TakeScreenshotToFile(int32 Width, int32 Height)
|
|
|
208
208
|
return FString();
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// Enable realtime rendering so the viewport
|
|
212
|
-
// Without this, the editor viewport only renders when "dirty" and
|
|
213
|
-
// FScreenshotRequest never gets picked up on subsequent calls.
|
|
211
|
+
// Enable realtime rendering so the viewport renders frames continuously.
|
|
214
212
|
ViewportClient->SetRealtime(true);
|
|
215
213
|
|
|
216
214
|
const FString Timestamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S_%s"));
|
|
@@ -220,13 +218,22 @@ static FString TakeScreenshotToFile(int32 Width, int32 Height)
|
|
|
220
218
|
GScreenshotResolutionX = Width;
|
|
221
219
|
GScreenshotResolutionY = Height;
|
|
222
220
|
|
|
223
|
-
//
|
|
221
|
+
// Force a complete render cycle BEFORE requesting the screenshot.
|
|
222
|
+
// This ensures the viewport has rendered the current camera state.
|
|
223
|
+
// Without this, subsequent screenshots within the same session fail
|
|
224
|
+
// because FScreenshotRequest only fires once per rendered frame.
|
|
224
225
|
ViewportClient->Viewport->Invalidate();
|
|
226
|
+
GEditor->RedrawAllViewports();
|
|
227
|
+
FlushRenderingCommands();
|
|
225
228
|
|
|
226
|
-
//
|
|
227
|
-
// the renderer will process this on the next frame.
|
|
229
|
+
// Now request the screenshot — the next frame render will capture it.
|
|
228
230
|
FScreenshotRequest::RequestScreenshot(FilePath, false /* bShowUI */, false /* bAddFilenameSuffix */);
|
|
229
231
|
|
|
232
|
+
// Force another render to actually capture the screenshot.
|
|
233
|
+
ViewportClient->Viewport->Invalidate();
|
|
234
|
+
GEditor->RedrawAllViewports();
|
|
235
|
+
FlushRenderingCommands();
|
|
236
|
+
|
|
230
237
|
return FilePath;
|
|
231
238
|
}
|
|
232
239
|
|