Files

143 lines
9.2 KiB
Python
Raw Permalink Blame History

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