"""WoolRest R02 - original resting, hornless nativity sheep, MARGRAF ITALY.

Procedural sculpture in millimetres. No external geometry is required.
Requires Python 3.11+, numpy, scipy, manifold3d and trimesh.
Run: python build_woolrest.py --output ./WoolRest_R02
The STL is one connected part with its integral oval base at Z=0. The 3MF
contains the exact STL triangle coordinates, placed in positive XY by its build transform.
"""
from pathlib import Path
import argparse,hashlib,json,math,zipfile,xml.etree.ElementTree as ET
import numpy as np
from scipy.interpolate import PchipInterpolator,CubicSpline
import manifold3d as mf
import trimesh

REV='R02'
STATUS='DIGITAL_MODEL_PHYSICAL_UNTESTED'
NAME='WoolRest_Resting_Sheep_R02'
ZONES={1:('Fleece / lana','#DDD5C2'),2:('Face and outer ears / muso e orecchie','#C4B9A7'),
       3:('Folded legs / zampe ripiegate','#B8AA95'),4:('Eyes, nose and hooves / occhi, naso e zoccoli','#655746'),
       5:('Oval base / base ovale','#A2967F'),6:('Inner ears / interno orecchie','#C5A294')}
FEATURES=[]
LOCKS=[]
FILLED_CAVITIES=[]
NUMERICAL_FRAGMENTS=[]


def solid(vertices,faces,zone,name):
    vertices=np.asarray(vertices,float);faces=np.asarray(faces,np.uint32)
    m=trimesh.Trimesh(vertices,faces,process=False)
    if m.volume<0:faces=faces[:,::-1].copy()
    props=np.column_stack([vertices,np.full(len(vertices),float(zone))]).astype(np.float32)
    obj=mf.Manifold(mf.Mesh(props,faces))
    if obj.status()!=mf.Error.NoError:raise ValueError(f'{name}: {obj.status()}')
    return obj


def union(parts):
    return mf.Manifold.batch_boolean(parts,mf.OpType.Add)


def ellipsoid(center,radii,zone,name,n=80,rings=44,transform=None):
    """Rounded authored volume; authored transformation."""
    c=np.asarray(center,float);r=np.asarray(radii,float)
    units=[[0,0,-1]]
    theta=np.arange(n)*2*np.pi/n
    for latitude in np.linspace(-np.pi/2,np.pi/2,rings+2)[1:-1]:
        units.extend(np.column_stack([np.cos(latitude)*np.cos(theta),np.cos(latitude)*np.sin(theta),np.full(n,np.sin(latitude))]))
    units.append([0,0,1]);u=np.asarray(units)
    vertices=u*r+c
    if transform is not None:vertices=transform(vertices)
    faces=[]
    for k in range(n):faces.append([0,1+(k+1)%n,1+k])
    for j in range(rings-1):
        a=1+j*n
        for k in range(n):
            k1=(k+1)%n;faces.extend([[a+k,a+k1,a+n+k],[a+k1,a+n+k1,a+n+k]])
    a=1+(rings-1)*n
    for k in range(n):faces.append([len(vertices)-1,a+k,a+(k+1)%n])
    return solid(vertices,faces,zone,name)


def loft(rows,zone,name,axis='z',n=144,step=.22):
    """Capped smooth elliptical sections, individually authored per form."""
    rows=np.asarray(rows,float);tt=np.linspace(rows[0,0],rows[-1,0],max(4,int((rows[-1,0]-rows[0,0])/step)+1))
    pp=PchipInterpolator(rows[:,0],rows[:,1:],axis=0)(tt)
    angle=np.arange(n)*2*np.pi/n;vertices=[]
    for a,(cx,cy,rx,ry) in zip(tt,pp):
        ring=np.column_stack([cx+rx*np.sin(angle),cy+ry*np.cos(angle),np.full(n,a)])
        if axis=='x':ring=ring[:,[2,0,1]]
        elif axis=='y':ring=ring[:,[0,2,1]]
        vertices.extend(ring)
    for index,a in [(0,tt[0]),(-1,tt[-1])]:
        point=np.array([pp[index,0],pp[index,1],a])
        if axis=='x':point=point[[2,0,1]]
        elif axis=='y':point=point[[0,2,1]]
        vertices.append(point)
    faces=[]
    for j in range(len(tt)-1):
        a=j*n
        for k in range(n):
            k1=(k+1)%n;faces.extend([[a+k,a+k1,a+n+k],[a+k1,a+n+k1,a+n+k]])
    for k in range(n):
        k1=(k+1)%n;faces.extend([[len(vertices)-2,k1,k],[len(vertices)-1,(len(tt)-1)*n+k,(len(tt)-1)*n+k1]])
    return solid(vertices,faces,zone,name)


