Initial Spatial Lab release with verified Blender bridge

This commit is contained in:
emil28092005
2026-09-17 16:59:32 +03:00
commit 60d0018fa9
30 changed files with 58238 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Spatial Lab: editable mathematical construction graphs and portable geometry."""
__version__ = "0.1.0"
from .kernel import build, register_operator
from .project import Project
__all__ = ["build", "register_operator", "Project"]
+2
View File
@@ -0,0 +1,2 @@
from .cli import main
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
import argparse
import json
import sys
from pathlib import Path
from . import __version__
from .kernel import build, operator_catalog, load_plugin, validate_bundle
from .project import Project, read_json, write_json
from .examples import demo_project, small_project
def main(argv=None):
p=argparse.ArgumentParser(prog='spatial-lab',description='Mathematical geometry workbench; author through commands, inspect in a browser, transfer exact meshes to Blender.')
p.add_argument('--version',action='version',version=__version__)
p.add_argument('--plugin',action='append',default=[],help='Load a trusted local Python operator module (repeatable)')
s=p.add_subparsers(dest='command',required=True)
q=s.add_parser('init',help='Create an editable project');q.add_argument('project');q.add_argument('--example',choices=['atrium','simple'],default='atrium')
q=s.add_parser('build',help='Evaluate and export the technical bundle');q.add_argument('project');q.add_argument('--out',required=True)
q=s.add_parser('inspect',help='Show evaluated nodes and mesh checks');q.add_argument('project');q.add_argument('--node')
q=s.add_parser('param',help='Change a global parameter transactionally');q.add_argument('project');q.add_argument('name');q.add_argument('value',help='JSON value, e.g. 12 or "2*pi"')
q=s.add_parser('patch',help='Apply an atomic list of graph changes');q.add_argument('project');q.add_argument('changes',help='JSON file containing an array of changes')
q=s.add_parser('node',help='Add or replace a node by stable ID');q.add_argument('project');q.add_argument('spec',help='JSON file containing one node')
q=s.add_parser('remove',help='Remove a node');q.add_argument('project');q.add_argument('node_id');q.add_argument('--cascade',action='store_true')
for name in ['undo','redo']:
q=s.add_parser(name);q.add_argument('project')
s.add_parser('ops',help='List installed operators and their versions')
q=s.add_parser('check',help='Check bundle integrity');q.add_argument('bundle')
q=s.add_parser('serve',help='Run the read-only, live geometry viewer');q.add_argument('project');q.add_argument('--port',type=int,default=8767)
a=p.parse_args(argv)
try:
for plugin in a.plugin:load_plugin(plugin)
if a.command=='ops':result=operator_catalog()
elif a.command=='init':
if Path(a.project).exists():raise ValueError('Project already exists; choose another path')
project=demo_project() if a.example=='atrium' else small_project();b=build(project);write_json(a.project,project);result={'project':str(Path(a.project).resolve()),**b['stats']}
elif a.command=='build':
b=Project(a.project).build();write_json(a.out,b);result={'bundle':str(Path(a.out).resolve()),'hash':b['bundle_hash'],**b['stats'],'warnings':b['validation']['warnings']}
elif a.command=='inspect':
b=Project(a.project).build()
if a.node:
result=next((n for n in b['nodes'] if n['id']==a.node),None)
if result is None:raise ValueError('No such node: '+a.node)
else:result={'name':b['name'],'parameters':b['recipe'].get('parameters',{}),'nodes':b['nodes'],'stats':b['stats'],'bounds':b['bounds'],'validation':b['validation']}
elif a.command=='check':
b=read_json(a.bundle);validate_bundle(b);result={'valid':True,'hash':b['bundle_hash'],'stats':b['stats']}
elif a.command=='serve':
from .server import serve
serve(a.project,a.port);return 0
else:
project=Project(a.project)
if a.command in ['undo','redo']:b=project.travel(a.command)
else:
if a.command=='patch':changes=read_json(a.changes)
elif a.command=='node':changes=[{'action':'upsert_node','node':read_json(a.spec)}]
elif a.command=='remove':changes=[{'action':'remove_node','id':a.node_id,'cascade':a.cascade}]
elif a.command=='param':changes=[{'action':'set_parameter','name':a.name,'value':json.loads(a.value)}]
b=project.apply(changes)
result={'action':a.command,'project_hash':b['project_hash'],**b['stats'],'warnings':b['validation']['warnings']}
print(json.dumps(result,indent=2,allow_nan=False));return 0
except (ValueError,OSError,KeyError,TypeError) as e:
print(json.dumps({'error':str(e)}),file=sys.stderr);return 2
if __name__=='__main__':raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
"""Editable examples built exclusively from expressions, frames, sections and transforms."""
from .kernel import PROJECT_SCHEMA
def demo_project():
return {
'schema':PROJECT_SCHEMA,'id':'braided-atrium','name':'Braided atrium',
'parameters':{'radius':11,'lobe':3.2,'rise':4.8,'width':1.25,'thickness':.24,'twist':360,'samples':240,'levels':3},
'nodes':[
{'id':'braid-path','op':'curve','params':{'xyz':['(radius+lobe*cos(3*t))*cos(2*t)','(radius+lobe*cos(3*t))*sin(2*t)','rise*sin(3*t)'],'domain':[0,'tau'],'segments':'samples','closed':True},'visible':False,'role':'route'},
{'id':'braid-frames','op':'frames','inputs':{'path':'braid-path'},'params':{'twist_degrees':'twist'},'visible':False},
{'id':'braid-section','op':'sweep','inputs':{'frames':'braid-frames'},'params':{'profile':[['-width/2','-thickness/2'],['width/2','-thickness/2'],['width/2','thickness/2'],['-width/2','thickness/2']]},'visible':False},
{'id':'braided-galleries','name':'Braided galleries','op':'repeat','inputs':{'source':'braid-section'},'params':{'count':'levels','translate':[0,0,6.5],'rotate':[0,0,37],'scale':.87},'color':[.37,.56,.68],'role':'gallery'},
{'id':'ring-path','op':'curve','params':{'xyz':['(radius+lobe+2.6)*cos(t)','(radius+lobe+2.6)*sin(t)','-6+0.65*sin(4*t)'],'segments':144,'closed':True},'visible':False},
{'id':'ring-frames','op':'frames','inputs':{'path':'ring-path'},'visible':False},
{'id':'ring-section','op':'sweep','inputs':{'frames':'ring-frames'},'params':{'profile':[[-.13,-.11],[.13,-.11],[.13,.11],[-.13,.11]]},'visible':False},
{'id':'outer-rings','name':'Contour rings','op':'repeat','inputs':{'source':'ring-section'},'params':{'count':9,'translate':[0,0,2.8],'rotate':[0,0,11]},'color':[.63,.68,.73],'role':'structure'},
{'id':'spine-path','op':'curve','params':{'xyz':['(radius+lobe+2.6)*cos(t)','(radius+lobe+2.6)*sin(t)','-6+18*t/pi'],'domain':[0,'1.35*pi'],'segments':112},'visible':False},
{'id':'spine-frames','op':'frames','inputs':{'path':'spine-path'},'visible':False},
{'id':'spine-section','op':'sweep','inputs':{'frames':'spine-frames'},'params':{'profile':[[-.18,-.18],[.18,-.18],[.18,.18],[-.18,.18]]},'visible':False},
{'id':'spiral-spines','name':'Spiral spines','op':'repeat','inputs':{'source':'spine-section'},'params':{'count':4,'rotate':[0,0,90]},'color':[.69,.47,.29],'role':'structure'},
{'id':'saddle-shell','name':'Lower saddle','op':'surface','params':{'xyz':['u','v','0.025*(u*u-v*v)-9'],'u_domain':[-7,7],'v_domain':[-7,7],'u_segments':28,'v_segments':28,'thickness':.22},'color':[.57,.66,.58],'role':'shell'}
]}
def small_project():
return {'schema':PROJECT_SCHEMA,'id':'first-study','name':'First study','parameters':{'height':4},'nodes':[
{'id':'path','op':'curve','params':{'xyz':['8*cos(t)','8*sin(t)','height*t/tau'],'domain':[0,'tau'],'segments':96},'visible':False},
{'id':'local-frames','op':'frames','inputs':{'path':'path'},'visible':False},
{'id':'gallery','op':'sweep','inputs':{'frames':'local-frames'},'params':{'profile':[[-1,-.15],[1,-.15],[1,.15],[-1,.15]]},'role':'gallery'}]}
+56
View File
@@ -0,0 +1,56 @@
"""Bounded arithmetic expressions, interpreted without eval or Python execution."""
import ast
import math
from functools import lru_cache
FUNCTIONS={k:getattr(math,k) for k in ['sin','cos','tan','asin','acos','atan','atan2','sqrt','exp','log','floor','ceil','radians','degrees']}
FUNCTIONS.update(abs=abs,min=min,max=max)
CONSTANTS={'pi':math.pi,'tau':math.tau,'e':math.e}
@lru_cache(maxsize=512)
def parse(text):
if len(text)>2048:raise ValueError('Expression exceeds 2048 characters')
tree=ast.parse(text,mode='eval')
if sum(1 for _ in ast.walk(tree))>128:raise ValueError('Expression is too complex')
permitted=(ast.Expression,ast.Constant,ast.Name,ast.Load,ast.BinOp,ast.UnaryOp,ast.Call,ast.Add,ast.Sub,ast.Mult,ast.Div,ast.Pow,ast.Mod,ast.USub,ast.UAdd)
for n in ast.walk(tree):
if not isinstance(n,permitted):raise ValueError(f'Unsupported expression syntax: {type(n).__name__}')
if isinstance(n,ast.Constant) and (isinstance(n.value,bool) or not isinstance(n.value,(int,float))):raise ValueError('Only numeric literals are allowed')
if isinstance(n,ast.Call) and (not isinstance(n.func,ast.Name) or n.func.id not in FUNCTIONS or n.keywords):raise ValueError('Only documented math functions are allowed')
return tree.body
def scalar(value, variables=None):
env={**CONSTANTS,**(variables or {})}
def walk(n):
if isinstance(n,ast.Constant):return float(n.value)
if isinstance(n,ast.Name):
if n.id not in env:raise ValueError(f'Unknown symbol: {n.id}')
return float(env[n.id])
if isinstance(n,ast.UnaryOp):return -walk(n.operand) if isinstance(n.op,ast.USub) else walk(n.operand)
if isinstance(n,ast.Call):return FUNCTIONS[n.func.id](*[walk(a) for a in n.args])
a,b=walk(n.left),walk(n.right)
if isinstance(n.op,ast.Pow):
if abs(b)>32 or abs(a)>1e9:raise ValueError('Power operands exceed limits')
return a**b
if isinstance(n.op,ast.Add):return a+b
if isinstance(n.op,ast.Sub):return a-b
if isinstance(n.op,ast.Mult):return a*b
if isinstance(n.op,ast.Div):return a/b
if isinstance(n.op,ast.Mod):return a%b
raise ValueError('Unsupported operator')
try:
if isinstance(value,bool):raise ValueError('A boolean is not a numeric expression')
result=walk(parse(value)) if isinstance(value,str) else float(value)
if isinstance(result,complex) or not math.isfinite(result) or abs(result)>1e9:raise ValueError('Expression result must be finite and within ±1e9')
return result
except (ZeroDivisionError,OverflowError,TypeError,ValueError,SyntaxError,RecursionError) as e:
raise ValueError(f'Invalid expression {str(value)[:100]!r}: {e}') from e
def vector(value,variables=None,size=3):
if not isinstance(value,(list,tuple)) or len(value)!=size:raise ValueError(f'Expected a {size}-component vector')
return [scalar(v,variables) for v in value]
def integer(value,variables=None,low=1,high=2048):
n=scalar(value,variables)
if n!=int(n) or not low<=n<=high:raise ValueError(f'Expected an integer in [{low}, {high}]')
return int(n)
+170
View File
@@ -0,0 +1,170 @@
"""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
+111
View File
@@ -0,0 +1,111 @@
"""Small, dependency-free geometry routines. Right-handed, Z-up, metres."""
import math
EPS = 1e-10
def add(a, b): return [a[i] + b[i] for i in range(3)]
def sub(a, b): return [a[i] - b[i] for i in range(3)]
def mul(a, s): return [v * s for v in a]
def dot(a, b): return sum(x*y for x, y in zip(a, b))
def cross(a, b): return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def length(a): return math.sqrt(dot(a, a))
def unit(a):
d = length(a)
if d < EPS: raise ValueError("Cannot normalize a zero-length vector")
return mul(a, 1/d)
def identity(): return [[float(i == j) for j in range(4)] for i in range(4)]
def matmul(a, b): return [[sum(a[i][k]*b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def point(m, p): return [sum(m[i][j]*p[j] for j in range(3)) + m[i][3] for i in range(3)]
def direction(m, p): return [sum(m[i][j]*p[j] for j in range(3)) for i in range(3)]
def determinant(m): return dot(m[0][:3], cross(m[1][:3], m[2][:3]))
def rotate(v, axis, angle):
c, s = math.cos(angle), math.sin(angle)
return add(add(mul(v, c), mul(cross(axis, v), s)), mul(axis, dot(axis, v)*(1-c)))
def transport(v, old_t, new_t):
axis = cross(old_t, new_t); sn = length(axis); cs = max(-1., min(1., dot(old_t, new_t)))
if sn < EPS:
if cs < 0: raise ValueError("Curve reverses direction by 180 degrees; refine or change the path")
return v[:]
return rotate(v, mul(axis, 1/sn), math.atan2(sn, cs))
def transform(translation=(0,0,0), rotation=(0,0,0), scale=(1,1,1)):
if isinstance(scale, (int, float)): scale = [scale]*3
if len(scale) != 3 or any(s <= 0 for s in scale): raise ValueError("Scale must have three positive components")
rx, ry, rz = [math.radians(v) for v in rotation]
cx,sx,cy,sy,cz,sz = math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
x = [[1,0,0,0],[0,cx,-sx,0],[0,sx,cx,0],[0,0,0,1]]
y = [[cy,0,sy,0],[0,1,0,0],[-sy,0,cy,0],[0,0,0,1]]
z = [[cz,-sz,0,0],[sz,cz,0,0],[0,0,1,0],[0,0,0,1]]
m = matmul(z,matmul(y,x))
for i in range(3):
for j in range(3): m[i][j] *= scale[j]
m[i][3] = translation[i]
return m
def frames(points, closed=False, up=(0,0,1), twist=0):
"""Rotation-minimizing frames, with distributed holonomy correction on closed paths."""
n = len(points)
if n < (3 if closed else 2): raise ValueError("Too few points for frames")
tangents = []
for i in range(n):
a = points[(i-1)%n] if closed or i else points[0]
b = points[(i+1)%n] if closed or i < n-1 else points[-1]
tangents.append(unit(sub(b,a)))
t0 = tangents[0]; right = cross(t0, unit(up))
if length(right) < EPS:
axis = min([[1,0,0],[0,1,0],[0,0,1]], key=lambda x: abs(dot(x,t0)))
right = cross(t0,axis)
rights = [unit(right)]
for i in range(1,n): rights.append(unit(transport(rights[-1],tangents[i-1],tangents[i])))
correction = 0
if closed:
if abs(twist/360-round(twist/360)) > 1e-8:
raise ValueError("Closed frames require twist_degrees to be a multiple of 360")
seam = transport(rights[-1], tangents[-1], tangents[0])
correction = math.atan2(dot(t0,cross(seam,rights[0])),dot(seam,rights[0]))
out = []
for i,(p,t,r) in enumerate(zip(points,tangents,rights)):
a = (correction+math.radians(twist))*i/(n if closed else n-1)
r = unit(rotate(r,t,a)); u = unit(cross(r,t))
out.append({"origin":p[:], "x":r, "y":t, "z":u})
return out
def triangulate_polygon(points):
"""Ear clipping in 2D; accepts either winding, rejects invalid/degenerate profiles."""
n = len(points)
if n < 3: raise ValueError("A section needs at least three vertices")
def orient(a,b,c): return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
area = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points,points[1:]+points[:1]))*.5
if abs(area) < EPS: raise ValueError("Section has zero area")
# A simple polygon must not have crossings or repeated vertices.
for i in range(n):
for j in range(i+1,n):
if math.dist(points[i],points[j]) < EPS: raise ValueError("Section has duplicate vertices")
if j == i+1 or (i==0 and j==n-1): continue
a,b,c,d=points[i],points[(i+1)%n],points[j],points[(j+1)%n]
if orient(a,b,c)*orient(a,b,d)<-EPS and orient(c,d,a)*orient(c,d,b)<-EPS:
raise ValueError("Section self-intersects")
ids = list(range(n)) if area > 0 else list(reversed(range(n)))
triangles=[]
while len(ids)>3:
found=False
for k in range(len(ids)):
a,b,c=ids[k-1],ids[k],ids[(k+1)%len(ids)]
if orient(points[a],points[b],points[c])<=EPS: continue
inside=any(all(v>=-EPS for v in [orient(points[a],points[b],points[q]),orient(points[b],points[c],points[q]),orient(points[c],points[a],points[q])]) for q in ids if q not in (a,b,c))
if inside:continue
triangles.append([a,b,c]);ids.pop(k);found=True;break
if not found:raise ValueError("Section cannot be triangulated; remove collinear or crossing edges")
triangles.append(ids)
return triangles, area
def mesh_report(vertices, faces):
edges={}; directed={}; degenerate=[]; volume=0.
for i,f in enumerate(faces):
if len(f)!=3 or len(set(f))!=3 or any(not isinstance(x,int) or x<0 or x>=len(vertices) for x in f):
raise ValueError(f"Invalid triangle {i}: {f}")
a,b,c=[vertices[j] for j in f]
if length(cross(sub(b,a),sub(c,a)))<1e-10: degenerate.append(i)
volume+=dot(a,cross(b,c))/6
for x,y in zip(f,f[1:]+f[:1]):
key=tuple(sorted((x,y)));edges[key]=edges.get(key,0)+1
directed[key]=directed.get(key,0)+(1 if x<y else -1)
return {"vertices":len(vertices),"triangles":len(faces),"boundary_edges":sum(v==1 for v in edges.values()),"nonmanifold_edges":sum(v>2 for v in edges.values()),"inconsistent_edges":sum(edges[k]==2 and v!=0 for k,v in directed.items()),"degenerate_triangles":len(degenerate),"signed_volume":volume}
+142
View File
@@ -0,0 +1,142 @@
"""Built-in construction tools. Plugins register the same small interface."""
import math
from .kernel import register_operator
from .math3d import *
def require(entity,kind):
if entity.get('kind')!=kind:raise ValueError(f'Expected {kind}, got {entity.get("kind")}')
return entity
def mesh(vertices,faces,closed=True):
if closed and mesh_report(vertices,faces)['signed_volume']<0:faces=[list(reversed(f)) for f in faces]
return {'kind':'mesh','parts':[{'key':'main','vertices':vertices,'faces':faces,'closed':closed,'matrix':identity()}]}
@register_operator('curve',description='Sample a parametric 3D curve x(t), y(t), z(t). Chord-error estimate is recorded.',inputs=())
def curve(c,p,inputs):
expressions=p.get('xyz')
if not isinstance(expressions,list) or len(expressions)!=3:raise ValueError('xyz must contain three expressions')
a,b=c.vec(p.get('domain',[0,'tau']),2);n=c.count(p.get('segments',128),low=2,high=4096);closed=bool(p.get('closed',False))
if b<=a:raise ValueError('Domain must increase')
def at(t):return [c.expression(x,t=t) for x in expressions]
points=[at(a+(b-a)*i/n) for i in range(n if closed else n+1)]
if closed and length(sub(points[0],at(b)))>1e-6:raise ValueError('Closed curve endpoints do not coincide within 1e-6 m')
if any(length(sub(x,y))<1e-9 for x,y in zip(points,points[1:])):raise ValueError('Curve has coincident adjacent samples')
error=max(length(sub(at(a+(b-a)*(i+.5)/n),mul(add(points[i],points[(i+1)%len(points)]),.5))) for i in range(n))
if 'max_chord_error' in p and error>c.number(p['max_chord_error']):raise ValueError(f'Estimated chord error {error:.6g} m exceeds max_chord_error; increase segments')
return {'kind':'curve','points':points,'closed':closed,'estimated_chord_error':error}
@register_operator('polyline',description='Define a path directly from mathematical points.',inputs=())
def polyline(c,p,inputs):
pts=[c.vec(v) for v in p['points']]
if not 2<=len(pts)<=4096:raise ValueError('Polyline needs 2–4096 points')
if p.get('closed') and len(pts)<3:raise ValueError('Closed polyline needs at least 3 points')
if any(length(sub(a,b))<1e-9 for a,b in zip(pts,pts[1:])):raise ValueError('Repeated adjacent point')
if p.get('closed') and length(sub(pts[0],pts[-1]))<1e-9:raise ValueError('Do not repeat the first point of a closed polyline')
return {'kind':'curve','points':pts,'closed':bool(p.get('closed',False))}
@register_operator('frames',description='Parallel-transport local frames along a curve, with optional distributed twist.',inputs=('path',))
def frame_op(c,p,inputs):
path=require(inputs['path'],'curve');closed=path['closed']
return {'kind':'frames','closed':closed,'frames':frames(path['points'],closed,c.vec(p.get('up',[0,0,1])),c.number(p.get('twist_degrees',0)))}
@register_operator('sweep',description='Sweep a closed 2D section in the local X/Z plane along local frames.',inputs=('frames',))
def sweep(c,p,inputs):
fr=require(inputs['frames'],'frames');profile=[c.vec(v,2) for v in p['profile']]
if len(profile)>128:raise ValueError('Profile is limited to 128 vertices')
cap,area=triangulate_polygon(profile)
if area<0:profile.reverse();cap,area=triangulate_polygon(profile)
fs=fr['frames'];n,m=len(fs),len(profile)
if n*m>200000:raise ValueError('Sweep exceeds 200,000 vertices; reduce samples or section resolution')
verts=[add(f['origin'],add(mul(f['x'],x),mul(f['z'],z))) for f in fs for x,z in profile]
faces=[]
for i in range(n if fr['closed'] else n-1):
k=(i+1)%n
for j in range(m):
q=(j+1)%m;a,b,d,e=i*m+j,k*m+j,k*m+q,i*m+q
faces.extend([[a,b,d],[a,d,e]])
capped=bool(p.get('cap',True))
if not fr['closed'] and capped:
faces.extend(cap);faces.extend([[v+(n-1)*m for v in reversed(t)] for t in cap])
return mesh(verts,faces,fr['closed'] or capped)
@register_operator('surface',description='Sample x(u,v), y(u,v), z(u,v); optionally thicken into a closed shell.',inputs=())
def surface(c,p,inputs):
xyz=p['xyz'];ua,ub=c.vec(p.get('u_domain',[0,1]),2);va,vb=c.vec(p.get('v_domain',[0,1]),2)
nu=c.count(p.get('u_segments',32),low=2,high=256);nv=c.count(p.get('v_segments',16),low=2,high=256)
wu,wv=bool(p.get('wrap_u',False)),bool(p.get('wrap_v',False));rows,cols=nu if wu else nu+1,nv if wv else nv+1
if ua>=ub or va>=vb or len(xyz)!=3:raise ValueError('Invalid surface domain or xyz')
def at(u,v):return [c.expression(x,u=u,v=v) for x in xyz]
if wu and any(length(sub(at(ua,va+(vb-va)*i/nv),at(ub,va+(vb-va)*i/nv)))>1e-6 for i in range(nv+1)):raise ValueError('u seam does not close')
if wv and any(length(sub(at(ua+(ub-ua)*i/nu,va),at(ua+(ub-ua)*i/nu,vb)))>1e-6 for i in range(nu+1)):raise ValueError('v seam does not close')
verts=[at(ua+(ub-ua)*i/nu,va+(vb-va)*j/nv) for i in range(rows) for j in range(cols)]
faces=[]
for i in range(nu):
for j in range(nv):
a=i*cols+j;b=((i+1)%rows)*cols+j;d=((i+1)%rows)*cols+(j+1)%cols;e=i*cols+(j+1)%cols
faces.extend([[a,b,d],[a,d,e]])
thick=c.number(p.get('thickness',0))
if thick<0:raise ValueError('Thickness cannot be negative')
if not thick:return mesh(verts,faces,wu and wv)
normals=[[0.,0.,0.] for _ in verts]
for a,b,d in faces:
normal=cross(sub(verts[b],verts[a]),sub(verts[d],verts[a]))
for i in [a,b,d]:normals[i]=add(normals[i],normal)
normals=[unit(n) for n in normals];n=len(verts)
thick_verts=[add(v,mul(norm,thick*.5)) for v,norm in zip(verts,normals)]+[sub(v,mul(norm,thick*.5)) for v,norm in zip(verts,normals)]
all_faces=faces+[[i+n for i in reversed(f)] for f in faces];edges={}
for f in faces:
for a,b in zip(f,f[1:]+f[:1]):
key=tuple(sorted((a,b)));edges.setdefault(key,[]).append((a,b))
for edge in edges.values():
if len(edge)==1:
a,b=edge[0];all_faces.extend([[b,a,a+n],[b,a+n,b+n]])
return mesh(thick_verts,all_faces,True)
@register_operator('loft',description='Connect sampled closed 3D contours with matching vertex counts. Ends are capped when planar.',inputs=('sections',))
def loft(c,p,inputs):
curves=[require(v,'curve') for v in inputs['sections']]
if len(curves)<2 or not all(v['closed'] for v in curves):raise ValueError('Loft needs at least two closed curves')
m=len(curves[0]['points'])
if any(len(v['points'])!=m for v in curves):raise ValueError('All sections must have matching point counts and correspondence')
verts=[v for section in curves for v in section['points']];faces=[]
for i in range(len(curves)-1):
for j in range(m):
q=(j+1)%m;a,b,d,e=i*m+j,(i+1)*m+j,(i+1)*m+q,i*m+q
faces.extend([[a,b,d],[a,d,e]])
cap=bool(p.get('cap',True))
if cap:
for idx,section in [(0,curves[0]),(len(curves)-1,curves[-1])]:
pts=section['points'];origin=pts[0];x=unit(sub(pts[1],origin));normal=None
for pt in pts[2:]:
candidate=cross(x,sub(pt,origin))
if length(candidate)>1e-8:normal=unit(candidate);break
if normal is None:raise ValueError('Degenerate loft end section')
if any(abs(dot(sub(pt,origin),normal))>1e-6 for pt in pts):raise ValueError('Capped loft ends must be planar')
y=cross(normal,x);flat=[[dot(sub(pt,origin),x),dot(sub(pt,origin),y)] for pt in pts]
triangles,_=triangulate_polygon(flat)
if idx:triangles=[list(reversed(f)) for f in triangles]
faces.extend([[j+idx*m for j in f] for f in triangles])
return mesh(verts,faces,cap)
@register_operator('transform',description='Apply an affine transform to mesh instances or curve points; rotations are XYZ Euler degrees.',inputs=('source',))
def transform_op(c,p,inputs):
source=inputs['source'];scale=p.get('scale',[1,1,1]);scale=c.vec(scale) if isinstance(scale,list) else c.number(scale)
matrix=transform(c.vec(p.get('translate',[0,0,0])),c.vec(p.get('rotate',[0,0,0])),scale)
if source['kind']=='curve':return {**source,'points':[point(matrix,v) for v in source['points']]}
require(source,'mesh')
return {'kind':'mesh','parts':[{**part,'matrix':matmul(matrix,part['matrix'])} for part in source['parts']]}
@register_operator('repeat',description='Repeat a mesh using a cumulative step transform; shared mesh data and stable instance IDs.',inputs=('source',))
def repeat(c,p,inputs):
source=require(inputs['source'],'mesh');count=c.count(p.get('count',2),high=256)
if len(source['parts'])*count>2048:raise ValueError('Repeat would exceed 2048 parts')
step=transform(c.vec(p.get('translate',[0,0,0])),c.vec(p.get('rotate',[0,0,0])),c.number(p.get('scale',1)))
current=identity();parts=[]
for i in range(count):
parts.extend({**part,'key':f'{i:04}/'+part['key'],'matrix':matmul(current,part['matrix'])} for part in source['parts'])
current=matmul(step,current)
return {'kind':'mesh','parts':parts}
@register_operator('merge',description='Group mesh parts without boolean operations; preserves individual identities.',inputs=('sources',))
def merge(c,p,inputs):
return {'kind':'mesh','parts':[{**part,'key':f'{i:04}/'+part['key']} for i,source in enumerate(inputs['sources']) for part in require(source,'mesh')['parts']]}
+101
View File
@@ -0,0 +1,101 @@
"""Transactional project edits, with bounded persistent undo/redo."""
import copy
import json
import os
import tempfile
from contextlib import contextmanager
from pathlib import Path
from .kernel import build, digest, dependencies
def read_json(path):
p=Path(path)
if p.stat().st_size>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB')
with p.open() as f:return json.load(f)
def write_json(path,data):
p=Path(path);p.parent.mkdir(parents=True,exist_ok=True)
fd,temp=tempfile.mkstemp(prefix=p.name+'.',suffix='.tmp',dir=p.parent)
try:
with os.fdopen(fd,'w') as f:
json.dump(data,f,indent=2,allow_nan=False);f.write('\n');f.flush();os.fsync(f.fileno())
os.replace(temp,p)
finally:
if os.path.exists(temp):os.unlink(temp)
class Project:
def __init__(self,path):
self.path=Path(path).resolve()
self.history_path=self.path.parent/'.spatial_lab'/(self.path.name+'.history.json')
def read(self):return read_json(self.path)
def build(self):return build(self.read())
@contextmanager
def _lock(self):
self.history_path.parent.mkdir(parents=True,exist_ok=True)
with self.history_path.with_suffix('.lock').open('a+b') as f:
if os.name=='nt':
import msvcrt
f.seek(0,2)
if f.tell()==0:f.write(b'0');f.flush()
f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_NBLCK,1)
else:
import fcntl
fcntl.flock(f.fileno(),fcntl.LOCK_EX|fcntl.LOCK_NB)
try:yield
finally:
if os.name=='nt':f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_UNLCK,1)
else:fcntl.flock(f.fileno(),fcntl.LOCK_UN)
def _history(self,current):
h=read_json(self.history_path) if self.history_path.exists() else {}
# External JSON edits or an interrupted two-file write invalidate history,
# never the current project. The next edit starts a new undo chain.
if h.get('current_hash')!=digest(current):h={'past':[],'future':[],'current_hash':digest(current)}
return h
def _save(self,project,history):
history['current_hash']=digest(project)
write_json(self.history_path,history);write_json(self.path,project)
def apply(self,changes):
if not isinstance(changes,list) or not changes:raise ValueError('An edit transaction needs a nonempty list of changes')
with self._lock():
before=self.read();after=copy.deepcopy(before);h=self._history(before)
for change in changes:
action=change.get('action')
if action=='set_parameter':after.setdefault('parameters',{})[change['name']]=change['value']
elif action=='upsert_node':
node=copy.deepcopy(change['node']);ids=[n['id'] for n in after['nodes']]
if node['id'] in ids:after['nodes'][ids.index(node['id'])]=node
else:after['nodes'].append(node)
elif action=='patch_node':
node=next((n for n in after['nodes'] if n['id']==change['id']),None)
if node is None:raise ValueError('No such node: '+change['id'])
for key,value in change['patch'].items():
if key=='id':raise ValueError('Stable node IDs cannot be renamed with patch_node')
if key in ['params','inputs']:node.setdefault(key,{}).update(value)
else:node[key]=value
elif action=='remove_node':
target=change['id']
if target not in [n['id'] for n in after['nodes']]:raise ValueError('No such node: '+target)
removing={target}
while True:
dependents={n['id'] for n in after['nodes'] if any(d in removing for d in dependencies(n))}-removing
if dependents and not change.get('cascade',False):raise ValueError('Node is used by: '+', '.join(sorted(dependents)))
if not dependents:break
removing.update(dependents)
after['nodes']=[n for n in after['nodes'] if n['id'] not in removing]
else:raise ValueError(f'Unknown edit action: {action}')
result=build(after) # Failed evaluation leaves both project and history untouched.
if digest(before)==digest(after):return result
h['past']=(h['past']+[before])[-64:];h['future']=[];self._save(after,h)
return result
def travel(self,direction):
if direction not in ['undo','redo']:raise ValueError('Expected undo or redo')
with self._lock():
current=self.read();h=self._history(current);src,dst=('past','future') if direction=='undo' else ('future','past')
if not h[src]:raise ValueError('Nothing to '+direction+' (external edits start a new history chain)')
target=h[src][-1];result=build(target);h[src].pop();h[dst]=(h[dst]+[current])[-64:];self._save(target,h)
return result
+61
View File
@@ -0,0 +1,61 @@
"""A local, read-only live viewer. Authoring remains in Python/CLI transactions."""
import hashlib
import json
import threading
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
from .kernel import build,operator_catalog
from .project import read_json
def make_server(project_path,port=8767):
path=Path(project_path).resolve();assets=Path(__file__).parent/'web'
state={'source_hash':None,'bundle':None,'error':None};lock=threading.Lock()
def current():
with lock:
try:
with path.open('rb') as f:raw=f.read(64*1024*1024+1)
if len(raw)>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB')
key=hashlib.sha256(raw).hexdigest()
if key!=state['source_hash']:
state['source_hash']=key
try:state['bundle']=build(json.loads(raw));state['error']=None
except (ValueError,TypeError,KeyError) as e:state['error']=str(e)
except (OSError,ValueError) as e:state['error']=str(e)
return state.copy()
class Handler(BaseHTTPRequestHandler):
def log_message(self,fmt,*args):pass
def send(self,data,kind='application/json',status=200,etag=None,download=None):
body=data if isinstance(data,bytes) else json.dumps(data,allow_nan=False).encode()
self.send_response(status);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-cache');self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:")
if etag:self.send_header('ETag',etag)
if download:self.send_header('Content-Disposition',f'attachment; filename="{download}"')
self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
def do_GET(self):
route=urlsplit(self.path).path
if route in ['/','/app.js','/style.css']:
name='index.html' if route=='/' else route[1:];kind={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8'}[Path(name).suffix]
self.send((assets/name).read_bytes(),kind);return
if route=='/api/operators':self.send(operator_catalog());return
if route in ['/api/bundle','/export.bundle.json','/api/status']:
result=current()
if result['error']:self.send({'error':result['error'],'source':path.name,'stale':bool(result['bundle'])},status=422);return
b=result['bundle'];tag='"'+b['bundle_hash']+'"'
if route=='/api/status':self.send({'source':path.name,'project_hash':b['project_hash'],'bundle_hash':b['bundle_hash'],'stats':b['stats']});return
if self.headers.get('If-None-Match')==tag and route=='/api/bundle':self.send(b'',status=304,etag=tag);return
self.send(b,etag=tag,download=b['project_id']+'.bundle.json' if route=='/export.bundle.json' else None);return
self.send({'error':'Not found'},status=404)
server=ThreadingHTTPServer(('127.0.0.1',port),Handler);server.daemon_threads=True
return server
def serve(path,port):
build(read_json(path))
server=make_server(path,port)
print(f'Spatial Lab: http://127.0.0.1:{server.server_port}\nWatching: {Path(path).resolve()}',flush=True)
try:server.serve_forever()
except KeyboardInterrupt:pass
finally:server.server_close()
+116
View File
@@ -0,0 +1,116 @@
"use strict";
const $=s=>document.querySelector(s), canvas=$("#viewport"), gl=canvas.getContext("webgl2",{antialias:true,alpha:false});
let bundle=null,etag=null,selected=null,isolated=false,showSolid=true,showWire=false,showGuides=true,section=false,clipZ=1e9,resources=[],grid=null;
let yaw=-.9,pitch=.52,distance=62,target=[0,0,5],radius=25,viewMode="orbit",dirty=true;
const add=(a,b)=>a.map((x,i)=>x+b[i]),sub=(a,b)=>a.map((x,i)=>x-b[i]),mul=(a,s)=>a.map(x=>x*s),dot=(a,b)=>a.reduce((s,x,i)=>s+x*b[i],0),cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],unit=a=>mul(a,1/(Math.hypot(...a)||1));
const identity=()=>[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1];
function multiply(a,b){let o=new Array(16).fill(0);for(let c=0;c<4;c++)for(let r=0;r<4;r++)for(let k=0;k<4;k++)o[c*4+r]+=a[k*4+r]*b[c*4+k];return o;}
function lookAt(eye,center,up){let z=unit(sub(eye,center)),x=unit(cross(up,z)),y=cross(z,x);return[x[0],y[0],z[0],0,x[1],y[1],z[1],0,x[2],y[2],z[2],0,-dot(x,eye),-dot(y,eye),-dot(z,eye),1];}
function perspective(fov,aspect,near,far){let f=1/Math.tan(fov/2),nf=1/(near-far);return[f/aspect,0,0,0,0,f,0,0,0,0,(far+near)*nf,-1,0,0,2*far*near*nf,0];}
function ortho(w,h,near,far){return[2/w,0,0,0,0,2/h,0,0,0,0,-2/(far-near),0,0,0,-(far+near)/(far-near),1];}
function colMajor(rows){return rows[0].map((_,i)=>rows.map(r=>r[i])).flat();}
function normalMatrix(m){const a=[m[0],m[1],m[2]],b=[m[4],m[5],m[6]],c=[m[8],m[9],m[10]],d=dot(a,cross(b,c));return[...mul(cross(b,c),1/d),...mul(cross(c,a),1/d),...mul(cross(a,b),1/d)];}
let program,loc={};
function shader(type,src){let s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s;}
function initializeGL(){
if(!gl)throw new Error("WebGL 2 is unavailable. JSON export and the Python kernel still work.");
program=gl.createProgram();gl.attachShader(program,shader(gl.VERTEX_SHADER,`#version 300 es
precision highp float;
layout(location=0) in vec3 position; layout(location=1) in vec3 normal;
uniform mat4 mvp; uniform mat4 model; uniform mat3 normals;
out vec3 world; out vec3 n;
void main(){world=(model*vec4(position,1.0)).xyz;n=normals*normal;gl_Position=mvp*vec4(position,1.0);}`));
gl.attachShader(program,shader(gl.FRAGMENT_SHADER,`#version 300 es
precision highp float;in vec3 world;in vec3 n;uniform vec3 color;uniform float unlit;uniform float clipHeight;out vec4 frag;
void main(){if(world.z>clipHeight)discard;vec3 nn=normalize(n+vec3(0.00001));float light=0.50+0.38*abs(dot(nn,normalize(vec3(-0.35,-0.55,0.78))))+0.12*max(nn.z,0.0);frag=vec4(color*mix(light,1.0,unlit),1.0);}`));
gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(program));
for(const name of ["mvp","model","normals","color","unlit","clipHeight"])loc[name]=gl.getUniformLocation(program,name);
gl.enable(gl.DEPTH_TEST);gl.clearColor(.914,.933,.953,1);
}
function makeGeometry(g,lineOnly=false){
const points=g.vertices,normal=points.map(()=>[0,0,0]),faces=g.faces||[];
for(const [a,b,c] of faces){const n=cross(sub(points[b],points[a]),sub(points[c],points[a]));for(const i of [a,b,c])normal[i]=add(normal[i],n);}
const edgeSet=new Set(),edges=[];
if(g.edges){for(const e of g.edges)edges.push(...e);}else for(const f of faces)for(let j=0;j<3;j++){let a=f[j],b=f[(j+1)%3],key=Math.min(a,b)+":"+Math.max(a,b);if(!edgeSet.has(key)){edgeSet.add(key);edges.push(a,b);}}
const vao=gl.createVertexArray();gl.bindVertexArray(vao);const buffers=[];
function attribute(index,data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(data),gl.STATIC_DRAW);gl.enableVertexAttribArray(index);gl.vertexAttribPointer(index,3,gl.FLOAT,false,0,0);}
attribute(0,points.flat());attribute(1,normal.map(unit).flat());
function indices(data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,b);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(data),gl.STATIC_DRAW);return b;}
const triangles=indices(faces.flat()),lines=indices(edges);gl.bindVertexArray(null);
return{vao,buffers,triangles,lines,triangleCount:faces.length*3,lineCount:edges.length,lineOnly};
}
function dispose(){for(const r of resources){gl.deleteVertexArray(r.vao);for(const b of r.buffers)gl.deleteBuffer(b);}resources=[];}
let geometries=new Map(),drawables=[];
function install(data){
const previous=!!bundle;bundle=data;dispose();geometries=new Map();drawables=[];
for(const [id,g] of Object.entries(data.geometries)){let r=makeGeometry(g);geometries.set(id,r);resources.push(r);}
for(const o of data.objects){let resource=geometries.get(o.geometry);
if(o.kind==="curve"){let edges=o.points.slice(1).map((_,i)=>[i,i+1]);if(o.closed)edges.push([o.points.length-1,0]);resource=makeGeometry({vertices:o.points,edges},true);resources.push(resource);}
if(o.kind==="frame"){resource=makeGeometry({vertices:[[0,0,0],[.65,0,0],[0,.65,0],[0,0,.65]],edges:[[0,1],[0,2],[0,3]]},true);resources.push(resource);}
drawables.push({object:o,resource,model:colMajor(o.matrix)});
}
const min=data.bounds.min,max=data.bounds.max;radius=Math.max(1,Math.hypot(...sub(max,min))/2);
if(!previous){target=mul(add(min,max),.5);distance=radius*2.7;}
const step=10**Math.floor(Math.log10(radius/5)),extent=Math.ceil(radius*1.3/step)*step,points=[],edges=[];const base=Math.floor(min[2]/step)*step;
for(let x=-extent;x<=extent+.001;x+=step){let n=points.length;points.push([x,-extent,base],[x,extent,base],[-extent,x,base],[extent,x,base]);edges.push([n,n+1],[n+2,n+3]);}
grid=makeGeometry({vertices:points,edges},true);resources.push(grid);
$("#project-name").textContent=data.name;document.title=data.name+" — Spatial Lab";
$("#node-count").textContent=data.stats.nodes+" nodes";
$("#statistics").textContent=`${data.stats.objects} objects / ${data.stats.unique_meshes} meshes / ${data.stats.triangles.toLocaleString()} triangles`;
const warning=data.validation.warnings.length;$("#validation").textContent=warning?`${warning} topology notes`:"Mesh topology checked";$("#validation").classList.toggle("warn",warning>0);
$("#validation").title="Checks finite coordinates, triangle indices, edge incidence and solid orientation. Does not test self-intersections or walkability.";
const slider=$("#section-height");slider.min=min[2]-.1;slider.max=max[2]+.1;if(!section){slider.value=max[2]+.1;clipZ=1e9;}
if(selected&&!data.nodes.some(n=>n.id===selected))selected=null;
outline();inspector();dirty=true;
}
function outline(){
const list=$("#nodes");list.replaceChildren();
for(const n of bundle.nodes){let b=document.createElement("button");b.className="node"+(n.id===selected?" selected":"")+(!n.visible?" computational":"");b.dataset.node=n.id;b.title=n.id;
const symbol=document.createElement("span");symbol.className="symbol";symbol.textContent=n.kind==="curve"?"∿":n.kind==="frames"?"⊥":"▱";
const label=document.createElement("span");label.className="label";label.append(document.createTextNode(n.id));let small=document.createElement("small");small.textContent=n.op;label.append(small);
const vis=document.createElement("span");vis.className="visibility";vis.textContent=n.visible?"●":"";
b.append(symbol,label,vis);b.onclick=()=>{selected=selected===n.id?null:n.id;if(!selected)isolated=false;outline();inspector();dirty=true;};list.append(b);
}
}
function inspector(){
const root=$("#inspector");root.replaceChildren();$("#clear-selection").hidden=!selected;$("#isolate").setAttribute("aria-pressed",String(isolated));
if(!selected){$("#selection-name").textContent="Project parameters";$("#selection-status").textContent="All geometry";for(const [k,v] of Object.entries(bundle.recipe.parameters||{})){let row=document.createElement("div");row.className="param-row";let a=document.createElement("span"),b=document.createElement("span");a.textContent=k;b.textContent=typeof v==="string"?v:JSON.stringify(v);row.append(a,b);root.append(row);}return;}
const n=bundle.nodes.find(n=>n.id===selected);$("#selection-name").textContent=n.id;$("#selection-status").textContent=n.visible?(isolated?"Isolated: ":"Selected: ")+n.id:"Construction node: "+n.id;
let dl=document.createElement("dl");for(const [k,v] of Object.entries({operation:n.op,role:n.role,...n.inputs,...n.params})){let dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=k;dd.textContent=typeof v==="string"?v:JSON.stringify(v);dl.append(dt,dd);}root.append(dl);
}
function draw(){
if(!gl||!bundle)return;const dpr=Math.min(devicePixelRatio,2),w=Math.round(canvas.clientWidth*dpr),h=Math.round(canvas.clientHeight*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;dirty=true;}if(!dirty)return;dirty=false;
gl.viewport(0,0,w,h);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.useProgram(program);
let offset=[Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)],eye=add(target,mul(offset,distance)),v=lookAt(eye,target,[0,0,1]);
let p=viewMode==="orbit"?perspective(.70,w/h,.05,Math.max(1000,distance+radius*10)):ortho(distance*.7*w/h,distance*.7,.05,Math.max(1000,distance+radius*10));let pv=multiply(p,v);
function render(r,m,color,lines=false){gl.bindVertexArray(r.vao);gl.uniformMatrix4fv(loc.model,false,m);gl.uniformMatrix4fv(loc.mvp,false,multiply(pv,m));gl.uniformMatrix3fv(loc.normals,false,normalMatrix(m));gl.uniform3fv(loc.color,color);gl.uniform1f(loc.unlit,lines?1:0);gl.uniform1f(loc.clipHeight,section?clipZ:1e9);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,lines?r.lines:r.triangles);gl.drawElements(lines?gl.LINES:gl.TRIANGLES,lines?r.lineCount:r.triangleCount,gl.UNSIGNED_INT,0);}
if(showGuides)render(grid,identity(),[.77,.82,.86],true);
let visible=0;
for(const d of drawables){const o=d.object;if(isolated&&o.node_id!==selected)continue;if(d.resource.lineOnly&&!showGuides)continue;visible++;
const selectedObject=selected===o.node_id,color=selectedObject?[.82,.56,.28]:o.color;
if(showSolid&&!d.resource.lineOnly){gl.enable(gl.POLYGON_OFFSET_FILL);gl.polygonOffset(1,1);render(d.resource,d.model,color);gl.disable(gl.POLYGON_OFFSET_FILL);}
if(showWire||d.resource.lineOnly||selectedObject)render(d.resource,d.model,selectedObject?[.38,.24,.12]:showSolid?mul(color,.54):mul(color,.68),true);
}
$("#empty").hidden=visible>0;gl.bindVertexArray(null);
}
function animate(){draw();requestAnimationFrame(animate);}
function setView(name){viewMode=name;if(name==="top"){yaw=-Math.PI/2;pitch=Math.PI/2-.0001;}if(name==="front"){yaw=-Math.PI/2;pitch=0;}if(name==="side"){yaw=0;pitch=0;}if(name==="orbit"){yaw=-.9;pitch=.52;}document.querySelectorAll("[data-view]").forEach(b=>b.classList.toggle("active",b.dataset.view===name));$("#view-label").textContent=name==="orbit"?"Perspective":name[0].toUpperCase()+name.slice(1)+" / orthographic";dirty=true;}
for(const b of document.querySelectorAll("[data-view]"))b.onclick=()=>setView(b.dataset.view);
$("#fit").onclick=()=>{if(bundle){target=mul(add(bundle.bounds.min,bundle.bounds.max),.5);distance=radius*(viewMode==="orbit"?2.7:2.4);dirty=true;}};
function toggle(id,get,set){$(id).onclick=()=>{set(!get());$(id).setAttribute("aria-pressed",String(get()));dirty=true;};}
toggle("#solid",()=>showSolid,v=>{showSolid=v;if(!showSolid&&!showWire){showWire=true;$("#wire").setAttribute("aria-pressed","true");}});
toggle("#wire",()=>showWire,v=>{showWire=v;if(!showWire&&!showSolid){showSolid=true;$("#solid").setAttribute("aria-pressed","true");}});
toggle("#guides",()=>showGuides,v=>showGuides=v);toggle("#isolate",()=>isolated,v=>{isolated=!!selected&&v;inspector();});
$("#clear-selection").onclick=()=>{selected=null;isolated=false;outline();inspector();dirty=true;};
$("#section-enabled").onchange=e=>{section=e.target.checked;$("#section-height").disabled=!section;updateClip();};
function updateClip(){clipZ=Number($("#section-height").value);$("#section-value").textContent=section?clipZ.toFixed(2)+" m":"—";dirty=true;}$("#section-height").oninput=updateClip;
let drag=null;
canvas.addEventListener("pointerdown",e=>{canvas.setPointerCapture(e.pointerId);drag={x:e.clientX,y:e.clientY,pan:e.shiftKey||e.button===1};});
canvas.addEventListener("pointerup",()=>drag=null);canvas.addEventListener("pointercancel",()=>drag=null);
canvas.addEventListener("pointermove",e=>{if(!drag)return;const dx=e.clientX-drag.x,dy=e.clientY-drag.y;drag.x=e.clientX;drag.y=e.clientY;if(drag.pan){const right=[-Math.sin(yaw),Math.cos(yaw),0],up=cross(unit([Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)]),right);target=add(target,add(mul(right,-dx*distance/canvas.clientHeight*.65),mul(up,dy*distance/canvas.clientHeight*.65)));}else{if(viewMode!=="orbit")setView("orbit");yaw-=dx*.006;pitch=Math.max(-1.5,Math.min(1.5,pitch+dy*.006));}dirty=true;});
canvas.addEventListener("wheel",e=>{e.preventDefault();distance=Math.max(.2,Math.min(radius*30,distance*Math.exp(e.deltaY*.001)));dirty=true;},{passive:false});
canvas.addEventListener("keydown",e=>{if(e.key==="Escape")$("#clear-selection").click();if(e.key.toLowerCase()==="f")$("#fit").click();});
new ResizeObserver(()=>dirty=true).observe(canvas);
function showError(message){$("#error").hidden=false;$("#error").textContent=(bundle?"Source cannot be built. Showing the last valid geometry.\n":"")+message;$("#live-state").textContent="Build error";$("#live-state").classList.add("error");}
async function refresh(){try{const r=await fetch("/api/bundle",{headers:etag?{"If-None-Match":etag}:{}});if(r.status!==304){const data=await r.json();if(!r.ok)throw new Error(data.error||r.statusText);etag=r.headers.get("ETag");install(data);}$("#error").hidden=true;$("#live-state").textContent="Live project";$("#live-state").classList.remove("error");}catch(e){showError(e.message);}finally{setTimeout(refresh,1800);}}
try{initializeGL();refresh();animate();}catch(e){showError(e.message);}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Spatial Lab</title><link rel="stylesheet" href="/style.css"></head>
<body>
<header><strong>Spatial Lab</strong><span class="divider"></span><span id="project-name">Opening project…</span><span id="live-state" role="status">Connecting</span><a href="/export.bundle.json" id="export">Export geometry</a></header>
<main>
<aside>
<section class="outline"><div class="section-title"><h1>Construction</h1><span id="node-count"></span></div><div id="nodes"></div></section>
<section class="inspector"><div class="section-title"><h2 id="selection-name">Project parameters</h2><button id="clear-selection" title="Clear selection" hidden>×</button></div><div id="inspector"></div></section>
<footer>Author with Python or CLI.<br>Source changes appear here automatically.</footer>
</aside>
<div class="workspace">
<nav aria-label="View controls"><div class="button-group"><button data-view="orbit" class="active">Orbit</button><button data-view="top">Top</button><button data-view="front">Front</button><button data-view="side">Side</button><button id="fit">Fit</button></div><div class="button-group"><button id="solid" class="active" aria-pressed="true">Surface</button><button id="wire" aria-pressed="false">Wire</button><button id="guides" class="active" aria-pressed="true">Guides</button><button id="isolate" aria-pressed="false">Isolate</button></div></nav>
<div class="viewport-wrap"><canvas id="viewport" tabindex="0" aria-label="3D mathematical geometry preview. Drag to orbit, shift-drag to pan, scroll to zoom."></canvas><div id="view-label">Perspective</div><div id="empty" hidden>No visible geometry</div><div id="error" role="alert" hidden></div><div class="axis-key"><span>X</span><span>Y</span><span>Z ↑</span></div><div class="help">Drag to orbit · Shift-drag to pan · Scroll to zoom</div></div>
<div class="section-bar"><label><input id="section-enabled" type="checkbox"> Section Z</label><input id="section-height" aria-label="Section height" type="range" min="-10" max="20" step="0.01" disabled><output id="section-value">—</output><span id="selection-status">All geometry</span></div>
<div class="status-bar"><span id="statistics">Waiting for geometry</span><span id="validation"> </span><span>Metres / Z up</span></div>
</div>
</main><script src="/app.js"></script></body></html>
File diff suppressed because one or more lines are too long