"""Construction graph evaluation and the neutral, deterministic geometry contract.""" import copy import hashlib import importlib.util import json import math import re from dataclasses import dataclass from pathlib import Path from .expressions import scalar, vector, integer, CONSTANTS, FUNCTIONS from .math3d import identity, point, mesh_report, length, sub PROJECT_SCHEMA='spatial-lab.project/1' BUNDLE_SCHEMA='spatial-lab.bundle/1' OPERATORS={} def canonical(data):return json.dumps(data,sort_keys=True,separators=(',',':'),allow_nan=False) def digest(data):return hashlib.sha256(canonical(data).encode()).hexdigest() def register_operator(name,version=1,description='',inputs=()): """Register a trusted Python operator: function(context, params, resolved_inputs).""" def decorate(fn): if name in OPERATORS:raise ValueError(f'Operator already registered: {name}') OPERATORS[name]={'function':fn,'version':version,'description':description,'inputs':list(inputs)} return fn return decorate def operator_catalog(): from . import operators # noqa: F401 return {name:{k:v for k,v in op.items() if k!='function'} for name,op in sorted(OPERATORS.items())} def load_plugin(path): operator_catalog() p=Path(path).resolve() spec=importlib.util.spec_from_file_location('spatial_lab_extension_'+hashlib.sha256(str(p).encode()).hexdigest()[:16],p) if not spec or not spec.loader:raise ValueError(f'Cannot load plugin: {p}') module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) @dataclass class Context: variables:dict node_id:str def number(self,v):return scalar(v,self.variables) def vec(self,v,size=3):return vector(v,self.variables,size) def count(self,v,low=1,high=2048):return integer(v,self.variables,low,high) def expression(self,expression,**variables):return scalar(expression,{**self.variables,**variables}) def dependencies(node): out=[] for v in node.get('inputs',{}).values(): if isinstance(v,str):out.append(v) elif isinstance(v,list) and all(isinstance(x,str) for x in v):out.extend(v) else:raise ValueError(f"{node.get('id')}: input references must be node IDs or lists of IDs") return out def evaluate(project): operator_catalog() if project.get('schema')!=PROJECT_SCHEMA:raise ValueError(f'Expected {PROJECT_SCHEMA}') if not re.fullmatch(r'[A-Za-z0-9_.-]{1,80}',project.get('id','')):raise ValueError('Project ID must contain 1–80 letters, digits, dots, underscores or hyphens') nodes=project.get('nodes') if not isinstance(nodes,list) or len(nodes)>256:raise ValueError('Project needs a nodes array with at most 256 nodes') variables={} for k,v in project.get('parameters',{}).items(): if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',k) or k in {*CONSTANTS,*FUNCTIONS,'t','u','v'}:raise ValueError(f'Reserved or invalid parameter name: {k}') variables[k]=scalar(v) by_id={} for node in nodes: name=node.get('id','') if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]{0,63}',name):raise ValueError(f'Invalid node ID: {name}') if name in by_id:raise ValueError(f'Duplicate node ID: {name}') if not isinstance(node.get('params',{}),dict) or not isinstance(node.get('inputs',{}),dict):raise ValueError(f'{name}: params and inputs must be objects') by_id[name]=node results={};visiting=[];order=[] def visit(name): if name in results:return results[name] if name in visiting:raise ValueError('Dependency cycle: '+' → '.join(visiting+[name])) if name not in by_id:raise ValueError(f'Missing node: {name}') node=by_id[name];op=OPERATORS.get(node.get('op')) if op is None:raise ValueError(f"{name}: unknown operator {node.get('op')!r}; load its plugin explicitly") if node.get('version',1)!=op['version']:raise ValueError(f'{name}: unsupported operator version') raw=node.get('inputs',{}) if set(raw)!=set(op['inputs']):raise ValueError(f"{name}: expected inputs {op['inputs']}, got {list(raw)}") dependencies(node);visiting.append(name) inputs={k:visit(v) if isinstance(v,str) else [visit(x) for x in v] for k,v in raw.items()} try:result=op['function'](Context(variables,name),node.get('params',{}),inputs) except (ValueError,KeyError,TypeError,IndexError,OverflowError) as e:raise ValueError(f'{name} ({node["op"]}): {e}') from e if not isinstance(result,dict) or result.get('kind') not in ['curve','frames','mesh']:raise ValueError(f'{name}: operator returned an unsupported entity') results[name]=result;visiting.pop();order.append(name) return result for name in by_id:visit(name) return results,order def _finite_vectors(vertices,size=3): if not vertices:raise ValueError('Geometry is empty') for p in vertices: if len(p)!=size or any(not isinstance(x,(int,float)) or not math.isfinite(x) or abs(x)>1e9 for x in p):raise ValueError('Invalid or nonfinite geometry coordinates') def build(project): results,order=evaluate(project);nodes={n['id']:n for n in project['nodes']} geometries={};objects=[];reports={};bounds_points=[];warnings=[];node_info=[] for name in order: node=nodes[name];entity=results[name];kind=entity['kind'] summary={'id':name,'op':node['op'],'kind':kind,'inputs':node.get('inputs',{}),'params':node.get('params',{}),'visible':node.get('visible',True),'role':node.get('role','geometry')} if kind=='curve': pts=entity['points'];_finite_vectors(pts) summary['points']=len(pts);summary['length_m']=sum(length(sub(b,a)) for a,b in zip(pts,pts[1:]+([pts[0]] if entity.get('closed') else []))) if 'estimated_chord_error' in entity:summary['estimated_chord_error_m']=entity['estimated_chord_error'] elif kind=='frames':summary['frames']=len(entity['frames']) else:summary['parts']=len(entity['parts']) node_info.append(summary) if not node.get('visible',True):continue color=vector(node.get('color',[.40,.56,.68])) if any(c<0 or c>1 for c in color):raise ValueError(f'{name}: color channels must be in [0,1]') base={'node_id':name,'name':node.get('name',name),'role':node.get('role','geometry'),'color':color} if kind=='mesh': if len(entity['parts'])>2048:raise ValueError(f'{name}: more than 2048 instances') keys=set() for part in entity['parts']: key=str(part.get('key','main')) if key in keys:raise ValueError(f'{name}: duplicate part key {key}') keys.add(key) geom={'vertices':part['vertices'],'faces':part['faces']} gid=digest(geom) if gid not in geometries: _finite_vectors(geom['vertices']) if len(geom['vertices'])>200000 or len(geom['faces'])>500000:raise ValueError('Per-geometry complexity limit exceeded') report=mesh_report(**geom) if report['degenerate_triangles'] or report['nonmanifold_edges'] or report['inconsistent_edges']:raise ValueError(f'{name}: invalid mesh topology: {report}') if part.get('closed',False) and (report['boundary_edges'] or report['signed_volume']<=1e-10):raise ValueError(f'{name}: expected an outward-oriented closed solid: {report}') geometries[gid]=geom;reports[gid]=report if report['boundary_edges']:warnings.append(f'{name}: open surface ({report["boundary_edges"]} boundary edges)') matrix=part.get('matrix',identity());_finite_vectors(matrix,4) if len(matrix)!=4 or matrix[3]!=[0,0,0,1]:raise ValueError('Transforms must be affine 4×4 matrices') from .math3d import determinant if determinant(matrix)<=1e-10:raise ValueError('Transforms must preserve orientation and be invertible') objects.append({**base,'id':name+'/'+key,'kind':'mesh','geometry':gid,'matrix':matrix}) bounds_points.extend(point(matrix,p) for p in geom['vertices']) elif kind=='curve': objects.append({**base,'id':name+'/path','kind':'curve','points':entity['points'],'closed':entity.get('closed',False),'matrix':identity()}) bounds_points.extend(entity['points']) else: for i,f in enumerate(entity['frames']): m=[[f['x'][j],f['y'][j],f['z'][j],f['origin'][j]] for j in range(3)]+[[0,0,0,1]] objects.append({**base,'id':name+f'/frame-{i:04}','kind':'frame','matrix':m});bounds_points.append(f['origin']) if len(objects)>4096 or sum(len(g['vertices']) for g in geometries.values())>500000 or len(bounds_points)>2000000:raise ValueError('Scene complexity limit exceeded') bounds={'min':[min(p[i] for p in bounds_points) for i in range(3)],'max':[max(p[i] for p in bounds_points) for i in range(3)]} if bounds_points else {'min':[-1,-1,-1],'max':[1,1,1]} bundle={'schema':BUNDLE_SCHEMA,'project_id':project['id'],'name':project.get('name',project['id']),'project_hash':digest(project),'units':'meters','handedness':'right','up_axis':'+Z','matrix_convention':'row arrays; column vectors; local-to-world','recipe':copy.deepcopy(project),'nodes':node_info,'geometries':geometries,'objects':objects,'bounds':bounds,'validation':{'geometry_reports':reports,'warnings':warnings,'self_intersections_checked':False,'walkability_checked':False},'stats':{'nodes':len(nodes),'objects':len(objects),'unique_meshes':len(geometries),'vertices':sum(len(g['vertices']) for g in geometries.values()),'triangles':sum(len(geometries[o['geometry']]['faces']) for o in objects if o['kind']=='mesh')}} bundle['bundle_hash']=digest(bundle) return bundle def validate_bundle(bundle): """Validate untrusted bundle data before handing it to an adapter.""" if bundle.get('schema')!=BUNDLE_SCHEMA:raise ValueError('Unsupported bundle schema') if (bundle.get('units'),bundle.get('handedness'),bundle.get('up_axis'))!=('meters','right','+Z'):raise ValueError('Unsupported coordinate convention') if digest({k:v for k,v in bundle.items() if k!='bundle_hash'})!=bundle.get('bundle_hash'):raise ValueError('Bundle hash does not match its contents') for gid,g in bundle['geometries'].items(): if digest(g)!=gid:raise ValueError(f'Geometry hash mismatch: {gid}') _finite_vectors(g['vertices']);report=mesh_report(**g) if report['degenerate_triangles'] or report['nonmanifold_edges'] or report['inconsistent_edges']:raise ValueError('Invalid mesh topology in bundle') ids=set() for o in bundle['objects']: if o['id'] in ids:raise ValueError('Duplicate object ID') ids.add(o['id']) if o.get('kind') not in ['mesh','curve','frame']:raise ValueError('Unsupported object kind') m=o['matrix'];_finite_vectors(m,4) from .math3d import determinant if len(m)!=4 or m[3]!=[0,0,0,1] or determinant(m)<=1e-10:raise ValueError('Invalid object transform') if o['kind']=='mesh' and o['geometry'] not in bundle['geometries']:raise ValueError('Missing geometry reference') if o['kind']=='curve':_finite_vectors(o['points']) return True