def tube(points,radii,zone,name,sides=32,step=.16):
    points=np.asarray(points,float);dist=np.r_[0,np.cumsum(np.linalg.norm(np.diff(points,axis=0),axis=1))]
    tt=np.linspace(0,dist[-1],max(6,int(dist[-1]/step)+1));cs=CubicSpline(dist,points,axis=0)
    curve=cs(tt);tangent=cs(tt,1);tangent/=np.linalg.norm(tangent,axis=1)[:,None]
    rr=PchipInterpolator(dist,np.asarray(radii,float))(tt);vertices=[]
    for p,d,r in zip(curve,tangent,rr):
        h=np.array([0.,1.,0.]) if abs(d[1])<.9 else np.array([1.,0.,0.])
        a=np.cross(d,h);a/=np.linalg.norm(a);b=np.cross(d,a)
        phi=np.arange(sides)*2*np.pi/sides
        vertices.extend(p+r*(np.cos(phi)[:,None]*a+np.sin(phi)[:,None]*b))
    vertices.extend([curve[0],curve[-1]]);faces=[]
    for j in range(len(tt)-1):
        a=j*sides
        for k in range(sides):
            k1=(k+1)%sides;faces.extend([[a+k,a+k1,a+sides+k],[a+k1,a+sides+k1,a+sides+k]])
    for k in range(sides):
        faces.extend([[len(vertices)-2,(k+1)%sides,k],[len(vertices)-1,(len(tt)-1)*sides+k,(len(tt)-1)*sides+(k+1)%sides]])
    obj=solid(vertices,faces,zone,name)
    # Spherical ends make grooves and leg folds rounded, never needle tips.
    return union([obj,ellipsoid(curve[0],[rr[0]]*3,zone,name+' end',n=32,rings=20),
                  ellipsoid(curve[-1],[rr[-1]]*3,zone,name+' end',n=32,rings=20)])


def sampler(obj,axis=0,sign=1):
    raw=obj.to_mesh();t=np.asarray(raw.vert_properties)[:,:3][np.asarray(raw.tri_verts)].astype(float)
    other=[i for i in range(3) if i!=axis];a=t[:,0][:,other];b=t[:,1][:,other];c=t[:,2][:,other]
    denom=(b[:,1]-c[:,1])*(a[:,0]-c[:,0])+(c[:,0]-b[:,0])*(a[:,1]-c[:,1]);good=np.abs(denom)>1e-10
    def sample(q,r):
        with np.errstate(divide='ignore',invalid='ignore'):
            u=((b[:,1]-c[:,1])*(q-c[:,0])+(c[:,0]-b[:,0])*(r-c[:,1]))/denom
            v=((c[:,1]-a[:,1])*(q-c[:,0])+(a[:,0]-c[:,0])*(r-c[:,1]))/denom;w=1-u-v
        ok=good&(u>=-1e-8)&(v>=-1e-8)&(w>=-1e-8)
        if not np.any(ok):raise ValueError(f'No surface at {q,r}')
        hits=u[ok]*t[ok,0,axis]+v[ok]*t[ok,1,axis]+w[ok]*t[ok,2,axis]
        return float(hits.max() if sign>0 else hits.min())
    return sample


def chart_ellipsoid(center,radii):
    c=np.asarray(center,float);r=np.asarray(radii,float)
    def point(uv):
        u,v=uv
        return c+r*np.array([np.sin(u),np.cos(u)*np.sin(v),np.cos(u)*np.cos(v)])
    def normal(uv):
        n=(point(uv)-c)/(r*r);return n/np.linalg.norm(n)
    return point,normal


def chart_neck(rows):
    rows=np.asarray(rows,float);curve=PchipInterpolator(rows[:,0],rows[:,1:],axis=0)
    def point(uv):
        z,theta=uv;cx,cy,rx,ry=curve(z)
        return np.array([cx+rx*np.cos(theta),cy+ry*np.sin(theta),z])
    def normal(uv):
        z,theta=uv;cx,cy,rx,ry=curve(z);dx,dy,drx,dry=curve(z,1)
        a=np.array([dx+drx*np.cos(theta),dy+dry*np.sin(theta),1.])
        b=np.array([-rx*np.sin(theta),ry*np.cos(theta),0.])
        n=np.cross(b,a);return n/np.linalg.norm(n)
    return point,normal


