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()