"""GableNest R01, PR010, MARGRAF ITALY. Original procedural CAD in millimetres.

Requires Python 3.11+, numpy, manifold3d, trimesh. Install with:
    python -m pip install numpy manifold3d trimesh
Run: python build_gablenest.py --output ../
All STL files are individual connected parts in their intended flat print orientation.
No slicer, printer, external file, network service or personal path is used.
"""
from pathlib import Path
import argparse, hashlib, json, math
import numpy as np
import manifold3d as mf
import trimesh

REV='R01'
CLEARANCE=.35
W,D,BT=165.,125.,5.
FW,FT=140.,5.
HALF=FW/2
EAVE,RIDGE=88.,122.
K=(RIDGE-EAVE)/HALF
THETA=math.atan(K)
C,S=math.cos(THETA),math.sin(THETA)
ROOF_T,ROOF_DEPTH=3.2,122.
ROOF_HALF=78.
ROOF_L=ROOF_HALF/C
ROOF_OUTER_RIDGE=BT+RIDGE+ROOF_T/C
FRAME_Y=48.
BASE_TAB_WIDTH,BASE_TAB_DEPTH=12.,3.
ROOF_TAB_WIDTH,ROOF_TAB_HEIGHT=12.,1.6
TAB_X=(26.,56.)


def box(x,y,z,dx,dy,dz):
    return mf.Manifold.cube((dx,dy,dz)).translate((x,y,z))


def poly(points,height,z=0):
    return mf.CrossSection([np.asarray(points,dtype=float)],mf.FillRule.EvenOdd).extrude(height).translate((0,0,z))


def union(items):
    return mf.Manifold.batch_boolean(items,mf.OpType.Add)


def subtract(base,items):
    return base-union(items) if items else base


def hullbox(cx,cy,z0,z1,sx0,sy0,sx1,sy1):
    vertices=[]
    for z,sx,sy in [(z0,sx0,sy0),(z1,sx1,sy1)]:
        vertices.extend([(cx-sx/2,cy-sy/2,z),(cx+sx/2,cy-sy/2,z),(cx+sx/2,cy+sy/2,z),(cx-sx/2,cy+sy/2,z)])
    return mf.Manifold.hull_points(np.array(vertices))


def base_slot(cx,cy,top=BT):
    # Actual joint A: 0.35 mm per side plus 0.45 mm entrance chamfer.
    sx,sy=BASE_TAB_WIDTH+2*CLEARANCE,FT+2*CLEARANCE
    pocket=box(cx-sx/2,cy-sy/2,top-3.4,sx,sy,3.5)
    entry=hullbox(cx,cy,top-.45,top+.01,sx,sy,sx+.9,sy+.9)
    return pocket+entry


def roof_slot(q,v,top=ROOF_T):
    # Actual joint B: keyed pocket, same 0.35 mm per side, 0.35 mm lead-in.
    sx,sy=ROOF_TAB_WIDTH+2*CLEARANCE,FT+2*CLEARANCE
    pocket=box(q-sx/2,v-sy/2,top-1.95,sx,sy,2.05)
    entry=hullbox(q,v,top-.35,top+.01,sx,sy,sx+.7,sy+.7)
    return pocket+entry


def base_tab(cx,y0=3.):
    # Plate-plane chamfer on both corners of the insertion tip.
    w=BASE_TAB_WIDTH/2
    return poly([(cx-w,y0),(cx-w,y0-2.35),(cx-w+.65,y0-3.),
                 (cx+w-.65,y0-3.),(cx+w,y0-2.35),(cx+w,y0)],FT)


def roof_tab(q,base_y=0):
    # Coupon's B tongue and production roof tongues share this exact polygon.
    a=ROOF_TAB_WIDTH/2
    return poly([(q-a,base_y-.3),(q+a,base_y-.3),(q+a,base_y+1.0),
                 (q+a-.6,base_y+1.6),(q-a+.6,base_y+1.6),(q-a,base_y+1.0)],FT)