def physical_lock_control(chart,control,name):
    # Define the C/S sweep in real millimetres before mapping it to the anatomy.
    # This retains broad, legible curves instead of compressing them at a pole.
    point,normal=chart;anchor=(np.asarray(control[0])+np.asarray(control[3]))/2
    eps=.0001;du=(point(anchor+[eps,0])-point(anchor-[eps,0]))/(2*eps);dv=(point(anchor+[0,eps])-point(anchor-[0,eps]))/(2*eps)
    un=du/np.linalg.norm(du);vn=dv/np.linalg.norm(dv);n=normal(anchor)
    parity=sum(ord(c) for c in name)%3
    cshape=np.array([[-1.3,-3.5],[1.95,-2.3],[1.95,2.3],[-1.3,3.5]])
    sshape=np.array([[-1.2,-4.2],[1.7,-1.4],[-1.7,1.4],[1.2,4.2]])
    shape=cshape if parity==0 else sshape
    if name.startswith('body lock'):
        side=-1 if anchor[1]<0 else 1
        if name.split()[2].startswith('1-'):
            flow=side*vn;cross=un;shape=np.array([[0,-2.9],[2.7,-1.9],[2.7,1.9],[0,2.9]])
        else:
            flow=-.62*un+side*.78*vn;flow/=np.linalg.norm(flow);cross=np.cross(n,flow);cross/=np.linalg.norm(cross)
    elif name.startswith('neck lock'):
        flow=-un;cross=vn;shape=(cshape*np.array([1.08,1.16])) if parity==0 else (sshape*np.array([1.04,1.08]))
    elif name.startswith('nape lock'):
        flow=-un;cross=vn;shape=cshape*np.array([.48,.46])
    elif name.startswith('haunch'):
        side=-1 if 'haunch -1' in name else 1;flow=side*vn;cross=un;shape=np.array([[0,-2.25],[2.10,-1.50],[2.10,1.50],[0,2.25]])
    elif name.startswith('forelock'):
        flow=un;cross=vn;shape=cshape*np.array([.62,.63])
    else:
        flow=un;cross=vn;shape=cshape*np.array([.52,.56])
    jac=np.column_stack([du,dv]);inverse=np.linalg.pinv(jac)
    return np.array([anchor+inverse@(cross*x+flow*y) for x,y in shape])


