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
+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']]}