@gripforgeai/mcp 0.1.9 → 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.
@@ -0,0 +1,622 @@
1
+ """GripForge native map adapter, UE 5.7. Recipe-independent; consumes plan.json only.
2
+
3
+ Run in the full editor with -ExecutePythonScript=<this file> -GFMapPlan=<plan.json>.
4
+ The common scene, bindings and completed work stay on disk. Never saves native source packages.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ import sys
12
+ import time
13
+ import traceback
14
+ from pathlib import Path
15
+ import unreal
16
+
17
+ sys.path.insert(0, str(Path(__file__).parent))
18
+ from unreal_scene_math import world_matrices, native_transform, point, identity, exposure_brightness
19
+ from unreal_spawn_review import SpawnReview
20
+
21
+ unreal.EditorPythonScripting.set_keep_python_script_alive(True)
22
+ match = re.search(r'-GFMapPlan=(?:"([^"]+)"|(\S+))', unreal.SystemLibrary.get_command_line())
23
+ if not match:
24
+ raise RuntimeError('Missing -GFMapPlan')
25
+ plan_file = Path(match.group(1) or match.group(2)).resolve()
26
+ job_dir = plan_file.parent
27
+ plan_bytes = plan_file.read_bytes()
28
+ plan = json.loads(plan_bytes)
29
+ plan_hash = hashlib.sha256(plan_bytes).hexdigest()
30
+ status_file = job_dir / 'status.json'
31
+ state = json.loads(status_file.read_text()) if status_file.exists() else {}
32
+ if state.get('plan_hash') not in (None, plan_hash):
33
+ raise RuntimeError('Immutable job plan changed. Prepare a new work revision.')
34
+ state.update(plan_hash=plan_hash, lifecycle='work', target_level=plan['target_level'], pid=os.getpid())
35
+ actors = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
36
+ levels = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
37
+ assets = unreal.EditorAssetLibrary
38
+ folder = plan['asset_folder']
39
+ existing, sources, materials, world = {}, {}, {}, None
40
+ nodes = {n['id']: n for n in plan['document']['nodes']}
41
+ matrices = world_matrices(list(nodes.values()))
42
+
43
+
44
+ def progress(stage, **values):
45
+ state.update(stage=stage, updated_at=time.time(), **values)
46
+ temp = status_file.with_suffix('.tmp')
47
+ temp.write_text(json.dumps(state, indent=2))
48
+ temp.replace(status_file)
49
+ unreal.log('GRIPFORGE_MAP ' + stage)
50
+
51
+
52
+ def save():
53
+ if world and not levels.save_current_level():
54
+ raise RuntimeError('Cannot save the work level.')
55
+
56
+
57
+ def cancelled():
58
+ file = job_dir / 'control.json'
59
+ if file.exists() and json.loads(file.read_text()).get('cancel'):
60
+ # Geometry was checkpointed before the PIE review; never save a play world.
61
+ if not levels.is_in_play_in_editor():
62
+ save()
63
+ raise InterruptedError('Cancelled at a saved checkpoint. Relaunch the same job to resume.')
64
+
65
+
66
+ def vec(p):
67
+ return unreal.Vector(p['x'] * 100, p['z'] * 100, p['y'] * 100)
68
+
69
+
70
+ def rgb(value, gain=1):
71
+ # Scene hex colours are sRGB, Unreal's LinearColor is linear.
72
+ values = [int(value[i:i+2], 16)/255 for i in (1, 3, 5)]
73
+ return unreal.LinearColor(*[(v/12.92 if v <= .04045 else ((v+.055)/1.055)**2.4)*gain for v in values], 1)
74
+
75
+
76
+ def apply_transform(actor, matrix, source=None):
77
+ translation, rotation, scale = native_transform(matrix, source)
78
+ actor.set_actor_transform(unreal.Transform(location=unreal.Vector(*translation), rotation=unreal.Quat(*rotation).rotator(), scale=unreal.Vector(*scale)), False, True)
79
+
80
+
81
+ def new_actor(kind, key, name, group):
82
+ if key in existing:
83
+ return existing[key]
84
+ actor = actors.spawn_actor_from_class(kind, unreal.Vector())
85
+ if not actor:
86
+ raise RuntimeError('Cannot spawn ' + key)
87
+ actor.set_actor_label(name)
88
+ actor.set_folder_path('GripForge/' + group)
89
+ if kind == unreal.TargetPoint:
90
+ actor.root_component.set_mobility(unreal.ComponentMobility.STATIC)
91
+ actor.tags = ['GripForge.Scene', 'gf:' + key, 'gf:job:' + plan_hash]
92
+ existing[key] = actor
93
+ return actor
94
+
95
+
96
+ def finished(actor):
97
+ return 'gf:complete' in [str(t) for t in actor.tags]
98
+
99
+
100
+ def finish(actor, node=None):
101
+ tags = [str(t) for t in actor.tags if str(t) != 'gf:complete' and not str(t).startswith('gf:data:')]
102
+ if node:
103
+ # Carry the portable data with the editable native actor, including polygons, roles and seeds.
104
+ payload = {k:v for k,v in node.items() if k not in ('heights', 'splat')}
105
+ tags.append('gf:data:' + json.dumps(payload, separators=(',', ':')))
106
+ actor.set_actor_hidden_in_game(not node['visible'] or node['type'] in ('group','path','region','zone'))
107
+ actor.set_is_temporarily_hidden_in_editor(not node['visible'])
108
+ actor.tags = tags + ['gf:complete']
109
+
110
+
111
+ def owned_asset(path):
112
+ if not assets.does_asset_exist(path):
113
+ return None
114
+ asset = unreal.load_asset(path)
115
+ if assets.get_metadata_tag(asset, 'GripForgeJob') != plan_hash:
116
+ raise RuntimeError('Existing output asset is not owned by this job: ' + path)
117
+ return asset
118
+
119
+
120
+ def save_asset(asset):
121
+ assets.set_metadata_tag(asset, 'GripForgeJob', plan_hash)
122
+ if not assets.save_loaded_asset(asset):
123
+ raise RuntimeError('Cannot save generated asset ' + asset.get_path_name())
124
+
125
+
126
+ def material_texture(source, channel, normal_format):
127
+ if isinstance(source, str):
128
+ texture = unreal.load_asset(source)
129
+ if not isinstance(texture, unreal.Texture):
130
+ raise RuntimeError('Missing PBR texture: ' + source)
131
+ return texture
132
+ # Never alter source texture packages. Each interpretation is an owned imported asset:
133
+ # the same bytes can be sampled as sRGB color and as a linear mask independently.
134
+ interpretation = 'normal_' + normal_format if channel == 'normal' else 'srgb' if channel in ('color', 'emissive') else 'linear'
135
+ name = 'T_' + source['sha256'][:32] + '_' + interpretation
136
+ dest = folder + '/' + name
137
+ existing_texture = owned_asset(dest)
138
+ if existing_texture:
139
+ return existing_texture
140
+ task = unreal.AssetImportTask()
141
+ task.filename = source['file']
142
+ task.destination_path = folder
143
+ task.destination_name = name
144
+ task.automated = True
145
+ task.replace_existing = False
146
+ task.save = False
147
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
148
+ texture = unreal.load_asset(dest)
149
+ if not isinstance(texture, unreal.Texture2D):
150
+ raise RuntimeError('Cannot import portable texture: ' + source['assetId'])
151
+ texture.set_editor_property('srgb', channel in ('color', 'emissive'))
152
+ texture.set_editor_property('compression_settings', unreal.TextureCompressionSettings.TC_NORMALMAP if channel == 'normal' else unreal.TextureCompressionSettings.TC_DEFAULT if channel in ('color', 'emissive') else unreal.TextureCompressionSettings.TC_MASKS)
153
+ if channel == 'normal':
154
+ texture.set_editor_property('flip_green_channel', normal_format == 'opengl')
155
+ save_asset(texture)
156
+ return texture
157
+
158
+
159
+ def material(definition):
160
+ key = definition['id']
161
+ if key in materials:
162
+ return materials[key]
163
+ dest = folder + '/M_' + key
164
+ existing_material = owned_asset(dest)
165
+ mat = existing_material or unreal.AssetToolsHelpers.get_asset_tools().create_asset('M_'+key, folder, unreal.Material, unreal.MaterialFactoryNew())
166
+ edit = unreal.MaterialEditingLibrary
167
+ if existing_material:
168
+ edit.delete_all_material_expressions(mat)
169
+ def connect(expression, output, prop):
170
+ if prop==unreal.MaterialProperty.MP_BASE_COLOR and definition.get('vertex_colors'):
171
+ vertex=edit.create_material_expression(mat,unreal.MaterialExpressionVertexColor,-100,-180)
172
+ product=edit.create_material_expression(mat,unreal.MaterialExpressionMultiply,50,-100)
173
+ edit.connect_material_expressions(expression,output,product,'A')
174
+ edit.connect_material_expressions(vertex,'RGB',product,'B')
175
+ edit.connect_material_property(product,'',prop)
176
+ else:
177
+ edit.connect_material_property(expression,output,prop)
178
+ channels = [('color', unreal.MaterialProperty.MP_BASE_COLOR), ('normal', unreal.MaterialProperty.MP_NORMAL),
179
+ ('roughness', unreal.MaterialProperty.MP_ROUGHNESS), ('metalness', unreal.MaterialProperty.MP_METALLIC),
180
+ ('ao', unreal.MaterialProperty.MP_AMBIENT_OCCLUSION), ('emissive', unreal.MaterialProperty.MP_EMISSIVE_COLOR)]
181
+ textures = definition.get('textures', {})
182
+ uv = edit.create_material_expression(mat, unreal.MaterialExpressionTextureCoordinate, -650, -220)
183
+ uv.set_editor_property('u_tiling', definition.get('repeat', [1, 1])[0])
184
+ uv.set_editor_property('v_tiling', definition.get('repeat', [1, 1])[1])
185
+ for index, (channel, prop) in enumerate(channels):
186
+ if channel in textures:
187
+ texture = material_texture(textures[channel], channel, definition.get('normal_format', 'opengl'))
188
+ node = edit.create_material_expression(mat, unreal.MaterialExpressionTextureSample, -400, index*180)
189
+ node.texture = texture
190
+ edit.connect_material_expressions(uv, '', node, 'UVs')
191
+ node.sampler_type = (unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL if channel=='normal' else
192
+ unreal.MaterialSamplerType.SAMPLERTYPE_COLOR if texture.get_editor_property('srgb') else
193
+ unreal.MaterialSamplerType.SAMPLERTYPE_MASKS if texture.get_editor_property('compression_settings')==unreal.TextureCompressionSettings.TC_MASKS else unreal.MaterialSamplerType.SAMPLERTYPE_LINEAR_COLOR)
194
+ # Separate grayscale maps and glTF-style ORM images use the same channels as Three.js.
195
+ output='RGB' if channel in ('color','normal','emissive') else ('G' if channel=='roughness' else 'B' if channel=='metalness' else 'R') if isinstance(textures[channel],dict) else 'R'
196
+ if channel not in ('normal','ao'):
197
+ factor=edit.create_material_expression(mat,unreal.MaterialExpressionConstant3Vector if channel in ('color','emissive') else unreal.MaterialExpressionConstant,-600,index*180+70)
198
+ if channel in ('color','emissive'):
199
+ factor.constant=rgb(definition.get(channel,'#ffffff'),definition.get('emissive_intensity',1) if channel=='emissive' else 1)
200
+ else:
201
+ factor.r=definition.get(channel,1)
202
+ product=edit.create_material_expression(mat,unreal.MaterialExpressionMultiply,-150,index*180)
203
+ edit.connect_material_expressions(node,output,product,'A')
204
+ edit.connect_material_expressions(factor,'',product,'B')
205
+ connect(product,'',prop)
206
+ else:
207
+ connect(node,output,prop)
208
+ elif channel == 'color':
209
+ node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant3Vector, -400, index*180)
210
+ node.constant = rgb(definition.get('color','#ffffff'))
211
+ connect(node, '', prop)
212
+ elif channel in ('roughness','metalness'):
213
+ node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant, -400, index*180)
214
+ node.r = definition.get(channel, .8 if channel=='roughness' else 0)
215
+ edit.connect_material_property(node, '', prop)
216
+ if definition.get('emissive') and 'emissive' not in textures:
217
+ node = edit.create_material_expression(mat, unreal.MaterialExpressionConstant3Vector, -400, 800)
218
+ node.constant = rgb(definition['emissive'], definition.get('emissive_intensity',1))
219
+ edit.connect_material_property(node, '', unreal.MaterialProperty.MP_EMISSIVE_COLOR)
220
+ if definition.get('unlit'):
221
+ mat.set_editor_property('shading_model', unreal.MaterialShadingModel.MSM_UNLIT)
222
+ edit.recompile_material(mat)
223
+ save_asset(mat)
224
+ materials[key] = mat
225
+ return mat
226
+
227
+
228
+ def imported_mesh(source):
229
+ name = 'SM_' + source['assetId'] + '_' + source['revisionId'][:24]
230
+ dest = folder + '/' + name
231
+ mesh = owned_asset(dest)
232
+ if mesh:
233
+ return mesh
234
+ task = unreal.AssetImportTask()
235
+ task.filename = source['file']
236
+ task.destination_path = folder + '/Imported_' + source['assetId']
237
+ task.destination_name = name
238
+ task.automated = True
239
+ task.replace_existing = False
240
+ task.save = False
241
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
242
+ imported = [unreal.load_asset(p) for p in task.imported_object_paths]
243
+ meshes = [asset for asset in imported if isinstance(asset, unreal.StaticMesh)]
244
+ if len(meshes) != 1:
245
+ raise RuntimeError('Each GLB binding must contain exactly one static mesh; split source assets before delivery: ' + source['file'])
246
+ mesh = meshes[0]
247
+ for asset in imported:
248
+ save_asset(asset)
249
+ if not assets.rename_asset(mesh.get_path_name(), dest):
250
+ raise RuntimeError('Cannot name generated mesh ' + name)
251
+ mesh = unreal.load_asset(dest)
252
+ save_asset(mesh)
253
+ return mesh
254
+
255
+
256
+ def geometry_spline(actor, node):
257
+ spline = actor.get_component_by_class(unreal.SplineComponent)
258
+ if not spline:
259
+ subsystem = unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
260
+ handles = subsystem.k2_gather_subobject_data_for_instance(actor)
261
+ handle, failure = subsystem.add_new_subobject(unreal.AddNewSubobjectParams(parent_handle=handles[0], new_class=unreal.SplineComponent))
262
+ data = unreal.SubobjectDataBlueprintFunctionLibrary.get_data(handle)
263
+ spline = unreal.SubobjectDataBlueprintFunctionLibrary.get_associated_object(data)
264
+ if not isinstance(spline, unreal.SplineComponent):
265
+ raise RuntimeError('Cannot create editable scene spline: ' + str(failure))
266
+ points = node['points'] if node['type']=='path' else [{'x':p[0],'y':0,'z':p[1]} for p in node['polygon']]
267
+ spline.set_spline_points([vec(p) for p in points], unreal.SplineCoordinateSpace.LOCAL, True)
268
+ for i in range(len(points)):
269
+ spline.set_spline_point_type(i, unreal.SplinePointType.LINEAR, False)
270
+ spline.set_closed_loop(node['type']!='path', True)
271
+ spline.set_editor_property('component_tags', ['GripForge.Geometry', 'width_m:'+str(node.get('width',0))])
272
+
273
+
274
+ def environment(node):
275
+ settings = plan['environment']; prefix = node['id']
276
+ lighting = node.get('lighting', {})
277
+ sun = new_actor(unreal.DirectionalLight, prefix+'_sun', node['name']+' · Sun', 'Environment')
278
+ direction = vec(node.get('sky',{}).get('sun', {'x':-6,'y':6,'z':3}))
279
+ sun.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(direction,unreal.Vector()), False)
280
+ sun.light_component.set_intensity(settings['sun_lux'])
281
+ sun.light_component.set_light_color(rgb(lighting.get('sunColor','#ffffff')))
282
+ sun.light_component.set_editor_property('atmosphere_sun_light', True)
283
+ sky = new_actor(unreal.SkyLight, prefix+'_sky', node['name']+' · Sky light', 'Environment')
284
+ sky.light_component.set_intensity(settings['sky_intensity'])
285
+ sky.light_component.set_editor_property('real_time_capture', True)
286
+ new_actor(unreal.SkyAtmosphere, prefix+'_atmosphere', node['name']+' · Atmosphere', 'Environment')
287
+ if node.get('fog'):
288
+ fog = new_actor(unreal.ExponentialHeightFog, prefix+'_fog', node['name']+' · Fog', 'Environment')
289
+ fog.component.set_editor_property('fog_density', settings['fog_density'])
290
+ fog.component.set_editor_property('fog_inscattering_luminance', rgb(node['fog']['color']))
291
+ pp = new_actor(unreal.PostProcessVolume, prefix+'_exposure', node['name']+' · Exposure', 'Environment')
292
+ pp.set_editor_property('unbound', True)
293
+ post = pp.get_editor_property('settings')
294
+ extended=unreal.SystemLibrary.get_console_variable_int_value('r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange')==1
295
+ exposure=exposure_brightness(settings['exposure_ev100'],extended)
296
+ for prop, value in [('override_auto_exposure_min_brightness',True),('override_auto_exposure_max_brightness',True),
297
+ ('auto_exposure_min_brightness',exposure),('auto_exposure_max_brightness',exposure),
298
+ ('override_auto_exposure_bias',True),('auto_exposure_bias',0.0),
299
+ ('override_bloom_intensity',True),('bloom_intensity',settings['bloom_intensity'])]:
300
+ post.set_editor_property(prop,value)
301
+ pp.set_editor_property('settings',post)
302
+
303
+
304
+ def build_node(node):
305
+ key, kind = node['id'], node['type']
306
+ if key in existing and finished(existing[key]) and kind!='environment':
307
+ return
308
+ source = None
309
+ group = nodes.get(node['parentId'], {}).get('name', kind.title())
310
+ if kind in ('asset', 'terrain'):
311
+ ref = node['asset'] if kind=='asset' else next(t for t in plan['terrains'] if t['nodeId']==key)
312
+ source, resource = sources[(ref['assetId'], ref['revisionId'])]
313
+ cls = resource if source['kind']=='native_actor' else unreal.StaticMeshActor
314
+ actor = new_actor(cls, key, node['name'], group)
315
+ if source['kind']=='native_actor':
316
+ for prop,value in plan.get('actor_properties',{}).get(key,{}).items():
317
+ actor.set_editor_property(prop,value)
318
+ if any(p['nodeId']==key and p.get('hide_static_meshes') for p in plan.get('vfx_previews',[])):
319
+ for component in actor.get_components_by_class(unreal.StaticMeshComponent):
320
+ component.set_visibility(False,False)
321
+ if source['kind']!='native_actor':
322
+ actor.static_mesh_component.set_static_mesh(resource)
323
+ elif kind=='primitive':
324
+ shape = {'box':'Cube','sphere':'Sphere','cylinder':'Cylinder','plane':'Plane'}[node['shape']]
325
+ actor = new_actor(unreal.StaticMeshActor, key, node['name'], group)
326
+ actor.static_mesh_component.set_static_mesh(unreal.load_asset('/Engine/BasicShapes/'+shape))
327
+ mat = dict(node['material'], id='primitive_'+key)
328
+ actor.static_mesh_component.set_material(0,material(mat))
329
+ elif kind=='camera':
330
+ actor = new_actor(unreal.CameraActor, key, node['name'], 'Cameras')
331
+ actor.camera_component.set_field_of_view(node['fov'])
332
+ elif kind=='light':
333
+ cls = {'point':unreal.PointLight,'directional':unreal.DirectionalLight,'ambient':unreal.SkyLight,'hemisphere':unreal.SkyLight}[node['lightKind']]
334
+ actor = new_actor(cls,key,node['name'],group)
335
+ actor.light_component.set_intensity(node['intensity'])
336
+ actor.light_component.set_light_color(rgb(node['color']))
337
+ actor.light_component.set_cast_shadows(node['shadows'])
338
+ if node['lightKind']=='point':
339
+ actor.light_component.set_attenuation_radius(node['range']*100)
340
+ else:
341
+ actor = new_actor(unreal.TargetPoint, key, node['name'], group)
342
+ apply_transform(actor, matrices[key], source)
343
+ if kind=='primitive':
344
+ size = node['size']
345
+ s = actor.get_actor_scale3d()
346
+ # UE basic primitives are 100 cm across. Plane lies in native XY, as scene planes lie in XZ.
347
+ actor.set_actor_scale3d(unreal.Vector(s.x*size[0],s.y*size[2],s.z*size[1]))
348
+ if kind=='camera':
349
+ parent = matrices[node['parentId']] if node['parentId'] else identity()
350
+ target = point(parent,[node['target'][k] for k in 'xyz'])
351
+ actor.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(actor.get_actor_location(),unreal.Vector(target[0]*100,target[2]*100,target[1]*100)),False)
352
+ if kind in ('path','region','zone'):
353
+ geometry_spline(actor,node)
354
+ if kind=='environment':
355
+ environment(node)
356
+ if kind in ('primitive','terrain','asset') and hasattr(actor,'static_mesh_component'):
357
+ mode = plan['collision'].get(key, 'complex' if kind=='terrain' else 'source')
358
+ component = actor.static_mesh_component
359
+ if mode=='complex':
360
+ if not source or source['kind']!='glb':
361
+ raise RuntimeError('Cannot change shared native collision data: '+key)
362
+ mesh = component.static_mesh
363
+ mesh.get_editor_property('body_setup').set_editor_property('collision_trace_flag',unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE)
364
+ save_asset(mesh)
365
+ component.set_collision_profile_name('NoCollision' if mode=='none' else 'BlockAll')
366
+ for binding in plan['material_bindings']:
367
+ if binding['nodeId']==key:
368
+ component.set_material(binding['slot'],materials[binding['materialId']])
369
+ finish(actor,node)
370
+
371
+
372
+ def build():
373
+ global world, existing
374
+ progress('checking_sources', error=None)
375
+ if plan['schema']!='gripforge.unreal-scene.v1' or any(not p.startswith('/Game/GripForge/Maps/') for p in (plan['target_level'],folder)):
376
+ raise RuntimeError('Invalid native work output namespace')
377
+ project = Path(plan['project_file']).resolve().parent
378
+ current = Path(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir())).resolve()
379
+ if project != current:
380
+ raise RuntimeError('The active Unreal project does not match the pinned delivery project')
381
+ for source in plan['assets']:
382
+ cancelled()
383
+ path = Path(source['file']) if source['kind']=='glb' else project/'Content'/(source['path'][6:]+'.uasset')
384
+ if hashlib.sha256(path.read_bytes()).hexdigest()!=source['sha256']:
385
+ raise RuntimeError('Source revision changed: '+source['assetId'])
386
+ texture_files = {t['file']: t for m in plan['materials'] for t in m.get('textures', {}).values() if isinstance(t, dict)}
387
+ for source in texture_files.values():
388
+ cancelled()
389
+ if hashlib.sha256(Path(source['file']).read_bytes()).hexdigest() != source['sha256']:
390
+ raise RuntimeError('Texture revision changed: ' + source['assetId'])
391
+ # Check every transform before creating a level, including shear / rotated non-uniform parents.
392
+ for m in matrices.values():
393
+ native_transform(m)
394
+ resuming=assets.does_asset_exist(plan['target_level'])
395
+ if resuming:
396
+ # Loading a World with load_asset and retaining its Python wrapper across LoadLevel
397
+ # prevents UE's old-world GC and crashes the editor. Inspect ownership after opening it.
398
+ if not levels.load_level(plan['target_level']):
399
+ raise RuntimeError('Cannot resume native work level')
400
+ else:
401
+ if not levels.new_level(plan['target_level']):
402
+ raise RuntimeError('Cannot create native work level')
403
+ opened_world=unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
404
+ if resuming and assets.get_metadata_tag(opened_world,'GripForgeJob')!=plan_hash:
405
+ raise RuntimeError('Target level exists without this job ownership marker. Use a new versioned target.')
406
+ world=opened_world
407
+ assets.set_metadata_tag(world,'GripForgeJob',plan_hash)
408
+ assets.set_metadata_tag(world,'GripForgeLifecycle','work')
409
+ save()
410
+ existing = {str(tag)[3:]:actor for actor in actors.get_all_level_actors() for tag in actor.tags
411
+ if str(tag).startswith('gf:') and not str(tag).startswith(('gf:job:','gf:data:')) and str(tag)!='gf:complete'}
412
+ progress('importing_assets',level_created=True,completed_assets=0,total_assets=len(plan['assets']))
413
+ for index,source in enumerate(plan['assets']):
414
+ cancelled()
415
+ progress('importing_assets',completed_assets=index,current_asset=source['assetId'])
416
+ if source['kind']=='glb':
417
+ resource=imported_mesh(source)
418
+ elif source['kind']=='native_actor':
419
+ resource=unreal.load_class(None, source['path']+'.'+source['path'].rsplit('/',1)[1]+'_C')
420
+ else:
421
+ resource=unreal.load_asset(source['path'])
422
+ if not resource:
423
+ raise RuntimeError('Cannot load asset '+source['assetId'])
424
+ sources[(source['assetId'],source['revisionId'])]=(source,resource)
425
+ progress('building_materials',completed_assets=len(plan['assets']),current_asset=None)
426
+ for index,definition in enumerate(plan['materials']):
427
+ cancelled()
428
+ progress('building_materials', completed_materials=index, total_materials=len(plan['materials']), current_material=definition['id'])
429
+ material(definition)
430
+ progress('building_materials', completed_materials=len(plan['materials']), current_material=None, imported_texture_files=len(texture_files))
431
+ ordered=sorted(nodes.values(),key=lambda n: 0 if n['type']=='terrain' else 1 if n['type']=='group' else 2)
432
+ for index,node in enumerate(ordered):
433
+ cancelled()
434
+ build_node(node)
435
+ if index%40==0:
436
+ save()
437
+ progress('placing_nodes',completed_nodes=index+1,total_nodes=len(nodes),progress=round((index+1)/len(nodes)*.85,3))
438
+ for node in nodes.values():
439
+ if node['parentId']:
440
+ parent_actor=existing[node['parentId']]
441
+ if isinstance(parent_actor,unreal.TargetPoint):
442
+ parent_actor.root_component.set_mobility(unreal.ComponentMobility.STATIC)
443
+ if not existing[node['id']].attach_to_actor(parent_actor, '', unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False):
444
+ raise RuntimeError('Cannot preserve native scene hierarchy: '+node['id'])
445
+ for override in plan.get('niagara_overrides',[]):
446
+ matched=0
447
+ for component in existing[override['nodeId']].get_components_by_class(unreal.NiagaraComponent):
448
+ system=component.get_asset()
449
+ if not system or system.get_path_name().split('.')[0]!=override['system']:
450
+ continue
451
+ for name,value in override['colors'].items():
452
+ component.set_variable_linear_color(name,rgb(value['color'],value['intensity']))
453
+ matched+=1
454
+ if not matched:
455
+ raise RuntimeError('Niagara system not found on requested instance: '+override['nodeId'])
456
+ for start in plan['gameplay']['player_starts']:
457
+ node=nodes[start['nodeId']]
458
+ actor=new_actor(unreal.PlayerStart,'start_'+node['id'],node['name']+' · Player start','Spawn Zones')
459
+ apply_transform(actor,matrices[node['id']])
460
+ actor.set_actor_location(actor.get_actor_location()+vec(start['offset']),False,True)
461
+ actor.set_editor_property('player_start_tag','gf:start_'+node['id'])
462
+ finish(actor)
463
+ mode=plan['gameplay'].get('game_mode')
464
+ if mode:
465
+ cls=unreal.load_class(None,mode)
466
+ if not cls:
467
+ raise RuntimeError('Missing gameplay class '+mode)
468
+ world.get_world_settings().set_editor_property('default_game_mode',cls)
469
+ active=existing.get(plan['document'].get('activeCameraId'))
470
+ if isinstance(active,unreal.CameraActor):
471
+ unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(active.get_actor_location(),active.get_actor_rotation())
472
+ save()
473
+ measurements={}
474
+ for node in nodes.values():
475
+ if node['type']=='terrain':
476
+ lo,hi=existing[node['id']].static_mesh_component.get_local_bounds()
477
+ measured=[(hi.x-lo.x)/100,(hi.y-lo.y)/100]
478
+ if any(abs(a-b)>.1 for a,b in zip(measured,node['size'])):
479
+ raise RuntimeError('Terrain metre/centimetre mismatch: '+str(measured))
480
+ measurements[node['id']]=measured
481
+ checked=0
482
+ for node in nodes.values():
483
+ if node['type'] not in ('asset','terrain'):
484
+ continue
485
+ ref=node['asset'] if node['type']=='asset' else next(t for t in plan['terrains'] if t['nodeId']==node['id'])
486
+ source,_=sources[(ref['assetId'],ref['revisionId'])]
487
+ t,q,s=native_transform(matrices[node['id']],source)
488
+ actor=existing[node['id']]
489
+ location,scale=actor.get_actor_location(),actor.get_actor_scale3d()
490
+ rotation=actor.get_actor_rotation().quaternion()
491
+ delta=max(abs(t[i]-getattr(location,k)) for i,k in enumerate('xyz'))
492
+ scale_delta=max(abs(s[i]-getattr(scale,k)) for i,k in enumerate('xyz'))
493
+ dot=abs(sum(q[i]*getattr(rotation,k) for i,k in enumerate('xyzw')))
494
+ if delta>1 or scale_delta>1e-4 or dot<.99999:
495
+ raise RuntimeError('Construction or attachment changed transform of '+node['id']+'. Bind its editable Blueprint parameters in actor_properties. Delta: '+str((delta,scale_delta,dot)))
496
+ checked+=1
497
+ progress('verifying_gameplay',gameplay_validation=dict(status='pending'),completed_nodes=len(nodes),total_nodes=len(nodes),progress=.9,terrain_m=measurements,verified_instance_transforms=checked,actor_count=len(existing))
498
+
499
+
500
+ def warmup_vfx():
501
+ count=0
502
+ for preview in plan.get('vfx_previews',[]):
503
+ components=existing[preview['nodeId']].get_components_by_class(unreal.NiagaraComponent)
504
+ if not components:
505
+ raise RuntimeError('No Niagara components on requested VFX preview: '+preview['nodeId'])
506
+ for component in components:
507
+ component.set_force_solo(True)
508
+ component.set_paused(False)
509
+ component.reinitialize_system()
510
+ component.activate(True)
511
+ component.advance_simulation_by_time(preview['warmup_seconds'],1/60)
512
+ component.set_paused(True)
513
+ count+=1
514
+ state['previewed_vfx_components']=count
515
+
516
+
517
+ try:
518
+ build()
519
+ captures=job_dir/'captures'; captures.mkdir(exist_ok=True)
520
+ queue=list(plan['capture']['camera_ids'])
521
+ captured=[]
522
+ next_shot=time.monotonic()+20
523
+ pending=None
524
+ pending_deadline=0
525
+ pending_started=0
526
+ capture_busy=False
527
+ spawn_review=None
528
+ gameplay_checked=False
529
+ def review_gameplay():
530
+ global spawn_review,gameplay_checked
531
+ if gameplay_checked:
532
+ return True
533
+ config=plan['gameplay'].get('spawn_review',{})
534
+ starts=plan['gameplay']['player_starts']
535
+ if not starts or not config.get('enabled',True):
536
+ result=dict(status='not_run',reason='no_player_starts' if not starts else 'disabled_in_plan')
537
+ else:
538
+ if spawn_review is None:
539
+ expected=[]
540
+ for start in starts:
541
+ location=existing['start_'+start['nodeId']].get_actor_location()
542
+ expected.append(dict(id=start['nodeId'],position=[location.x,location.y,location.z]))
543
+ spawn_review=SpawnReview(expected,config.get('duration_seconds',6),config.get('tolerance_cm',200),config.get('require_grounded',True))
544
+ if spawn_review.started_at is None:
545
+ if not spawn_review.start():
546
+ progress('waiting_for_editor',reason='Spawn review waits for the active user play session to end.')
547
+ return False
548
+ progress('verifying_gameplay',reason='Testing the actual GameMode spawn and floor contact in Play in Editor.')
549
+ return False
550
+ result=spawn_review.poll()
551
+ if result is None:
552
+ return False
553
+ (job_dir/'gameplay-review.json').write_text(json.dumps(result,indent=2))
554
+ progress('verifying_gameplay',gameplay_validation=result,reason=None)
555
+ if result['status']=='failed':
556
+ raise RuntimeError('Gameplay spawn review failed: '+', '.join(result['issues'])+'. Inspect gameplay-review.json; check GameMode spawn overrides and terrain collision.')
557
+ gameplay_checked=True
558
+ return True
559
+ def tick(_dt):
560
+ global next_shot,pending,capture_busy,pending_deadline,pending_started
561
+ if capture_busy or time.monotonic()<next_shot:
562
+ return
563
+ capture_busy=True
564
+ try:
565
+ cancelled()
566
+ if not review_gameplay():
567
+ next_shot=time.monotonic()+.2
568
+ return
569
+ # A user can start PIE while reviewing the imported level. Automation
570
+ # then captures the game's viewport, even when an editor camera was
571
+ # supplied. Preserve the play session and retry its interrupted view.
572
+ if levels.is_in_play_in_editor():
573
+ if pending:
574
+ queue.insert(0,pending)
575
+ pending=None
576
+ if state.get('stage')!='waiting_for_editor':
577
+ progress('waiting_for_editor',reason='Play in Editor is active; camera captures resume when it stops.',captures=captured)
578
+ next_shot=time.monotonic()+3
579
+ return
580
+ if pending:
581
+ file=captures/(pending+'.png')
582
+ if not file.exists() or file.stat().st_size<1024 or file.stat().st_mtime<pending_started:
583
+ if time.monotonic()<pending_deadline:
584
+ next_shot=time.monotonic()+2
585
+ return
586
+ raise RuntimeError('Unreal did not produce requested viewport capture within 180 seconds: '+pending)
587
+ captured.append(str(file)); pending=None
588
+ if not queue:
589
+ unreal.unregister_slate_post_tick_callback(tick_handle)
590
+ for preview in plan.get('vfx_previews',[]):
591
+ for component in existing[preview['nodeId']].get_components_by_class(unreal.NiagaraComponent):
592
+ component.set_paused(False)
593
+ component.set_force_solo(False)
594
+ active=existing.get(plan['document'].get('activeCameraId'))
595
+ if isinstance(active,unreal.CameraActor):
596
+ unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(active.get_actor_location(),active.get_actor_rotation())
597
+ progress('awaiting_visual_review',progress=1,captures=captured)
598
+ return
599
+ key=queue.pop(0); camera=existing[key]
600
+ # Automation capture pumps editor ticks. Reserve the camera before calling UE to
601
+ # prevent a nested tick from consuming the other views or claiming completion early.
602
+ pending=key; pending_started=time.time();next_shot=time.monotonic()+15;pending_deadline=time.monotonic()+180
603
+ progress('capturing',current_camera=key,captures=captured,reason=None)
604
+ warmup_vfx()
605
+ unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(camera.get_actor_location(),camera.get_actor_rotation())
606
+ unreal.AutomationLibrary.take_high_res_screenshot(plan['capture']['width'],plan['capture']['height'],str(captures/(key+'.png')),camera=camera)
607
+ # Shader/PSO compilation may block the call longer than the initial warmup.
608
+ # Start the file wait only after UE has returned to its event loop.
609
+ next_shot=time.monotonic()+5
610
+ pending_deadline=time.monotonic()+180
611
+ except Exception as error:
612
+ if spawn_review:
613
+ spawn_review.close()
614
+ unreal.unregister_slate_post_tick_callback(tick_handle)
615
+ progress('cancelled' if isinstance(error,InterruptedError) else 'failed',error=traceback.format_exc(),captures=captured)
616
+ finally:
617
+ capture_busy=False
618
+ tick_handle=unreal.register_slate_post_tick_callback(tick)
619
+ except Exception as error:
620
+ save()
621
+ progress('cancelled' if isinstance(error,InterruptedError) else 'failed',error=traceback.format_exc())
622
+ unreal.log_error(traceback.format_exc())
@@ -0,0 +1,7 @@
1
+ import { uploadUnrealExport } from '../dist/map-unreal-export-local.js';
2
+ try {
3
+ await uploadUnrealExport(process.argv[2], process.argv[3], process.argv[4], process.env.GRIPFORGE_API_KEY ?? '');
4
+ } catch (error) {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exitCode = 1;
7
+ }