Initial Spatial Lab release with verified Blender bridge
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""Integration test in real Blender, including geometry edits, pruning and undo.
|
||||
|
||||
blender -b --factory-startup --python tests/blender_roundtrip.py
|
||||
Writes a verified demonstration and report to out/.
|
||||
"""
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(root))
|
||||
from spatial_lab.examples import demo_project
|
||||
from spatial_lab.project import Project, write_json
|
||||
|
||||
spec = importlib.util.spec_from_file_location('bridge', root / 'blender/bridge.py')
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bridge)
|
||||
scene = bridge.create_scene('Spatial Lab / Braided atrium')
|
||||
bpy.context.window.scene = scene
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / 'study.json'
|
||||
write_json(path, demo_project())
|
||||
project = Project(path)
|
||||
original = project.build()
|
||||
bridge.import_bundle(original, scene)
|
||||
original_check = bridge.verify_bundle(original, scene)
|
||||
assert original_check['passed'], original_check
|
||||
obj = next(o for o in scene.objects if o.get('spatial_lab_id') == 'braided-galleries/0000/main')
|
||||
pointer = obj.as_pointer()
|
||||
original_material = obj.material_slots[0].material
|
||||
modifier = obj.modifiers.new('Artist bevel', 'BEVEL')
|
||||
artist_material = bpy.data.materials.new('Artist material')
|
||||
obj.material_slots[0].material = artist_material
|
||||
marker = bpy.data.objects.new('Unrelated object', None)
|
||||
scene.collection.objects.link(marker)
|
||||
|
||||
changed = project.apply([
|
||||
{'action': 'set_parameter', 'name': 'width', 'value': 1.8},
|
||||
{'action': 'set_parameter', 'name': 'levels', 'value': 2},
|
||||
])
|
||||
assert changed['geometries'] != original['geometries']
|
||||
updated = bridge.import_bundle(changed, scene)
|
||||
changed_check = bridge.verify_bundle(changed, scene)
|
||||
assert changed_check['passed'], changed_check
|
||||
assert updated['created'] == 0 and updated['updated'] == 16 and len(updated['removed']) == 1
|
||||
assert obj.as_pointer() == pointer and obj.modifiers.get(modifier.name).as_pointer() == modifier.as_pointer()
|
||||
assert obj.material_slots[0].material == artist_material and marker.name in scene.objects
|
||||
|
||||
restored = project.travel('undo')
|
||||
assert restored == original
|
||||
bridge.import_bundle(restored, scene)
|
||||
undo_check = bridge.verify_bundle(restored, scene)
|
||||
assert undo_check['passed'], undo_check
|
||||
assert obj.as_pointer() == pointer and obj.material_slots[0].material == artist_material
|
||||
obj.modifiers.remove(modifier)
|
||||
obj.material_slots[0].material = original_material
|
||||
bpy.data.objects.remove(marker, do_unlink=True)
|
||||
bpy.data.materials.remove(artist_material)
|
||||
|
||||
out = root / 'out'
|
||||
write_json(out / 'braided_atrium.bundle.json', restored)
|
||||
write_json(out / 'blender_verification.json', {
|
||||
'blender_version': bpy.app.version_string,
|
||||
'initial': original_check, 'edited': changed_check, 'undo': undo_check,
|
||||
'changed_import': updated,
|
||||
'preserved_object_identity_modifiers_materials_and_unrelated_objects': True,
|
||||
'undo_restored_identical_bundle': True,
|
||||
})
|
||||
from mathutils import Vector
|
||||
target = Vector((0, 0, 4.5))
|
||||
rotation = (target - Vector((45, -55, 35))).to_track_quat('-Z', 'Y')
|
||||
for screen in bpy.data.screens:
|
||||
for area in screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
view = area.spaces.active
|
||||
bridge.enum_set(view.shading, 'type', 'SOLID')
|
||||
bridge.enum_set(view.shading, 'color_type', 'MATERIAL')
|
||||
view.region_3d.view_location = target
|
||||
view.region_3d.view_distance = 62
|
||||
view.region_3d.view_rotation = rotation
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(out / 'Braided_Atrium.blend'))
|
||||
print('PASS: changed geometry, pruned instance, preserved artist work, exact undo, Blender parity.')
|
||||
@@ -0,0 +1,103 @@
|
||||
import copy
|
||||
import math
|
||||
import unittest
|
||||
from spatial_lab.kernel import build,evaluate,validate_bundle,PROJECT_SCHEMA,digest,register_operator
|
||||
from spatial_lab.expressions import scalar
|
||||
from spatial_lab.math3d import frames,dot,cross,length,sub,mesh_report
|
||||
from spatial_lab.examples import demo_project
|
||||
|
||||
def project(nodes,parameters=None):return {'schema':PROJECT_SCHEMA,'id':'test','parameters':parameters or {},'nodes':nodes}
|
||||
def straight():return project([
|
||||
{'id':'path','op':'polyline','params':{'points':[[0,0,0],[0,3,0]]},'visible':False},
|
||||
{'id':'frame','op':'frames','inputs':{'path':'path'},'visible':False},
|
||||
{'id':'solid','op':'sweep','inputs':{'frames':'frame'},'params':{'profile':[[-1,-.5],[1,-.5],[1,.5],[-1,.5]]}}])
|
||||
|
||||
class Expressions(unittest.TestCase):
|
||||
def test_math(self):self.assertAlmostEqual(scalar('sin(pi/2)*radius + sqrt(9)',{'radius':4}),7)
|
||||
def test_no_python_execution(self):
|
||||
for text in ["__import__('os')","x.__class__","[1,2][0]","(lambda: 1)()","sum(x for x in [1])"]:
|
||||
with self.subTest(text=text),self.assertRaises(ValueError):scalar(text)
|
||||
def test_domain_and_resource_guards(self):
|
||||
for text in ['1/0','sqrt(-1)','1e309','2**1000000','exp(10000)','unknown+1']:
|
||||
with self.subTest(text=text),self.assertRaises(ValueError):scalar(text)
|
||||
|
||||
class Geometry(unittest.TestCase):
|
||||
def test_straight_sweep_analytical_volume(self):
|
||||
b=build(straight());r=next(iter(b['validation']['geometry_reports'].values()))
|
||||
self.assertAlmostEqual(r['signed_volume'],6);self.assertEqual(r['boundary_edges'],0)
|
||||
self.assertEqual(b['bounds'],{'min':[-1.,0.,-.5],'max':[1.,3.,.5]})
|
||||
def test_frames_are_right_handed_orthonormal(self):
|
||||
pts=[[math.cos(i*.05)*5,math.sin(i*.05)*5,i*.02] for i in range(100)]
|
||||
for f in frames(pts,twist=720):
|
||||
self.assertAlmostEqual(dot(f['x'],f['y']),0,places=10)
|
||||
self.assertAlmostEqual(dot(cross(f['x'],f['y']),f['z']),1,places=10)
|
||||
def test_closed_frame_seam_and_mesh(self):
|
||||
p=straight();p['nodes'][0]={'id':'path','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True,'segments':64},'visible':False}
|
||||
b=build(p);r=next(iter(b['validation']['geometry_reports'].values()))
|
||||
self.assertEqual(r['boundary_edges'],0);self.assertEqual(r['inconsistent_edges'],0)
|
||||
result,_=evaluate(p);fs=result['frame']['frames']
|
||||
self.assertGreater(dot(fs[0]['x'],fs[-1]['x']),.99)
|
||||
def test_closed_fractional_twist_rejected(self):
|
||||
p=straight();p['nodes'][0]={'id':'path','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True},'visible':False};p['nodes'][1]['params']={'twist_degrees':180}
|
||||
with self.assertRaisesRegex(ValueError,'multiple of 360'):build(p)
|
||||
def test_closed_endpoint_mismatch_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError,'endpoints'):build(project([{'id':'curve','op':'curve','params':{'xyz':['t',0,0],'closed':True}}]))
|
||||
def test_chord_quality_requirement_is_enforced(self):
|
||||
with self.assertRaisesRegex(ValueError,'chord error'):build(project([{'id':'curve','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True,'segments':8,'max_chord_error':.001}}]))
|
||||
def test_closed_polyline_needs_three_points(self):
|
||||
with self.assertRaisesRegex(ValueError,'at least 3'):build(project([{'id':'line','op':'polyline','params':{'points':[[0,0,0],[1,0,0]],'closed':True}}]))
|
||||
def test_concave_section_is_capped(self):
|
||||
p=straight();p['nodes'][2]['params']['profile']=[[0,0],[2,0],[2,1],[1,1],[1,2],[0,2]]
|
||||
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],9);self.assertEqual(r['boundary_edges'],0)
|
||||
def test_self_crossing_section_rejected(self):
|
||||
p=straight();p['nodes'][2]['params']['profile']=[[0,0],[2,2],[2,0],[0,2]]
|
||||
with self.assertRaises(ValueError):build(p)
|
||||
def test_thick_surface_volume(self):
|
||||
b=build(project([{'id':'slab','op':'surface','params':{'xyz':['u','v',0],'u_domain':[0,2],'v_domain':[0,3],'u_segments':4,'v_segments':4,'thickness':.4}}]))
|
||||
r=next(iter(b['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],2.4);self.assertEqual(r['boundary_edges'],0)
|
||||
def test_open_surface_is_explicit(self):
|
||||
b=build(project([{'id':'sheet','op':'surface','params':{'xyz':['u','v','u*v']}}]));self.assertTrue(b['validation']['warnings'])
|
||||
def test_periodic_surface_torus(self):
|
||||
p=project([{'id':'torus','op':'surface','params':{'xyz':['(5+cos(v))*cos(u)','(5+cos(v))*sin(u)','sin(v)'],'u_domain':[0,'tau'],'v_domain':[0,'tau'],'wrap_u':True,'wrap_v':True,'u_segments':32,'v_segments':16}}])
|
||||
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertEqual(r['boundary_edges'],0);self.assertGreater(r['signed_volume'],90)
|
||||
def test_loft(self):
|
||||
p=project([{'id':'a','op':'polyline','params':{'points':[[0,0,0],[2,0,0],[2,2,0],[0,2,0]],'closed':True},'visible':False},
|
||||
{'id':'b','op':'polyline','params':{'points':[[0,0,3],[2,0,3],[2,2,3],[0,2,3]],'closed':True},'visible':False},
|
||||
{'id':'loft','op':'loft','inputs':{'sections':['a','b']}}])
|
||||
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],12)
|
||||
def test_instances_share_geometry_and_have_stable_ids(self):
|
||||
p=straight();p['nodes'][2]['visible']=False;p['nodes'].append({'id':'array','op':'repeat','inputs':{'source':'solid'},'params':{'count':4,'translate':[5,0,0]}})
|
||||
b=build(p);self.assertEqual(len(b['geometries']),1);self.assertEqual([o['id'] for o in b['objects']],['array/0000/main','array/0001/main','array/0002/main','array/0003/main']);self.assertEqual(b['bounds']['max'][0],16)
|
||||
def test_transform_bounds(self):
|
||||
p=straight();p['nodes'][2]['visible']=False;p['nodes'].append({'id':'moved','op':'transform','inputs':{'source':'solid'},'params':{'translate':[10,0,0],'rotate':[0,0,90],'scale':2}})
|
||||
b=build(p);self.assertAlmostEqual(b['bounds']['min'][0],4);self.assertAlmostEqual(b['bounds']['max'][1],2)
|
||||
def test_invalid_reflection(self):
|
||||
p=straight();p['nodes'].append({'id':'bad','op':'transform','inputs':{'source':'solid'},'params':{'scale':-1}})
|
||||
with self.assertRaisesRegex(ValueError,'positive'):build(p)
|
||||
|
||||
class Graph(unittest.TestCase):
|
||||
def test_order_independent_dependencies(self):
|
||||
p=straight();b=build(p);p['nodes'].reverse();other=build(p)
|
||||
self.assertEqual(b['geometries'],other['geometries']);self.assertEqual(b['objects'],other['objects'])
|
||||
def test_cycles_and_missing_references(self):
|
||||
p=straight();p['nodes'][0]={'id':'path','op':'transform','inputs':{'source':'solid'}}
|
||||
with self.assertRaisesRegex(ValueError,'cycle'):build(p)
|
||||
p=straight();p['nodes'][1]['inputs']['path']='absent'
|
||||
with self.assertRaisesRegex(ValueError,'Missing node'):build(p)
|
||||
def test_duplicate_id_and_version(self):
|
||||
p=straight();p['nodes'].append(copy.deepcopy(p['nodes'][0]))
|
||||
with self.assertRaisesRegex(ValueError,'Duplicate'):build(p)
|
||||
p=straight();p['nodes'][0]['version']=500
|
||||
with self.assertRaisesRegex(ValueError,'version'):build(p)
|
||||
def test_bundle_is_deterministic_and_tamper_evident(self):
|
||||
b=build(straight());self.assertEqual(b,build(straight()));self.assertTrue(validate_bundle(b));b['objects'][0]['matrix'][0][3]=100
|
||||
with self.assertRaisesRegex(ValueError,'hash'):validate_bundle(b)
|
||||
def test_demo_is_closed_and_valid(self):
|
||||
b=build(demo_project());self.assertEqual(b['stats']['objects'],17);self.assertEqual(b['validation']['warnings'],[])
|
||||
for r in b['validation']['geometry_reports'].values():self.assertEqual(r['boundary_edges'],0)
|
||||
def test_extension_registry(self):
|
||||
@register_operator('test-guide',description='Test extension')
|
||||
def guide(c,p,inputs):return {'kind':'curve','points':[[0,0,0],[c.number(p['length']),0,0]],'closed':False}
|
||||
b=build(project([{'id':'custom','op':'test-guide','params':{'length':'L'}}],{'L':7}));self.assertEqual(b['bounds']['max'][0],7)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from spatial_lab.project import Project,write_json
|
||||
from spatial_lab.examples import small_project
|
||||
|
||||
class Editing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.path=Path(self.tmp.name)/'study.json';write_json(self.path,small_project());self.project=Project(self.path)
|
||||
def tearDown(self):self.tmp.cleanup()
|
||||
def test_edit_undo_redo_exactly(self):
|
||||
original=self.project.build();modified=self.project.apply([{'action':'set_parameter','name':'height','value':8}])
|
||||
self.assertNotEqual(original['bundle_hash'],modified['bundle_hash']);self.assertEqual([o['id'] for o in original['objects']],[o['id'] for o in modified['objects']])
|
||||
self.assertEqual(Project(self.path).travel('undo'),original);self.assertEqual(Project(self.path).travel('redo'),modified)
|
||||
def test_failed_transaction_changes_nothing(self):
|
||||
before=self.path.read_bytes()
|
||||
with self.assertRaises(ValueError):self.project.apply([{'action':'set_parameter','name':'height','value':9},{'action':'patch_node','id':'path','patch':{'params':{'xyz':['1/0',0,0]}}}])
|
||||
self.assertEqual(self.path.read_bytes(),before);self.assertFalse(self.project.history_path.exists())
|
||||
def test_dependency_aware_removal(self):
|
||||
with self.assertRaisesRegex(ValueError,'used by'):self.project.apply([{'action':'remove_node','id':'path'}])
|
||||
b=self.project.apply([{'action':'remove_node','id':'path','cascade':True}]);self.assertEqual(b['stats']['nodes'],0)
|
||||
self.assertEqual(self.project.travel('undo')['stats']['nodes'],3)
|
||||
def test_add_and_patch_nodes(self):
|
||||
b=self.project.apply([{'action':'upsert_node','node':{'id':'echo','op':'transform','inputs':{'source':'gallery'},'params':{'translate':[0,0,10]}}},{'action':'patch_node','id':'gallery','patch':{'visible':False}}])
|
||||
self.assertEqual(b['objects'][0]['id'],'echo/main');self.assertGreater(b['bounds']['min'][2],9)
|
||||
def test_new_edit_clears_redo(self):
|
||||
self.project.apply([{'action':'set_parameter','name':'height','value':8}]);self.project.travel('undo');self.project.apply([{'action':'set_parameter','name':'height','value':6}])
|
||||
with self.assertRaisesRegex(ValueError,'Nothing to redo'):self.project.travel('redo')
|
||||
def test_external_edit_does_not_restore_stale_history(self):
|
||||
self.project.apply([{'action':'set_parameter','name':'height','value':8}]);p=self.project.read();p['parameters']['height']=10;write_json(self.path,p)
|
||||
with self.assertRaisesRegex(ValueError,'Nothing to undo'):self.project.travel('undo')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from spatial_lab.server import make_server
|
||||
from spatial_lab.project import Project,write_json
|
||||
from spatial_lab.examples import small_project
|
||||
|
||||
class ViewerServer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.path=Path(self.tmp.name)/'study.json';write_json(self.path,small_project());self.server=make_server(self.path,0);self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start();self.url=f'http://127.0.0.1:{self.server.server_port}'
|
||||
def tearDown(self):self.server.shutdown();self.server.server_close();self.thread.join();self.tmp.cleanup()
|
||||
def get(self,path):return urllib.request.urlopen(self.url+path)
|
||||
def test_bundle_live_changes_and_etag(self):
|
||||
with self.get('/api/bundle') as r:a=json.load(r);tag=r.headers['ETag']
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:urllib.request.urlopen(urllib.request.Request(self.url+'/api/bundle',headers={'If-None-Match':tag}))
|
||||
self.assertEqual(caught.exception.code,304);caught.exception.close()
|
||||
Project(self.path).apply([{'action':'set_parameter','name':'height','value':12}])
|
||||
with self.get('/api/bundle') as r:b=json.load(r)
|
||||
self.assertNotEqual(a['project_hash'],b['project_hash'])
|
||||
def test_error_does_not_silently_serve_stale_geometry(self):
|
||||
with self.get('/api/bundle') as r:json.load(r)
|
||||
self.path.write_text('{bad json')
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:self.get('/api/bundle')
|
||||
self.assertEqual(caught.exception.code,422);error=json.load(caught.exception);caught.exception.close();self.assertTrue(error['stale']);self.assertIn('error',error)
|
||||
def test_no_arbitrary_file_access(self):
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:self.get('/../../etc/passwd')
|
||||
self.assertEqual(caught.exception.code,404);caught.exception.close()
|
||||
def test_viewer_and_download(self):
|
||||
with self.get('/') as r:self.assertIn(b'viewport',r.read())
|
||||
with self.get('/export.bundle.json') as r:self.assertIn('attachment',r.headers['Content-Disposition']);self.assertEqual(json.load(r)['schema'],'spatial-lab.bundle/1')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user