def wood_grooves(panel,height,portal=False):
    cuts=[]
    # Broad shallow strokes. No individual fibres or decorative loose shells.
    for x in ((-64,64) if portal else (-51,-32,-13,13,32,51)):
        lo=8 if not portal else 5
        hi=min(height-12,RIDGE-K*abs(x)-10)
        cuts.append(poly([(x-.42,lo),(x+.42,lo),(x+.7,hi-7),(x+.25,hi),
                          (x-.35,hi-1),(x-.12,hi-8)],.58,FT-.55))
    if not portal:
        for x,y in [(-42,35),(19,57),(47,24),(-17,77)]:
            cuts.append(poly([(x-2,y),(x,y+1.1),(x+3,y+.4),(x+2,y-.6),(x,y-.85)],.58,FT-.55))
    return subtract(panel,cuts)


def frame(portal=False):
    # Print coordinates: X width, Y assembled height + 3, Z frame thickness.
    points=[(0,3),(FW,3),(FW,EAVE+3),(HALF,RIDGE+3),(0,EAVE+3)]
    body=poly(points,FT)
    if portal:
        opening=[(12,0),(FW-12,0),(FW-12,EAVE-5),(HALF,RIDGE-9),(12,EAVE-5)]
        body=body-poly(opening,FT+.2,-.1)
    else:
        star=[]
        for j in range(10):
            angle=math.pi/2+j*math.pi/5
            r=6.8 if j%2==0 else 3.35
            star.append((HALF+r*math.cos(angle),105+r*math.sin(angle)))
        body=body-poly(star,FT+.2,-.1)
    parts=[body]
    for cx in (-64.,64.):
        parts.append(base_tab(cx+HALF))
    # Both sloped top rails carry wide trapezoidal insertion keys.
    for side in (-1,1):
        for x in TAB_X:
            q=x/C
            tab=roof_tab(q)
            # Local q follows the roof slope; h points toward its exterior.
            transform=np.array([[side*C,side*S,0,HALF],[-S,C,0,RIDGE+3],[0,0,1,0]],float)
            # Mirrored left-side solid is resolved by manifold's transform.
            parts.append(tab.transform(transform))
    result=union(parts)
    # Move engraving coordinates from centered frame X into print X.
    # The decorative cuts are independent of, and never touch, insertion keys.
    local=result.translate((-HALF,-3,0))
    local=wood_grooves(local,RIDGE,portal)
    return local.translate((HALF,3,0))


def base():
    # Clipped corners are wide, stable and remain within 165 x 125 mm.
    a=6.
    points=[(a,0),(W-a,0),(W,a),(W,D-a),(W-a,D),(a,D),(0,D-a),(0,a)]
    solid=poly(points,BT)
    cuts=[]
    for yy in (-FRAME_Y,FRAME_Y):
        for xx in (-64.,64.):
            cuts.append(base_slot(xx+W/2,yy+D/2))
    # Quiet border grooves, 0.5 mm deep; the family stands on a broad flat floor.
    cuts.extend([box(7,10,BT-.5, .85,D-20,.6),box(W-7.85,10,BT-.5,.85,D-20,.6),
                 box(12,8,BT-.5,W-24,.85,.6),box(12,D-8.85,BT-.5,W-24,.85,.6)])
    return subtract(solid,cuts)


