Files

171 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 180 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