152 lines
10 KiB
Python
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()
|