def roof():
    # Exterior face is Z=0. Ridge is mitred in section, producing a vertical
    # central seam after assembly. The wide faces print directly on the bed.
    section=[(0,0),(ROOF_L,0),(ROOF_L,ROOF_T),(ROOF_T*math.tan(THETA),ROOF_T)]
    # Build in (u,w), extrude v, then permute to (u,v,w).
    sheet=poly(section,ROOF_DEPTH).transform(np.array([[1,0,0,0],[0,0,1,0],[0,1,0,0]],float))
    bits=[sheet]
    for v in (ROOF_DEPTH/2-FRAME_Y,ROOF_DEPTH/2+FRAME_Y):
        for sign in (-1,1):
            center=v+sign*(FT/2+CLEARANCE+1.3)
            # 2.6 mm broad retaining cheeks; clipped ends reduce sharp corners.
            u0,u1=7.,ROOF_L-4.
            cheek=poly([(u0+.9,ROOF_T-.05),(u1-.9,ROOF_T-.05),(u1,ROOF_T+.8),
                        (u1,ROOF_T+2.8),(u1-.9,ROOF_T+3.8),(u0+.9,ROOF_T+3.8),
                        (u0,ROOF_T+2.8),(u0,ROOF_T+.8)],2.6)
            cheek=cheek.transform(np.array([[1,0,0,0],[0,0,1,center-1.3],[0,1,0,0]],float))
            bits.append(cheek)
    solid=union(bits)
    cuts=[]
    for v in (ROOF_DEPTH/2-FRAME_Y,ROOF_DEPTH/2+FRAME_Y):
        for x in TAB_X:
            # Gable contact plane is offset in local u by sheet thickness*tan.
            q=x/C+ROOF_T*math.tan(THETA)
            cuts.append(roof_slot(q,v))
    # Four broad planks with incised seams, plus sparse broad wood marks.
    for u in (ROOF_L*.25,ROOF_L*.5,ROOF_L*.75):
        cuts.append(box(u-.42,0,-.01,.84,ROOF_DEPTH,.56))
    for u,v in [(14,35),(34,79),(57,51),(74,93)]:
        cuts.append(poly([(u-1.8,v),(u,v+1.2),(u+2.7,v+.5),(u+1.8,v-.65),(u,v-.9)],.57,-.01))
    return subtract(solid,cuts)


def male_coupon():
    body=box(0,3,0,34,18,FT)
    # Joint A points toward -Y; joint B points toward +Y. Test individually.
    return union([body,base_tab(17,3),roof_tab(17,21)])


def female_coupon():
    # Connected two-station coupon. A has exact base floor/entry dimensions;
    # B repeats roof thickness, pocket, cheek width, gap and cheek height.
    body=box(0,0,0,46,24,ROOF_T)+box(0,0,0,22,24,BT)
    v=12.;q=34.
    cheeks=[]
    for sign in (-1,1):
        center=v+sign*(FT/2+CLEARANCE+1.3)
        cheeks.append(box(25,center-1.3,ROOF_T-.05,18,2.6,3.85))
    body=union([body]+cheeks)
    return subtract(body,[base_slot(11,12),roof_slot(q,v)])


def mesh_of(solid):
    if solid.status()!=mf.Error.NoError:
        raise RuntimeError(str(solid.status()))
    m=solid.to_mesh()
    mesh=trimesh.Trimesh(np.asarray(m.vert_properties)[:,:3],np.asarray(m.tri_verts),process=True)
    if not mesh.is_watertight or not mesh.is_winding_consistent or len(solid.decompose())!=1 or mesh.volume<=0:
        raise RuntimeError('Connected closed-part CAD invariant failed')
    if abs(mesh.bounds[0,2])>1e-5:
        raise RuntimeError('Print orientation must have base Z=0')
    return mesh


def matrix(rows):
    return [list(r) for r in rows]+[[0,0,0,1]]


def assembly():
    right=matrix([[C,0,-S,0],[0,-1,0,ROOF_DEPTH/2],[-S,0,-C,ROOF_OUTER_RIDGE]])
    left=np.diag([-1.,-1.,1.,1.])@np.array(right)
    return [
      {'part':'base','quantity_instance':1,'matrix_print_to_assembled_mm':matrix([[1,0,0,-W/2],[0,1,0,-D/2],[0,0,1,0]])},
      {'part':'back_wall','quantity_instance':1,'matrix_print_to_assembled_mm':matrix([[-1,0,0,HALF],[0,0,1,FRAME_Y-FT/2],[0,1,0,2.]])},
      {'part':'front_portal','quantity_instance':1,'matrix_print_to_assembled_mm':matrix([[1,0,0,-HALF],[0,0,-1,-FRAME_Y+FT/2],[0,1,0,2.]])},
      {'part':'roof_panel','quantity_instance':1,'name':'right_roof','matrix_print_to_assembled_mm':right},
      {'part':'roof_panel','quantity_instance':2,'name':'left_roof','matrix_print_to_assembled_mm':left.tolist()}]


