Initial Spatial Lab release with verified Blender bridge
This commit is contained in:
@@ -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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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'}]}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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']]}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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);}
|
||||
@@ -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
Reference in New Issue
Block a user