def ribbon(chart,control,width,height,name,groove=True,burial=.75):
    """A closed, broad-rooted Bezier fleece lock on the actual parent surface.

    Every root is buried over the whole width; every terminal is blunt and
    returns to the parent. The paired raised crests and central channel are
    geometry, with no dangling torus curls, image texture or fur fibres.
    """
    point,normal=chart;requested=physical_lock_control(chart,control,name);tt=np.linspace(0,1,49)
    linear=np.array([requested[0],requested[0]+(requested[3]-requested[0])/3,requested[0]+2*(requested[3]-requested[0])/3,requested[3]])
    # Broad strokes must not fold back through themselves on a tight surface.
    # Preserve each authored C/S curve where its physical bend radius permits;
    # relax only excessive curvature before constructing the closed solid.
    original_width=width;relaxation=0.
    for relaxation in (0.,.15,.30,.45,.60,.75,.90,1.):
        control=requested*(1-relaxation)+linear*relaxation
        curve=(1-tt[:,None])**3*control[0]+3*(1-tt[:,None])**2*tt[:,None]*control[1]+3*(1-tt[:,None])*tt[:,None]**2*control[2]+tt[:,None]**3*control[3]
        xyz=np.array([point(uv) for uv in curve]);first=np.gradient(xyz,axis=0);second=np.gradient(first,axis=0)
        speed=np.linalg.norm(first,axis=1);curvature=np.linalg.norm(np.cross(first,second),axis=1)/np.maximum(speed**3,1e-12)
        max_curvature=float(curvature[2:-2].max())
        if max_curvature*width/2<.66:break
    if max_curvature*width/2>=.66:width=min(width,1.30/max_curvature)
    if width<1.75:raise ValueError(f'{name}: supported lock cannot retain a broad section')
    deriv=3*(1-tt[:,None])**2*(control[1]-control[0])+6*(1-tt[:,None])*tt[:,None]*(control[2]-control[1])+3*tt[:,None]**2*(control[3]-control[2])
    across=np.linspace(-1,1,13);vertices=[]
    for layer in range(2):
        for j,(uv,d) in enumerate(zip(curve,deriv)):
            eps=.0001;du=(point(uv+[eps,0])-point(uv-[eps,0]))/(2*eps);dv=(point(uv+[0,eps])-point(uv-[0,eps]))/(2*eps)
            jac=np.column_stack([du,dv]);tan=jac@d;tan/=np.linalg.norm(tan);n=normal(uv)
            cross=np.cross(tan,n);cross/=np.linalg.norm(cross);parameter_cross=np.linalg.lstsq(jac,cross,rcond=None)[0]
            fade=np.sin(np.pi*tt[j])**1.15;span=width*(.58+.42*np.sin(np.pi*tt[j])**.5)
            for side in across:
                mapped=uv+parameter_cross*side*span/2;p=point(mapped);out=normal(mapped)
                if layer==0:
                    crown=height*max(0,1-side**4)**1.4
                    channel=.36*np.exp(-(side*span/2/.43)**4) if groove else 0.
                    lift=-.065+fade*(crown-channel)
                else:lift=-burial
                vertices.append(p+out*lift)
    n=len(across);count=len(tt)*n;faces=[]
    for layer in range(2):
        for j in range(len(tt)-1):
            for k in range(n-1):
                a=layer*count+j*n+k;tri=[[a,a+1,a+n],[a+1,a+n+1,a+n]]
                faces.extend(tri if layer==0 else [x[::-1] for x in tri])
    for j in range(len(tt)-1):
        a=j*n;faces.extend([[a,a+n,a+count],[a+n,a+n+count,a+count]])
        a=j*n+n-1;faces.extend([[a,a+count,a+n],[a+n,a+count,a+n+count]])
    for k in range(n-1):
        faces.extend([[k,k+count,k+1],[k+1,k+count,k+count+1]])
        a=(len(tt)-1)*n+k;faces.extend([[a,a+1,a+count],[a+1,a+count+1,a+count]])
    obj=solid(vertices,faces,1,name)
    LOCKS.append({'name':name,'control_uv':control.tolist(),'requested_control_uv':requested.tolist(),'curvature_relaxation':relaxation,'requested_width_mm':original_width,'width_mm':width,'height_mm':height,'root_burial_mm':burial,'central_groove_nominal_width_mm':.86 if groove else None,'central_groove_nominal_depth_mm':.36 if groove else None})
    return obj


