Files

134 lines
5.7 KiB
Python

"""Faset asset export helper for the unmodified official Blender application."""
bl_info = {
"name": "Faset GLB Export", "author": "Faset Engine", "version": (0, 1, 0),
"blender": (4, 2, 0), "location": "File > Export > Faset GLB Bundle",
"description": "Publish GLB with persistent custom IDs and an atomic manifest", "category": "Import-Export",
}
from pathlib import Path
import tempfile
import uuid
import bpy
from bpy.props import StringProperty
from bpy_extras.io_utils import ExportHelper
from .bundle import publish_bundle
def ensure_persistent_ids(context):
"""Assign only missing IDs. Ambiguous duplicates require an explicit user operation."""
objects = list(context.scene.objects)
meshes = list({obj.data for obj in objects if obj.type == "MESH"})
materials = list({slot.material for obj in objects for slot in obj.material_slots if slot.material})
groups = [objects, meshes, materials]
for group in groups:
seen = {}
for block in group:
if block.library:
raise ValueError(f"Linked data needs local IDs before export: {block.name}")
identity = block.get("faset_id")
if not identity:
identity = str(uuid.uuid4())
block["faset_id"] = identity
try:
uuid.UUID(identity)
except (ValueError, TypeError, AttributeError) as error:
raise ValueError(f"Invalid faset_id on {block.name}") from error
if identity in seen:
raise ValueError(f"DuplicateSourceId: {seen[identity]} / {block.name}. "
"Select the new copy and run Faset: New IDs for Selected.")
seen[identity] = block.name
if not context.scene.get("faset_asset_id"):
context.scene["faset_asset_id"] = str(uuid.uuid4())
return context.scene["faset_asset_id"]
class FASET_OT_new_selected_ids(bpy.types.Operator):
bl_idname = "faset.new_selected_ids"
bl_label = "Faset: New IDs for Selected"
bl_description = "Explicitly fork object identities; shared mesh/material identities remain shared"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def execute(self, context):
selected = set(context.selected_objects)
for obj in selected:
if obj.library:
self.report({"ERROR"}, "Linked objects must be made local first")
return {"CANCELLED"}
obj["faset_id"] = str(uuid.uuid4())
# A copied mesh owns its new identity; a genuinely shared mesh stays shared.
for obj in selected:
mesh = obj.data if obj.type == "MESH" else None
if mesh and not mesh.library:
duplicate = any(other is not mesh and other.get("faset_id") == mesh.get("faset_id")
for other in bpy.data.meshes)
if duplicate:
mesh["faset_id"] = str(uuid.uuid4())
return {"FINISHED"}
class FASET_OT_export(bpy.types.Operator, ExportHelper):
bl_idname = "export_scene.faset_bundle"
bl_label = "Faset GLB Bundle"
filename_ext = ".json"
filter_glob: StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
frame, subframe = context.scene.frame_current, context.scene.frame_subframe
active = context.view_layer.objects.active
selected = list(context.selected_objects)
mode = active.mode if active else "OBJECT"
try:
asset_id = ensure_persistent_ids(context)
directory = Path(self.filepath).resolve().parent
with tempfile.TemporaryDirectory(prefix="faset-export-") as temporary:
payload = Path(temporary) / "scene.glb"
result = bpy.ops.export_scene.gltf(filepath=str(payload), export_format="GLB",
export_extras=True, export_yup=True, export_animations=False,
export_materials="EXPORT", use_selection=False, use_active_scene=True)
if "FINISHED" not in result:
raise RuntimeError("Blender glTF export did not finish")
manifest = publish_bundle(payload, directory, asset_id,
bpy.app.version_string, bpy.data.filepath)
self.report({"INFO"}, f"Published {manifest['generation'][:12]}; save .blend to persist IDs")
return {"FINISHED"}
except Exception as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
finally:
context.scene.frame_set(frame, subframe=subframe)
for obj in context.selected_objects:
obj.select_set(False)
for obj in selected:
if obj.name in context.view_layer.objects:
obj.select_set(True)
if active and active.name in context.view_layer.objects:
context.view_layer.objects.active = active
if active.mode != mode:
try:
bpy.ops.object.mode_set(mode=mode)
except RuntimeError:
pass
def menu_export(self, context):
self.layout.operator(FASET_OT_export.bl_idname, text="Faset GLB Bundle (.json)")
def register():
bpy.utils.register_class(FASET_OT_new_selected_ids)
bpy.utils.register_class(FASET_OT_export)
bpy.types.TOPBAR_MT_file_export.append(menu_export)
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_export)
bpy.utils.unregister_class(FASET_OT_export)
bpy.utils.unregister_class(FASET_OT_new_selected_ids)
if __name__ == "__main__":
register()