chore: checkpoint web Blender migration source, tools and docs
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>
This commit is contained in:
78
tools/web/native_export.py
Normal file
78
tools/web/native_export.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""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')
|
||||
Reference in New Issue
Block a user