def write(out):
    out.mkdir(parents=True,exist_ok=True)
    stldir=out/'STL';stldir.mkdir(exist_ok=True)
    parts=[('base','GableNest_Base_R01.stl',1,base()),
           ('back_wall','GableNest_Back_Wall_R01.stl',1,frame(False)),
           ('front_portal','GableNest_Front_Portal_R01.stl',1,frame(True)),
           ('roof_panel','GableNest_Roof_Panel_PRINT_2_R01.stl',2,roof()),
           ('fit_male','GableNest_Fit_Coupon_Male_R01.stl',1,male_coupon()),
           ('fit_female','GableNest_Fit_Coupon_Female_R01.stl',1,female_coupon())]
    manifest={'project':'PR010 GableNest','revision':REV,'units':'mm','status':'FROZEN_CAD_PENDING_INDEPENDENT_QA',
      'author':'MARGRAF ITALY','structural_pieces':5,'unique_structural_stl_files':4,
      'accessory_fit_coupon_pieces':2,'assembly_instances':assembly(),
      'matrix_convention':'4x4 row-major, column vector [x,y,z,1]; STL print coordinates to assembled scene; no scaling',
      'design_dimensions_mm':{'base':[W,D,BT],'frame_width':FW,'frame_thickness':FT,'frame_centers_y':[-FRAME_Y,FRAME_Y],
         'assembled_nominal_max_height':ROOF_OUTER_RIDGE,'roof_pitch_degrees':math.degrees(THETA)},
      'joint_design_mm':{'nominal_clearance_per_side':CLEARANCE,'base_tongue':[12.,5.,3.],
          'base_pocket':[12.7,5.7,3.4],'base_pocket_entry_chamfer':.45,
          'roof_tongue':[12.,5.,1.6],'roof_pocket':[12.7,5.7,1.95],
          'roof_pocket_entry_chamfer':.35,'roof_sheet_thickness':ROOF_T,
          'roof_remaining_pocket_floor':ROOF_T-1.95,'retaining_cheek_width':2.6,'retaining_cheek_height':3.8},
      'physical_validation':'Not physically printed or assembled. Fit, adhesion, painting, stability and support-free slicing are not established by CAD.',
      'parts':[]}
    for pid,name,qty,solid in parts:
        mesh=mesh_of(solid); path=stldir/name;mesh.export(path)
        manifest['parts'].append({'id':pid,'file':'STL/'+name,'quantity':qty,'role':'structure' if pid not in ('fit_male','fit_female') else 'accessory_coupon',
          'sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'print_bbox_min_mm':mesh.bounds[0].round(5).tolist(),
          'print_bbox_max_mm':mesh.bounds[1].round(5).tolist(),'print_dimensions_mm':mesh.extents.round(5).tolist(),
          'volume_mm3':round(mesh.volume,3),'triangles':len(mesh.faces),'print_orientation':'Provided orientation, flat face Z=0',
          'geometry_invariants':{'closed':bool(mesh.is_watertight),'consistent_winding':bool(mesh.is_winding_consistent),'connected_bodies':len(solid.decompose())}})
    (out/'assembly_manifest.json').write_text(json.dumps(manifest,indent=2)+'\n',encoding='utf-8')
    print(json.dumps({'output':str(out),'parts':[{'id':p['id'],'dimensions':p['print_dimensions_mm'],'triangles':p['triangles']} for p in manifest['parts']],'assembled_height_mm':ROOF_OUTER_RIDGE},indent=2))

if __name__=='__main__':
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--output',type=Path,default=Path(__file__).resolve().parents[1])
    write(parser.parse_args().output.resolve())




