Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
79 lines
4.5 KiB
Python
79 lines
4.5 KiB
Python
"""File -> Export jobs using Blender's registered exporters and RNA validation."""
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import zipfile
|
|
|
|
import bpy
|
|
from server_operator_properties import validated_operator_properties
|
|
|
|
FORMATS = {
|
|
'GLB': ('export_scene.gltf', 'glb', 'use_selection', {'export_format': 'GLB'}),
|
|
'GLTF': ('export_scene.gltf', 'gltf', 'use_selection', {'export_format': 'GLTF_SEPARATE', 'export_texture_dir': 'textures'}),
|
|
'FBX': ('export_scene.fbx', 'fbx', 'use_selection', {'path_mode': 'COPY', 'embed_textures': True}),
|
|
'OBJ': ('wm.obj_export', 'obj', 'export_selected_objects', {'path_mode': 'COPY'}),
|
|
'STL': ('wm.stl_export', 'stl', 'export_selected_objects', {}),
|
|
'PLY': ('wm.ply_export', 'ply', 'export_selected_objects', {}),
|
|
'USD': ('wm.usd_export', 'usdc', 'selected_objects_only', {'export_textures': True, 'relative_paths': True}),
|
|
'ALEMBIC': ('wm.alembic_export', 'abc', 'selected', {}),
|
|
}
|
|
|
|
|
|
def export_scene(root, spec, artifact, output):
|
|
identifier, extension, selection_key, defaults = FORMATS[spec['format']]
|
|
family, name = identifier.split('.')
|
|
if family == 'export_scene':
|
|
import addon_utils
|
|
addon_utils.enable('io_scene_gltf2' if name == 'gltf' else 'io_scene_fbx', default_set=False, persistent=False)
|
|
operator = getattr(getattr(bpy.ops, family), name)
|
|
try:
|
|
rna = operator.get_rna_type()
|
|
except RuntimeError as error:
|
|
raise RuntimeError('NATIVE_EXPORTER_UNAVAILABLE: ' + identifier) from error
|
|
destination = root / 'export'
|
|
destination.mkdir()
|
|
filename = destination / ('scene.' + extension)
|
|
supplied = spec.get('properties', {})
|
|
# Output locations and batch modes belong to this job, never to caller input.
|
|
blocked = {'filepath', 'directory', 'filename', 'files', 'check_existing',
|
|
'filter_glob', 'batch_mode', 'use_batch_own_dir', 'as_background_job',
|
|
'export_format', 'export_texture_dir', 'export_animation',
|
|
'use_selection', 'export_selected_objects', 'selected_objects_only', 'selected'}
|
|
for key in supplied:
|
|
if key in blocked or any(token in key.lower() for token in ('path', 'directory', 'filename')):
|
|
raise RuntimeError('NATIVE_EXPORT_MANAGED_PROPERTY: ' + key)
|
|
values = dict(defaults, **supplied)
|
|
values.update(filepath=str(filename), **{selection_key: spec['selectedOnly']})
|
|
if 'check_existing' in rna.properties: values['check_existing'] = False
|
|
if 'as_background_job' in rna.properties: values['as_background_job'] = False
|
|
if spec['selectedOnly'] and not any(obj.select_get() for obj in bpy.context.view_layer.objects):
|
|
raise RuntimeError('NATIVE_EXPORT_SELECTION_EMPTY')
|
|
values = validated_operator_properties(operator, values)
|
|
if not operator.poll(): raise RuntimeError('NATIVE_EXPORT_CONTEXT_UNAVAILABLE: ' + identifier)
|
|
state = operator('EXEC_DEFAULT', **values)
|
|
if 'FINISHED' not in state or not filename.is_file() or filename.stat().st_size == 0:
|
|
raise RuntimeError('NATIVE_EXPORT_NO_OUTPUT: ' + identifier)
|
|
files = []
|
|
for file in sorted(destination.rglob('*')):
|
|
if file.is_symlink(): raise RuntimeError('NATIVE_EXPORT_SYMLINK')
|
|
if file.is_file():
|
|
files.append({'name': file.relative_to(destination).as_posix(), 'bytes': file.stat().st_size,
|
|
'sha256': hashlib.sha256(file.read_bytes()).hexdigest()})
|
|
if len(files) > 10000 or sum(item['bytes'] for item in files) > 128 * 1024 * 1024:
|
|
raise RuntimeError('NATIVE_EXPORT_PACKAGE_BUDGET')
|
|
receipt = {'format': spec['format'], 'operator': identifier, 'blenderVersion': bpy.app.version_string,
|
|
'selectedOnly': spec['selectedOnly'], 'files': files}
|
|
(destination / 'export-receipt.json').write_text(json.dumps(receipt, indent=2), encoding='utf8')
|
|
package = root / 'export-package.zip'
|
|
with zipfile.ZipFile(package, 'w', zipfile.ZIP_DEFLATED) as bundle:
|
|
for file in sorted(destination.rglob('*')):
|
|
if file.is_file(): bundle.write(file, file.relative_to(destination).as_posix())
|
|
artifact(package, 'application/zip')
|
|
# Multi-file formats must be downloaded as the package to retain relative resources.
|
|
if len(files) == 1:
|
|
standalone = root / filename.name
|
|
shutil.copyfile(filename, standalone)
|
|
artifact(standalone, 'model/gltf-binary' if extension == 'glb' else 'application/octet-stream')
|
|
output.write(json.dumps(receipt, indent=2) + '\n')
|