Files

152 lines
10 KiB
Python

"""Import and verify exact Spatial Lab base geometry in Blender.
From Blender Python: import_bundle(path, scene=dedicated_scene).
CLI: blender -b --factory-startup --python bridge.py -- --bundle X --save Y --report Z
Only tagged objects of the same project in the specified scene are owned by this adapter.
"""
import argparse
import json
import math
import sys
from pathlib import Path
LAB=Path(__file__).resolve().parents[1]
if str(LAB) not in sys.path:sys.path.insert(0,str(LAB))
from spatial_lab.kernel import validate_bundle
from spatial_lab.project import read_json,write_json
from spatial_lab.math3d import point
def enum_set(owner,property_name,value):
valid=[item.identifier for item in owner.bl_rna.properties[property_name].enum_items]
if value not in valid:raise ValueError(f'{value} is not a supported {property_name}: {valid}')
setattr(owner,property_name,value)
def create_scene(name='Spatial Lab'):
import bpy
scene=bpy.data.scenes.new(name);scene.unit_settings.scale_length=1;enum_set(scene.unit_settings,'system','METRIC')
return scene
def _owned(scene,project_id):
result={}
for o in scene.objects:
if o.get('spatial_lab_project')==project_id:
key=o.get('spatial_lab_id')
if key in result:raise ValueError('Duplicate imported object ID: '+str(key))
result[key]=o
return result
def import_bundle(source,scene=None,prune=True):
import bpy
from mathutils import Matrix
bundle=read_json(source) if isinstance(source,(str,Path)) else source
validate_bundle(bundle)
scene=scene or bpy.context.scene
if abs(scene.unit_settings.scale_length-1)>1e-9:raise ValueError('Import into a scene with unit scale 1 (metres)')
pid=bundle['project_id'];old=_owned(scene,pid)
for spec in bundle['objects']:
if spec['id'] in old and old[spec['id']].get('spatial_lab_kind')!=spec['kind']:raise ValueError('An existing stable ID changed object kind: '+spec['id'])
collection=next((c for c in scene.collection.children if c.get('spatial_lab_project')==pid),None)
if collection is None:
collection=bpy.data.collections.new('Spatial Lab / '+bundle['name']);collection['spatial_lab_project']=pid;scene.collection.children.link(collection)
cache={};created=0;updated=0
for gid,g in bundle['geometries'].items():
me=bpy.data.meshes.new('SL geometry '+gid[:12]);me.from_pydata(g['vertices'],[],g['faces']);me.update()
me['spatial_lab_project']=pid;me['spatial_lab_geometry']=gid;cache[gid]=me
def material(spec):
key=spec['node_id'];mat=next((m for m in bpy.data.materials if m.get('spatial_lab_project')==pid and m.get('spatial_lab_node')==key),None)
if mat is None:
mat=bpy.data.materials.new('SL / '+key);mat['spatial_lab_project']=pid;mat['spatial_lab_node']=key;mat.use_nodes=True
shader=next(n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED');shader.inputs['Base Color'].default_value=(*spec['color'],1);shader.inputs['Roughness'].default_value=.76
mat.diffuse_color=(*spec['color'],1)
return mat
seen=set()
for spec in bundle['objects']:
oid=spec['id'];seen.add(oid);kind=spec['kind']
data=cache.get(spec.get('geometry'))
if kind=='curve':
pts=spec['points'];edges=[(i,i+1) for i in range(len(pts)-1)]
if spec.get('closed'):edges.append((len(pts)-1,0))
data=bpy.data.meshes.new('SL guide '+oid);data.from_pydata(pts,edges,[]);data.update();data['spatial_lab_project']=pid
obj=old.get(oid);old_materials=[]
if obj is None:
obj=bpy.data.objects.new('SL / '+oid,data);collection.objects.link(obj);created+=1
else:
old_materials=[s.material for s in obj.material_slots];obj.data=data;updated+=1
if obj.name not in collection.objects:collection.objects.link(obj)
obj['spatial_lab_project']=pid;obj['spatial_lab_id']=oid;obj['spatial_lab_kind']=kind;obj['spatial_lab_node']=spec['node_id'];obj['spatial_lab_role']=spec.get('role','geometry')
obj['spatial_lab_source_hash']=bundle['bundle_hash'];obj.matrix_world=Matrix(spec['matrix']);obj.color=(*spec['color'],1)
if kind=='mesh':
assigned=old_materials or [material(spec)]
while len(data.materials)<len(assigned):data.materials.append(material(spec))
for i,mat in enumerate(assigned):obj.material_slots[i].link='OBJECT';obj.material_slots[i].material=mat
elif kind=='curve':enum_set(obj,'display_type','WIRE');obj.hide_render=True
else:enum_set(obj,'empty_display_type','ARROWS');obj.empty_display_size=.65
removed=[]
if prune:
for oid,obj in old.items():
if oid not in seen:removed.append(oid);bpy.data.objects.remove(obj,do_unlink=True)
for me in list(bpy.data.meshes):
if me.get('spatial_lab_project')==pid and me.users==0:bpy.data.meshes.remove(me)
scene['spatial_lab_bundle_hash']=bundle['bundle_hash'];scene['spatial_lab_project_id']=pid
# Depsgraph updates are needed before world-space parity checks of fresh objects.
for layer in scene.view_layers:layer.update()
return {'created':created,'updated':updated,'removed':removed,'collection':collection.name,'bundle_hash':bundle['bundle_hash']}
def verify_bundle(source,scene=None,tolerance=1e-5):
import bpy
from mathutils import Vector
bundle=read_json(source) if isinstance(source,(str,Path)) else source;validate_bundle(bundle)
scene=scene or bpy.context.scene
for layer in scene.view_layers:layer.update()
owned=_owned(scene,bundle['project_id']);errors=[];local_error=0.;world_error=0.;matrix_error=0.;vertices=0;faces=0
expected={s['id'] for s in bundle['objects']}
for spec in bundle['objects']:
obj=owned.get(spec['id'])
if obj is None:errors.append('Missing object: '+spec['id']);continue
matrix_error=max(matrix_error,max(abs(obj.matrix_world[i][j]-spec['matrix'][i][j]) for i in range(4) for j in range(4)))
if spec['kind']=='frame':continue
pts=bundle['geometries'][spec['geometry']]['vertices'] if spec['kind']=='mesh' else spec['points']
triangles=bundle['geometries'][spec['geometry']]['faces'] if spec['kind']=='mesh' else []
if len(obj.data.vertices)!=len(pts):errors.append('Vertex count: '+spec['id']);continue
if [list(p.vertices) for p in obj.data.polygons]!=triangles:errors.append('Face connectivity/winding: '+spec['id'])
if spec['kind']=='curve':
expected_edges={tuple(sorted((i,i+1))) for i in range(len(pts)-1)}
if spec.get('closed'):expected_edges.add((0,len(pts)-1))
if {tuple(sorted(e.vertices)) for e in obj.data.edges}!=expected_edges:errors.append('Curve connectivity: '+spec['id'])
for v,p in zip(obj.data.vertices,pts):
local_error=max(local_error,math.dist(v.co,p));world_error=max(world_error,math.dist(obj.matrix_world@v.co,point(spec['matrix'],p)))
vertices+=len(pts);faces+=len(triangles)
extra=sorted(set(owned)-expected)
if extra:errors.append('Unexpected owned objects: '+', '.join(extra))
if local_error>tolerance:errors.append('Local-coordinate error exceeds tolerance')
if world_error>tolerance:errors.append('World-coordinate error exceeds tolerance')
if matrix_error>tolerance:errors.append('Transform error exceeds tolerance')
return {'passed':not errors,'project_id':bundle['project_id'],'bundle_hash':bundle['bundle_hash'],'objects_checked':len(expected),'vertices_checked_including_instances':vertices,'triangles_checked_including_instances':faces,'tolerance_m':tolerance,'max_local_vertex_error_m':local_error,'max_world_vertex_error_m':world_error,'max_matrix_component_error':matrix_error,'errors':errors,'scope':'Base mesh coordinates, connectivity, orientation and transforms. Artist modifiers are deliberately excluded.'}
def main():
import bpy
parser=argparse.ArgumentParser();parser.add_argument('--bundle',required=True);parser.add_argument('--save');parser.add_argument('--report');parser.add_argument('--exercise-update',action='store_true')
args=parser.parse_args(sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else [])
bundle=read_json(args.bundle);scene=create_scene('Spatial Lab / '+bundle['name']);bpy.context.window.scene=scene
imported=import_bundle(bundle,scene);verified=verify_bundle(bundle,scene)
if args.exercise_update:
marker=bpy.data.objects.new('Unrelated object must survive',None);scene.collection.objects.link(marker)
mesh_obj=next(o for o in scene.objects if o.get('spatial_lab_kind')=='mesh');pointer=mesh_obj.as_pointer()
modifier=mesh_obj.modifiers.new('Artist bevel survives reimport','BEVEL');modifier.width=.015;modifier.segments=2
mat=bpy.data.materials.new('Artist material survives reimport');mesh_obj.material_slots[0].link='OBJECT';mesh_obj.material_slots[0].material=mat
second=import_bundle(bundle,scene)
assert mesh_obj.as_pointer()==pointer and mesh_obj.modifiers.get(modifier.name) is not None and mesh_obj.material_slots[0].material==mat
assert marker.name in scene.objects and second['created']==0
# Restore the original base appearance for the delivered demo, after testing preservation.
mesh_obj.modifiers.remove(modifier);mesh_obj.material_slots[0].material=next(m for m in bpy.data.materials if m.get('spatial_lab_project')==bundle['project_id'] and m.get('spatial_lab_node')==mesh_obj['spatial_lab_node'])
bpy.data.objects.remove(marker,do_unlink=True)
verified=verify_bundle(bundle,scene);verified['reimport_preserves_object_identity_modifiers_materials_and_unrelated_objects']=True
if args.save:
Path(args.save).parent.mkdir(parents=True,exist_ok=True);bpy.ops.wm.save_as_mainfile(filepath=str(Path(args.save).resolve()))
result={'import':imported,'verification':verified}
if args.report:write_json(args.report,result)
print(json.dumps(result,indent=2))
if not verified['passed']:raise RuntimeError('Blender transfer verification failed')
if __name__=='__main__':main()