saumwacht-godot/assets-source/phase4/blender/pipeline.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

248 lines
12 KiB
Python

"""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('<I4s', raw, offset)
payload = raw[offset + 8:offset + 8 + size]
if kind == b'JSON':
doc = json.loads(payload)
for material in doc.get('materials', []):
logical_name = material.get('extras', {}).get('material_name')
if logical_name:
material['name'] = logical_name
names = set()
for node in doc.get('nodes', []):
name = node.get('extras', {}).get('part_name')
if name:
if name in names:
raise ValueError('Duplicate logical part: ' + name)
names.add(name)
node['name'] = name
payload = json.dumps(doc, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
payload += b' ' * (-len(payload) % 4)
chunks.append(struct.pack('<I4s', len(payload), kind) + payload)
offset += 8 + size
content = b''.join(chunks)
path.write_bytes(struct.pack('<4sII', b'glTF', 2, len(content) + 12) + content)
def _mesh_statistics(objects):
triangles = 0
for obj in objects:
if obj.type == 'MESH':
obj.data.calc_loop_triangles()
triangles += len(obj.data.loop_triangles)
return triangles
def build_one(asset_id, export_dir=None, blend_dir=None, keep_scene=True,
height=None, palette_overrides=None):
spec = get_spec(asset_id)
if height is not None:
if height <= 0:
raise ValueError('HEIGHT must be positive')
spec['height'] = height
export_dir = Path(export_dir or DEFAULT_EXPORT_DIR).expanduser().resolve()
blend_dir = Path(blend_dir or DEFAULT_BLEND_DIR).expanduser().resolve()
export_dir.mkdir(parents=True, exist_ok=True)
blend_dir.mkdir(parents=True, exist_ok=True)
record_dir = SOURCE / 'records' if export_dir == DEFAULT_EXPORT_DIR.resolve() else export_dir / '_records'
record_dir.mkdir(parents=True, exist_ok=True)
previous_scene = bpy.context.window.scene
scene = bpy.data.scenes.new('Saumwacht_' + asset_id)
scene['saumwacht_generated'] = True
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1.0
bpy.context.window.scene = scene
completed = False
try:
materials = make_materials(spec['palette'], palette_overrides)
if spec['role'] == 'worker':
build_high_worker(spec, materials)
elif spec['role'] == 'core':
build_high_core(spec, materials)
elif spec['faction'] == 'glutpakt':
build_high_glutpakt(spec, materials)
elif spec['faction'] == 'nebelchor':
build_nebelchor(spec, materials)
elif spec['faction'] == 'rift':
build_high_enemy(spec, materials)
elif spec['faction'] == 'nietenkonvent':
build_nietenkonvent(spec, materials)
elif spec['faction'] == 'wurzelbund':
build_high_wurzelbund(spec, materials)
elif spec['faction'] == 'neutral':
build_neutral(spec, materials)
else:
_builder(spec['role'])(spec, materials)
if (spec['faction'] == 'neutral' and spec['role'] != 'worker') or spec['role'] == 'lane':
enhance_existing(spec, materials)
if spec.get("detail_level") == "high":
sculpt_game_surfaces(spec)
finalize_high_geometry()
objects = list(scene.objects)
_bake_parts(objects)
factor = _fit_to_cell(objects, spec)
low, high = bounds(objects)
triangles = _mesh_statistics(objects)
cap = int(spec.get('triangle_budget', 3000 if spec['role'] in ('core', 'lane') else 1800))
if triangles > 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