"""Build in an isolated scene, preserve editable parts, export selected GLB. An existing user scene is never cleared. Single entry points leave the generated scene open; batch generation removes only its own temporary objects afterwards. """ import hashlib import json from pathlib import Path import struct import bpy from mathutils import Vector from common import empty, make_materials from catalog import DEFAULT_BLEND_DIR, DEFAULT_EXPORT_DIR, PROJECT, SOURCE, get_spec, specs from reference_glutpakt import build_glutpakt from reference_high_detail import build_high_glutpakt, build_high_enemy, enhance_existing, finalize_high_geometry from reference_nebelchor import build_nebelchor from reference_nietenkonvent import build_nietenkonvent from reference_wurzelbund import build_wurzelbund from reference_living_detail import build_high_wurzelbund, build_high_worker, build_high_core from reference_neutral import build_neutral from reference_sculpt_detail import sculpt_game_surfaces, add_offline_sculpt_modifiers, add_offline_materials def _builder(role): if role in ('melee', 'tank'): import frontline return getattr(frontline, 'build_' + role) if role in ('ranged', 'artillery', 'support'): import backline return getattr(backline, 'build_' + role) import environment return getattr(environment, 'build_' + role) def bounds(objects): bpy.context.view_layer.update() points = [obj.matrix_world @ Vector(corner) for obj in objects if obj.type == 'MESH' for corner in obj.bound_box] if not points: raise ValueError('No mesh geometry was generated') return (Vector([min(point[i] for point in points) for i in range(3)]), Vector([max(point[i] for point in points) for i in range(3)])) def _bake_parts(objects): """Keep each part's origin, but bake its local rotation/scale into the mesh.""" for obj in objects: if obj.type != 'MESH': continue obj.data.transform(obj.matrix_basis.to_3x3().to_4x4()) obj.rotation_euler = (0, 0, 0) obj.scale = (1, 1, 1) obj.data.update() def _fit_to_cell(objects, spec): low, high = bounds(objects) dims = high - low if spec['role'] == 'lane': # Tile top stays at Z=0; thickness and decorative lip may cross that plane. return 1.0 factor = min(spec['height'] / dims.z, spec['footprint'] / max(dims.x, dims.y)) center = Vector(((low.x + high.x) / 2, (low.y + high.y) / 2, low.z)) for obj in objects: obj.location = (obj.location - center) * factor obj.scale *= factor _bake_parts(objects) return factor def _canonical_glb_names(path): """Make GLB part names independent of other objects open in Blender. Blender may add .001 to datablock names in a populated session. Exported custom properties retain logical part names; this changes JSON names only, preserving all mesh buffers and material data produced by bpy's exporter. """ raw = path.read_bytes() magic, version, length = struct.unpack_from('<4sII', raw) if (magic, version, length) != (b'glTF', 2, len(raw)): raise ValueError('Invalid GLB container') chunks, offset = [], 12 while offset < len(raw): size, kind = struct.unpack_from(' cap: raise ValueError(f'{asset_id}: {triangles} triangles exceeds {cap}') root = empty('Model_' + asset_id) root['asset_id'] = asset_id root['faction'] = spec['faction'] root['role'] = spec['role'] root['forward_axis'] = '+Z in glTF/Godot' root['phase'] = 7 if spec.get('detail_level') == 'high' else 4 for obj in objects: obj.parent = root marker = empty('Forward', (0, -0.75, 0)) marker.parent = root bpy.ops.object.select_all(action='DESELECT') for obj in scene.objects: obj.select_set(True) bpy.context.view_layer.objects.active = root glb_path = export_dir / spec['file'] bpy.ops.export_scene.gltf(filepath=str(glb_path), export_format='GLB', use_selection=True, use_active_scene=True, export_yup=True, export_apply=True, export_materials='EXPORT', export_extras=True, export_cameras=False, export_lights=False, export_animations=False) _canonical_glb_names(glb_path) blend_path = blend_dir / (asset_id + '.blend') # Offline sculpt detail is saved only after the runtime export. add_offline_sculpt_modifiers(objects) add_offline_materials(objects, PROJECT) # Write this scene and its dependencies, never the user's other scenes. bpy.data.libraries.write(str(blend_path), {scene}, path_remap='RELATIVE_ALL', fake_user=True, compress=True) resource_path = 'res://' + glb_path.relative_to(PROJECT).as_posix() if glb_path.is_relative_to(PROJECT) else None record = {**spec, 'resource_path': resource_path, 'export_path': str(glb_path), 'blend_path': str(blend_path), 'triangles': triangles, 'offline_sculpt_parts': sum(bool(o.get('sculpt_surface')) for o in objects), 'offline_render_triangles': sum(len(o.data.loop_triangles)*(16 if o.get('sculpt_surface') else 1) for o in objects if o.type == 'MESH'), 'mesh_parts': sum(obj.type == 'MESH' for obj in objects), 'parts': [obj.get('part_name', obj.name) for obj in objects], 'bounds_blender': {'min': list(low), 'max': list(high)}, 'bounds_godot': {'min': [low.x, low.z, -high.y], 'max': [high.x, high.z, -low.y]}, 'scale_applied': factor, 'blender_version': bpy.app.version_string, 'sha256': hashlib.sha256(glb_path.read_bytes()).hexdigest(), 'source': 'Original authored bpy geometry. Sculpted surface relief and editable high-resolution modifiers; runtime materials are assigned by Godot.', 'status': 'exported'} (record_dir / (asset_id + '.json')).write_text(json.dumps(record, ensure_ascii=False, indent=2) + '\n') completed = True print(f'EXPORTED {asset_id}: {triangles} triangles, {record["mesh_parts"]} parts -> {glb_path}') return record finally: if not keep_scene or not completed: bpy.context.window.scene = previous_scene owned = list(scene.objects) meshes = [obj.data for obj in owned if obj.type == 'MESH'] materials = {mat for mesh in meshes for mat in mesh.materials if mat is not None} bpy.data.scenes.remove(scene) for obj in owned: if obj.users == 0: bpy.data.objects.remove(obj) for mesh in meshes: if mesh.users == 0: bpy.data.meshes.remove(mesh) for mat in materials: if mat.users == 0: bpy.data.materials.remove(mat) def write_manifest(records=None): """Use authoritative catalog order; reject incomplete or stale exports.""" if records is None: records = [json.loads((SOURCE / 'records' / (spec['id'] + '.json')).read_text()) for spec in specs()] for record in records: path = Path(record['export_path']) if hashlib.sha256(path.read_bytes()).hexdigest() != record['sha256']: raise ValueError('Export changed since record: ' + record['id']) value = {'phase': 4, 'asset_count': len(records), 'blender_version': bpy.app.version_string, 'coordinate_system': 'Metres; glTF/Godot +Y up, +Z forward; ground origin. Lane top at Y=0.', 'assets': records} output = PROJECT / 'assets' / 'phase4_manifest.json' if Path(records[0]['export_path']).parent != DEFAULT_EXPORT_DIR.resolve(): output = Path(records[0]['export_path']).parent / 'phase4_manifest.json' output.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n') return records