143 lines
9.2 KiB
Python
143 lines
9.2 KiB
Python
"""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']]}
|