def construct():
    FEATURES.clear();LOCKS.clear();FILLED_CAVITIES.clear();NUMERICAL_FRAGMENTS.clear()
    parts=[];cuts=[]
    parts.append(loft([[0,0,0,28.65,16.15],[.30,0,0,29.25,16.75],[1.75,0,0,29.25,16.75],[2.45,0,0,28.55,16.05]],5,'low integral oval base',step=.12))
    body_center=[-5.5,0,11.9];body_radii=[18.6,10.6,10.35]
    parts.append(ellipsoid(body_center,body_radii,1,'resting fleece foundation',n=160,rings=80))
    body_chart=chart_ellipsoid(body_center,body_radii)
    # Forty-eight overlapping locks follow a controlled dorsal-to-flank flow.
    for row,u in enumerate([-1.08,-.70,-.32,.06,.44,.82]):
        for col,v0 in enumerate([-2.10,-1.50,-.90,-.30,.30,.90,1.50,2.10]):
            side=-1 if v0<0 else 1;v=v0+(.04 if row%2 else -.025)
            if (row+col)%3==0:
                control=[[u+.16,v-side*.29],[u-.28,v-side*.10],[u-.25,v+side*.34],[u+.04,v+side*.27]]
            else:
                control=[[u+.18,v-side*.30],[u-.18,v-side*.13],[u+.24,v+side*.10],[u-.16,v+side*.31]]
            width=3.45+.13*((row+col)%3);height=1.22+.06*((2*row+col)%3)
            parts.append(ribbon(body_chart,control,width,height,f'body lock {row+1}-{col+1}'))
    # A single shaped throat/chest has continuous sections.
    # The same analytic sections locate each neck lock exactly.
    neck_rows=[[2.2,8.7,0,4.6,4.8],[5,9.0,0,6.0,5.9],[10,9.5,0,6.7,6.4],
       [16,10.3,0,6.8,6.4],[21,11.1,0,6.3,6.0],[26,12.2,0,5.3,5.0],
       [29,13.5,0,3.8,3.4],[30,14.0,0,2.0,1.9]]
    neck=loft(neck_rows,1,'continuous shaped wool neck and breast',n=144,step=.18);parts.append(neck)
    neck_chart=chart_neck(neck_rows)
    for row,z in enumerate([7.3,14.9,22.3]):
        for col,v in enumerate([-2.1,-1.4,-.7,0,.7,1.4,2.1]):
            if (row+col)%2:
                control=[[z+4.25,v-.14],[z+1.1,v+.34],[z-1.0,v-.34],[z-4.25,v+.14]]
            else:
                control=[[z+4.25,v-.19],[z+.9,v+.43],[z-4.8,v+.25],[z-3.8,v-.20]]
            parts.append(ribbon(neck_chart,control,3.55+.1*(col%2),1.25+.06*(row%2),f'neck lock {row+1}-{col+1}'))
    for index,v in enumerate([-1.35,1.35,3.0]):
        control=[[29.0,v-.18],[28.4,v+.32],[26.7,v+.18],[26.0,v-.16]]
        parts.append(ribbon(neck_chart,control,2.65,1.04,f'nape lock {index+1}',groove=False))
    # Wool-covered folded haunches, with small lower locks rather than beads.
    for side in (-1,1):
        c=[-14.1,side*7.0,6.8];r=[6.2,3.8,4.6]
        parts.append(ellipsoid(c,r,1,'folded wool haunch',n=104,rings=52));haunch=chart_ellipsoid(c,r)
        for index,(u,v) in enumerate([(-.65,side*.80),(-.20,side*1.45),(.35,side*.90),(-.50,side*1.80)]):
            control=[[u+.23,v-side*.30],[u-.26,v-side*.12],[u+.28,v+side*.20],[u-.21,v+side*.35]]
            parts.append(ribbon(haunch,control,2.55,1.04,f'haunch {side} lock {index+1}',groove=False))
        # The front shin curls back; the hind shin folds forward under the body.
        fore=[[10.3,side*6.6,6.8],[11.8,side*8.0,5.2],[10.6,side*9.5,4.0],[7.2,side*10.2,3.8],[4.3,side*10.3,3.85]]
        hind=[[-15.1,side*6.8,5.7],[-16.2,side*8.0,4.8],[-14.2,side*9.9,4.0],[-11.4,side*10.4,3.8],[-8.8,side*10.4,3.85]]
        parts.append(tube(fore,[2.15,2.0,1.7,1.45,1.40],3,'anatomically folded foreleg',sides=40,step=.13))
        parts.append(tube(hind,[2.35,2.15,1.7,1.45,1.40],3,'anatomically folded hindleg',sides=40,step=.13))
        for x,y in [(2.9,side*10.3),(-7.7,side*10.4)]:
            hoof=loft([[x-2.0,y,3.65,1.00,.88],[x-1.4,y,3.65,1.48,1.28],
              [x+1.35,y,3.75,1.50,1.17],[x+2.0,y,3.75,1.05,.82]],4,'rounded wedge-shaped cloven hoof',axis='x',n=96,step=.12)
            parts.append(hoof)
            cuts.append(tube([[x-1.4,y,4.71],[x-.1,y,4.87],[x+1.35,y,4.71]],[.43,.43,.43],4,'cloven hoof dividing groove',sides=32,step=.12))
    # Short, backed tail: three laid-down locks with no dangling curl ends.
    tail_center=[-23.1,0,7.7];tail_radii=[3.25,2.35,3.2]
    parts.append(ellipsoid(tail_center,tail_radii,1,'short supported tail',n=88,rings=44))
    tail_chart=chart_ellipsoid(tail_center,tail_radii)
    for index,v in enumerate([-1.0,0,1.0]):
        control=[[-.75,v-.25],[-.2,v+.24],[.3,v-.17],[.66,v+.23]]
        parts.append(ribbon(tail_chart,control,2.2,.86,f'tail lock {index+1}',groove=False))
    # One continuous head surface controls the nasal bridge, cheek, jaw and chin.
    head=loft([[12.9,0,25.4,2.9,4.0],[15.0,0,26.4,4.0,5.6],[17.7,0,27.0,4.55,5.8],
      [20.0,0,26.8,4.25,5.2],[22.3,0,25.9,3.70,4.4],[24.5,0,24.4,3.15,3.25],
      [26.4,0,23.7,2.6,2.55],[27.7,0,23.6,1.95,2.05],[28.5,0,23.65,1.35,1.65],
      [29.0,0,23.65,.75,1.05],[29.2,0,23.65,.30,.50]],2,'refined ovine head and rounded jaw',axis='x',n=160,step=.12)
    # Shallow eye sockets are cut before the integrated lids and eyeballs.
    eye_specs=[];socket_cuts=[];face_features=[]
    for side in (-1,1):
        sy=sampler(head,axis=1,sign=side);x,z=20.4,28.75;y=sy(x,z)
        socket_cuts.append(ellipsoid([x,y+side*.55,z],[1.82,.80,1.22],2,'shallow almond eye socket',n=80,rings=44))
        face_features.append(ellipsoid([x,y-side*.47,z],[1.48,.63,.86],2,'backed inset eye globe',n=80,rings=44))
        for label,coords,rad in [('upper',[(18.95,29.0),(20.35,29.80),(21.85,29.05)],.47),
                                 ('lower',[(19.0,28.62),(20.35,28.08),(21.8,28.65)],.42)]:
            pp=[[xx,sy(xx,zz)-side*.13,zz] for xx,zz in coords]
            face_features.append(tube(pp,[rad]*3,2,label+' integrated eyelid',sides=32,step=.11))
        eye_specs.append((side,x,z,y))
    head=head-union(socket_cuts);parts.append(head);parts.extend(face_features)
    # A low forelock foundation is overlaid by seven deliberately swept locks.
    cap_center=[15.7,0,31.0];cap_radii=[4.0,4.2,2.15]
    parts.append(ellipsoid(cap_center,cap_radii,1,'low forelock foundation',n=112,rings=56));cap_chart=chart_ellipsoid(cap_center,cap_radii)
    for index,(u,v) in enumerate([(-.52,-.85),(.05,-.90),(.55,-.60),(-.52,0),(.05,.10),(.55,.65),(-.50,.85)]):
        control=[[u-.30,v-.26],[u-.08,v+.34],[u+.32,v+.25],[u+.40,v-.15]]
        parts.append(ribbon(cap_chart,control,2.45,1.10,f'forelock {index+1}',groove=True,burial=.8))
    # Leaf ears are long-section lofts with supported roots, a shallow pinna and
    # a rounded inner ridge. Their free ends retain broad, blunt sections.
    for side in (-1,1):
        ear=loft([[3.1,17.4,29.8,1.9,1.3],[4.6,16.9,29.7,2.6,1.45],
          [6.8,16.2,29.1,2.45,1.25],[8.5,15.5,28.6,1.75,1.0],
          [9.5,15.1,28.4,1.1,.8],[10.0,15.0,28.3,.7,.7]],2,'broad-rooted leaf ear',axis='y',n=104,step=.12)
        bowl=loft([[4.3,16.95,31.30,.55,.55],[5.0,16.75,31.10,1.4,.62],
          [6.8,16.2,30.45,1.40,.62],[8.3,15.6,29.85,.95,.56],
          [9.05,15.25,29.45,.35,.38]],6,'shallow elongated ear pinna',axis='y',n=88,step=.12)
        ear=union([ear,ellipsoid([15.0,10.0,28.3],[.70,.8,.70],2,'rounded leaf ear terminal',n=64,rings=32)])-bowl;sample_top=sampler(ear,axis=2,sign=1)
        pp=[]
        for x,y in [(16.8,5.0),(16.25,6.5),(15.75,7.9)]:pp.append([x,y,sample_top(x,y)-.12])
        ear=union([ear,tube(pp,[.44,.43,.42],2,'backed inner ear fold',sides=28,step=.12)])
        if side<0:ear=ear.transform(np.array([[1,0,0,0],[0,-1,0,0],[0,0,1,0]],float))
        parts.append(ear)
    obj=union(parts)
    # Only a small recessed iris, two nostrils and short natural lip clefts.
    for side,x,z,y in eye_specs:
        eye_surface=sampler(obj,axis=1,sign=side)(x,z)
        cuts.append(ellipsoid([x,eye_surface+side*.14,z],[.52,.29,.57],4,'recessed iris',n=72,rings=40))
    nose=sampler(obj,axis=0,sign=1)
    for side in (-1,1):
        y=side*1.10;z=24.15;x=nose(y,z)
        angle=side*math.radians(28);rot=np.array([[1,0,0],[0,np.cos(angle),-np.sin(angle)],[0,np.sin(angle),np.cos(angle)]])
        c=np.array([x+.27,y,z])
        cuts.append(ellipsoid(c,[.53,.40,.72],4,'slanted ovine nostril',n=72,rings=40,transform=lambda v,c=c,R=rot:(v-c)@R.T+c))
    # Closed sheep lips: a short central cleft and two small side creases,
    # with no frontal smile opening or tooth-like ridges.
    pp=[[nose(0,z)+.23,0,z] for z in (23.53,23.27,23.02)]
    cuts.append(tube(pp,[.43]*3,4,'short natural upper lip cleft',sides=32,step=.10))
    for side in (-1,1):
        cheek=sampler(obj,axis=1,sign=side);pp=[]
        for x,z in [(25.9,22.45),(26.6,22.42),(27.2,22.50)]:
            y=cheek(x,z);pp.append([x,y+side*.34,z])
        cuts.append(tube(pp,[.55]*3,4,'short closed mouth corner',sides=32,step=.10))
    obj=(obj-union(cuts)).simplify(.001)
    positive=[]
    for component in obj.decompose():
        if component.volume()<0:FILLED_CAVITIES.append(abs(component.volume()))
        elif component.volume()<1e-5:NUMERICAL_FRAGMENTS.append(component.volume())
        else:positive.append(component)
    if len(positive)!=1:raise ValueError(f'Expected one positive body, found {[(p.volume(),p.bounding_box()) for p in positive]}')
    FEATURES.extend([{'name':'Organised sculpted fleece locks','count':len(LOCKS),'construction':'Closed surface-following Bezier ribbons with broad buried roots and backed blunt terminals','body_count':48,'neck_count':24,'haunch_count':8,'forelock_count':7,'tail_count':3},
       {'name':'Refined head','construction':'One controlled nasal/cheek/jaw loft; recessed sockets, integrated upper and lower lids, recessed irises, slanted nostrils and short lip clefts; no teeth'},
       {'name':'Leaf ears','construction':'Broad-rooted tapered lofts with shallow pinna and backed inner folds','minimum_terminal_section_nominal_mm':[1.4,1.4]},
       {'name':'Folded legs','construction':'Four continuous bent limbs and rounded wedge hooves','minimum_lower_leg_diameter_nominal_mm':2.8,'base_top_z_mm':2.45}])
    return positive[0]


