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>
248 lines
12 KiB
Python
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
|