171 lines
11 KiB
Python
171 lines
11 KiB
Python
"""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
|