def write_3mf(mesh,path):
    ns='http://schemas.microsoft.com/3dmanufacturing/core/2015/02';ET.register_namespace('',ns)
    tag=lambda name:'{'+ns+'}'+name
    root=ET.Element(tag('model'),{'unit':'millimeter','{http://www.w3.org/XML/1998/namespace}lang':'en-US'})
    ET.SubElement(root,tag('metadata'),{'name':'Title'}).text='WoolRest - Resting Sheep R02'
    ET.SubElement(root,tag('metadata'),{'name':'Designer'}).text='MARGRAF ITALY'
    resources=ET.SubElement(root,tag('resources'));obj=ET.SubElement(resources,tag('object'),{'id':'1','type':'model','name':NAME})
    geometry=ET.SubElement(obj,tag('mesh'));vertices=ET.SubElement(geometry,tag('vertices'))
    local,inverse=np.unique(np.asarray(mesh.vertices,dtype=np.float32).astype(float),axis=0,return_inverse=True)
    faces=inverse[np.asarray(mesh.faces)]
    for v in local:ET.SubElement(vertices,tag('vertex'),dict(zip(('x','y','z'),[repr(float(a)) for a in v])))
    triangles=ET.SubElement(geometry,tag('triangles'))
    for f in faces:ET.SubElement(triangles,tag('triangle'),dict(zip(('v1','v2','v3'),map(str,f))))
    build=ET.SubElement(root,tag('build'));ET.SubElement(build,tag('item'),{'objectid':'1','transform':'1 0 0 0 1 0 0 0 1 40 30 0'})
    data={'[Content_Types].xml':b'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>',
          '_rels/.rels':b'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>',
          '3D/3dmodel.model':ET.tostring(root,encoding='utf-8',xml_declaration=True)}
    with zipfile.ZipFile(path,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as archive:
        for name,payload in data.items():
            info=zipfile.ZipInfo(name,date_time=(1980,1,1,0,0,0));info.compress_type=zipfile.ZIP_DEFLATED
            archive.writestr(info,payload,compress_type=zipfile.ZIP_DEFLATED,compresslevel=9)


def save(obj,out):
    for name in ('STL','3MF','QA'): (out/name).mkdir(parents=True,exist_ok=True)
    raw=obj.to_mesh();props=np.asarray(raw.vert_properties);v=props[:,:3];f=np.asarray(raw.tri_verts)
    zones=np.rint(props[f,3].mean(axis=1)).astype(np.uint8)
    mesh=trimesh.Trimesh(v,f,process=False)
    # Exact zero-area float32 Boolean triangles carry no surface. Remove them
    # with the same mask applied to the paint zones, preserving triangle order.
    keep=mesh.nondegenerate_faces(height=1e-8)
    removed_degenerate_faces=int((~keep).sum())
    mesh.update_faces(keep);mesh.remove_unreferenced_vertices();zones=zones[keep]
    stl=out/'STL'/(NAME+'.stl');mesh.export(stl)
    mfpath=out/'3MF'/(NAME+'.3mf');write_3mf(mesh,mfpath)
    reread=trimesh.load_mesh(stl,process=True)
    if not reread.is_watertight or not reread.is_winding_consistent or reread.volume<=0:raise ValueError('Exported STL invariant failed')
    if np.any(reread.extents>np.array([60,36,36])+1e-4):raise ValueError('Requested size envelope exceeded')
    if abs(reread.bounds[0,2])>1e-6:raise ValueError('Integral base must have minimum Z=0')
    np.save(out/'QA/face_zones.npy',zones)
    feature_doc={'removed_zero_area_export_triangles':removed_degenerate_faces,'design_features':FEATURES,'fleece_locks':LOCKS,'filled_internal_cavities_mm3':FILLED_CAVITIES,'removed_numerical_fragments_mm3':NUMERICAL_FRAGMENTS}
    (out/'QA/FEATURE_DESIGN.json').write_text(json.dumps(feature_doc,indent=2)+'\n',encoding='utf-8')
    dims=reread.extents.round(6).tolist();bounds=reread.bounds.round(6).tolist()
    manifest={'project':'PR011 WoolRest','revision':REV,'name':'WoolRest - Resting Sheep','author':'MARGRAF ITALY',
      'units':'mm','status':STATUS,'source':'Source/build_woolrest.py','geometry_origin':'Procedurally authored geometry; no external mesh input',
      'parts':[{'id':'resting_sheep','quantity':1,'file':'STL/'+stl.name,'three_mf_file':'3MF/'+mfpath.name,'print_orientation':'Integral oval base at Z=0','dimensions_mm':dims,'bounds_mm':bounds,'volume_mm3':float(reread.volume),'triangles':len(reread.faces)}],
      'three_mf':{'units':'millimeter','objects':1,'build_items':1,'matrix_stl_to_model_mm':[[1,0,0,40],[0,1,0,30],[0,0,1,0],[0,0,0,1]],'build_item_transform':'1 0 0 0 1 0 0 0 1 40 30 0','coordinate_policy':'Exact float32 STL coordinates, local mesh and build placement','model_bbox_mm':(np.array(bounds)+np.array([40,30,0])).round(6).tolist(),'slicer_profile_included':False},
      'render_masks':{'file':'QA/face_zones.npy','format':'numpy uint8, one zone per triangle in the exact exported STL triangle order','triangles':len(zones),'zones':{str(k):{'label':n,'example_hex':c,'triangle_count':int(np.count_nonzero(zones==k))} for k,(n,c) in ZONES.items()}},
      'views':{'front':{'azimuth_degrees':0,'elevation_degrees':10},'side':{'azimuth_degrees':-90,'elevation_degrees':10},'three_quarter':{'azimuth_degrees':-48,'elevation_degrees':18}},
      'geometry_invariants':{'closed':bool(reread.is_watertight),'consistent_winding':bool(reread.is_winding_consistent),'positive_volume':bool(reread.volume>0),'connected_positive_bodies':len(obj.decompose()),'minimum_z_mm':float(reread.bounds[0,2])},
      'validation_limits':['Digital model; use the supplied printing notes and inspect your own sliced preview.','No physical printing, support removal, fit, strength, stability or painting test performed.'],
      'files':{}}
    for p in (stl,mfpath,out/'QA/face_zones.npy',Path(__file__)):
        key='Source/build_woolrest.py' if p==Path(__file__) else p.relative_to(out).as_posix()
        manifest['files'][key]={'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'bytes':p.stat().st_size}
    (out/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
    print(json.dumps({'status':STATUS,'dimensions_mm':dims,'volume_mm3':reread.volume,'triangles':len(reread.faces),'stl_sha256':manifest['files']['STL/'+stl.name]['sha256'],'zones':manifest['render_masks']['zones']},indent=2),flush=True)

if __name__=='__main__':
    ap=argparse.ArgumentParser(description=__doc__)
    ap.add_argument('--output',type=Path,default=Path(__file__).resolve().parents[1])
    args=ap.parse_args();save(construct(),args.output.resolve())





