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