#!/usr/bin/env python3
"""Generate V2 clevis/hinge STL files for Sott's dual monitor arm prototype.

V2 change: every pivot becomes a double-shear clevis/hinge joint:
- clamp pivot bosses are fork/clevis flanges;
- each arm has one male tab end and one clevis end;
- final monitor cradle has a male tab;
- M4 bolts capture the inner tab between two outer flanges.
"""
from __future__ import annotations
from pathlib import Path
import hashlib, json, math, struct, zipfile
from functools import reduce
from typing import Iterable, List, Sequence, Tuple

import numpy as np
from manifold3d import CrossSection, Manifold
from shapely import affinity
from shapely.geometry import Point, Polygon, box
from shapely.geometry.polygon import orient
from shapely.ops import unary_union

OUT = Path(__file__).resolve().parent
Vec = Tuple[float, float, float]
Tri = Tuple[Vec, Vec, Vec]

M4_CLEAR = 4.5
POLE_CLEAR = 35.6
PIVOT_TAB_THICKNESS = 8.0
CLEVIS_FLANGE_THICKNESS = 5.0
CLEVIS_GAP = 9.0
CLEVIS_TOTAL_THICKNESS = CLEVIS_FLANGE_THICKNESS * 2 + CLEVIS_GAP
MALE_TAB_Z0 = (CLEVIS_TOTAL_THICKNESS - PIVOT_TAB_THICKNESS) / 2
MALE_TAB_Z1 = MALE_TAB_Z0 + PIVOT_TAB_THICKNESS
UPPER_FLANGE_Z0 = CLEVIS_FLANGE_THICKNESS + CLEVIS_GAP
UPPER_FLANGE_Z1 = UPPER_FLANGE_Z0 + CLEVIS_FLANGE_THICKNESS


def sub(a: Vec, b: Vec) -> Vec:
    return (a[0]-b[0], a[1]-b[1], a[2]-b[2])


def cross(a: Vec, b: Vec) -> Vec:
    return (a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0])


def norm(v: Vec) -> Vec:
    l = math.sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2])
    if l == 0:
        return (0.0, 0.0, 0.0)
    return (v[0]/l, v[1]/l, v[2]/l)


def tri_normal(t: Tri) -> Vec:
    return norm(cross(sub(t[1], t[0]), sub(t[2], t[0])))


def write_binary_stl(path: Path, triangles: Sequence[Tri], name: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('wb') as f:
        header = (name[:76] + ' ' * 80).encode('ascii', errors='ignore')[:80]
        f.write(header)
        f.write(struct.pack('<I', len(triangles)))
        for tri in triangles:
            n = tri_normal(tri)
            f.write(struct.pack('<3f', *n))
            for v in tri:
                f.write(struct.pack('<3f', *v))
            f.write(struct.pack('<H', 0))


def manifold_to_tris(m: Manifold) -> List[Tri]:
    mesh = m.to_mesh()
    verts = np.asarray(mesh.vert_properties, dtype=float)
    faces = np.asarray(mesh.tri_verts, dtype=int)
    tris: List[Tri] = []
    for a, b, c in faces:
        tris.append((tuple(verts[a]), tuple(verts[b]), tuple(verts[c])))  # type: ignore[arg-type]
    return tris


def contours_from_polygon(poly: Polygon):
    poly = orient(poly, sign=1.0)  # exterior CCW, holes CW for manifold fill
    contours = []
    ext = np.asarray(list(poly.exterior.coords)[:-1], dtype=np.float32)
    if len(ext) >= 3:
        contours.append(ext)
    for interior in poly.interiors:
        arr = np.asarray(list(interior.coords)[:-1], dtype=np.float32)
        if len(arr) >= 3:
            contours.append(arr)
    return contours


def extrude_geom(geom, z0: float, z1: float) -> Manifold:
    if geom.is_empty or z1 <= z0:
        return Manifold()
    polys = list(geom.geoms) if hasattr(geom, 'geoms') else [geom]
    solids = []
    for poly in polys:
        if poly.is_empty or poly.area <= 0:
            continue
        cs = CrossSection(contours_from_polygon(poly))
        solids.append(cs.extrude(z1 - z0).translate([0, 0, z0]))
    if not solids:
        return Manifold()
    return reduce(lambda a, b: a + b, solids)



def cube_box(x0: float, x1: float, y0: float, y1: float, z0: float, z1: float) -> Manifold:
    return Manifold.cube([x1-x0, y1-y0, z1-z0], center=False).translate([x0, y0, z0])

def combine(solids: Iterable[Manifold]) -> Manifold:
    solids = [s for s in solids if not s.is_empty()]
    if not solids:
        return Manifold()
    return reduce(lambda a, b: a + b, solids)


def capsule(length: float, width: float, resolution: int = 32) -> Polygon:
    r = width / 2
    rect = box(-length/2 + r, -r, length/2 - r, r)
    return unary_union([
        rect,
        Point(-length/2 + r, 0).buffer(r, resolution=resolution),
        Point(length/2 - r, 0).buffer(r, resolution=resolution),
    ])


def shapely_translate(geom, xoff=0.0, yoff=0.0):
    return affinity.translate(geom, xoff=xoff, yoff=yoff)


def arm_solid(length=180.0, width=20.0, height=20.0, hole_d=M4_CLEAR) -> Manifold:
    r_outer = width / 2
    r_hole = hole_d / 2
    left_c = -length/2 + r_outer
    right_c = length/2 - r_outer
    body = box(left_c - 2.0, -width/2, right_c + 2.0, width/2)
    male_tab = Point(left_c, 0).buffer(r_outer, resolution=32).difference(Point(left_c, 0).buffer(r_hole, resolution=24))
    clevis_tab = Point(right_c, 0).buffer(r_outer, resolution=32).difference(Point(right_c, 0).buffer(r_hole, resolution=24))
    bands = sorted(set([0.0, CLEVIS_FLANGE_THICKNESS, MALE_TAB_Z0, MALE_TAB_Z1, UPPER_FLANGE_Z0, UPPER_FLANGE_Z1, height]))
    solids = []
    for z0, z1 in zip(bands[:-1], bands[1:]):
        mid = (z0 + z1) / 2
        shapes = [body]
        if MALE_TAB_Z0 <= mid <= MALE_TAB_Z1:
            shapes.append(male_tab)
        if (0 <= mid <= CLEVIS_FLANGE_THICKNESS) or (UPPER_FLANGE_Z0 <= mid <= UPPER_FLANGE_Z1):
            shapes.append(clevis_tab)
        solids.append(extrude_geom(unary_union(shapes), z0, z1))
    return combine(solids)


def pivot_spacer_solid(outer_d=12.0, inner_d=M4_CLEAR, height=2.0) -> Manifold:
    shape = Point(0,0).buffer(outer_d/2, resolution=48).difference(Point(0,0).buffer(inner_d/2, resolution=24))
    return extrude_geom(shape, 0, height)


def cradle_solid(length=180.0, internal_width=15.0, wall=4.0, height=25.0, pivot_tab_len=25.0, hole_d=M4_CLEAR) -> Manifold:
    outer_w = internal_width + 2*wall
    bottom = box(-length/2, -outer_w/2, length/2, outer_w/2)
    left_wall = box(-length/2, -outer_w/2, length/2, -internal_width/2)
    right_wall = box(-length/2, internal_width/2, length/2, outer_w/2)
    walls = unary_union([left_wall, right_wall])
    tab = capsule(pivot_tab_len + 20, 20, 24)
    tab = shapely_translate(tab, xoff=-length/2 - pivot_tab_len/2 + 10, yoff=0)
    tab = tab.difference(Point(-length/2 - pivot_tab_len + 10, 0).buffer(hole_d/2, resolution=24))
    bands = sorted(set([0.0, wall, MALE_TAB_Z0, MALE_TAB_Z1, height]))
    solids = []
    for z0, z1 in zip(bands[:-1], bands[1:]):
        mid = (z0 + z1) / 2
        shapes = [walls]  # overlap walls through bottom for a real union
        if mid <= wall:
            shapes.append(bottom)
        if MALE_TAB_Z0 <= mid <= MALE_TAB_Z1:
            shapes.append(tab)
        solids.append(extrude_geom(unary_union(shapes), z0, z1))
    return combine(solids)


def clamp_shapes(side='left', inner_d=POLE_CLEAR, outer_d=68.0, split_gap=2.0, hole_d=M4_CLEAR):
    ro = outer_d/2
    ri = inner_d/2
    ring = Point(0,0).buffer(ro, resolution=64).difference(Point(0,0).buffer(ri, resolution=64))
    if side == 'left':
        collar = ring.intersection(box(-ro-1, -ro-1, -split_gap/2, ro+1))
        boss = shapely_translate(capsule(46, 22, 24), xoff=-ro-12, yoff=0)
        boss = boss.difference(Point(-ro-24, 0).buffer(hole_d/2, resolution=24))
    else:
        collar = ring.intersection(box(split_gap/2, -ro-1, ro+1, ro+1))
        boss = shapely_translate(capsule(46, 22, 24), xoff=ro+12, yoff=0)
        boss = boss.difference(Point(ro+24, 0).buffer(hole_d/2, resolution=24))
    return collar, boss


def clamp_half_solid(side='left', height=50.0, inner_d=POLE_CLEAR, outer_d=68.0, split_gap=2.0, hole_d=M4_CLEAR) -> Manifold:
    ro = outer_d/2
    collar_shape, boss_shape = clamp_shapes(side, inner_d, outer_d, split_gap, hole_d)
    y_positions = (-22.0, 22.0)
    z_centres = (15.0, 35.0)
    channel_h = hole_d + 0.4
    channel_w_y = hole_d + 1.0
    x_cut = (-ro - 1.0, 0.5) if side == 'left' else (-0.5, ro + 1.0)

    # Start with one continuous collar volume, then subtract the four transverse M4 channels in 3D.
    collar = extrude_geom(collar_shape, 0, height)
    cutters = []
    for zc in z_centres:
        for y in y_positions:
            cutters.append(cube_box(x_cut[0], x_cut[1], y-channel_w_y/2, y+channel_w_y/2, zc-channel_h/2, zc+channel_h/2))
    for c in cutters:
        collar = collar - c

    # Clevis pivot boss: two flanges separated by the 9 mm receiver gap.
    lower = extrude_geom(boss_shape, 0, CLEVIS_FLANGE_THICKNESS)
    upper = extrude_geom(boss_shape, UPPER_FLANGE_Z0, UPPER_FLANGE_Z1)
    return collar + lower + upper


def write_readme() -> Path:
    readme = OUT/'README_DUAL_MONITOR_ARM_CLEVIS_V2.txt'
    readme.write_text(f"""Dual monitor arm/cradle CLEVIS V2 prototype STL set

What changed from the weak first version:
- Every pivot joint is now a hinge/clevis style joint.
- The clamp pivot bosses have two flanges with a gap between them.
- Each arm has one male tab end and one clevis/fork end.
- The monitor cradle has a male tab that fits inside the final arm clevis.
- The M4 bolt now captures the inner tab between two outer flanges, reducing wobble/twist compared with single-plate pivots.

Generated parts:
- pole_clamp_left_half_35mm_pole_M4_CLEVIS.stl
- pole_clamp_right_half_35mm_pole_M4_CLEVIS.stl
- arm_180x20x20_M4_male_to_clevis_PRINT_4.stl  (print 4 copies)
- open_monitor_cradle_180x25x15_M4_male_tab_PRINT_2.stl  (print 2 copies; mirror in slicer if required)
- M4_pivot_washer_12x2_PRINT_AS_NEEDED.stl

Assembly logic:
- Clamp clevis receives arm male tab.
- Arm clevis receives next arm male tab.
- Final arm clevis receives cradle male tab.
- Use M4 bolts, nuts, and washers at every pivot. Tighten enough to remove slop but do not crush the plastic.

Key dimensions:
- Pole clearance: {POLE_CLEAR:.1f} mm for a 35 mm pole.
- Clamp bolts: four transverse M4 channels, not longitudinal/vertical holes.
- M4 clearance holes/channels: {M4_CLEAR:.1f} mm nominal clearance.
- Link arms: 180 mm long x 20 mm wide x 20 mm high centre body.
- Male receiver tab thickness: {PIVOT_TAB_THICKNESS:.1f} mm.
- Clevis fork: two {CLEVIS_FLANGE_THICKNESS:.1f} mm flanges with {CLEVIS_GAP:.1f} mm internal gap.
- Cradle: 180 mm long, 25 mm high, 15 mm internal monitor slot, 4 mm walls.

PLA/PETG print notes:
- This is still a prototype, not a certified load-bearing monitor mount.
- Prefer PETG/ASA/nylon for real load tests; PLA is only for first fit/proportion testing.
- Use high infill/perimeters, e.g. 5+ walls and 50-80% infill.
- Print arms flat on the bed so layer lines run through the length/width, not across the bolt load path.
- Use real washers under M4 bolt heads/nuts.
- Test with dummy weight before trusting it with an actual monitor.
""", encoding='utf-8')
    return readme


def main():
    parts = {
        'pole_clamp_left_half_35mm_pole_M4_CLEVIS.stl': clamp_half_solid('left'),
        'pole_clamp_right_half_35mm_pole_M4_CLEVIS.stl': clamp_half_solid('right'),
        'arm_180x20x20_M4_male_to_clevis_PRINT_4.stl': arm_solid(),
        'open_monitor_cradle_180x25x15_M4_male_tab_PRINT_2.stl': cradle_solid(),
        'M4_pivot_washer_12x2_PRINT_AS_NEEDED.stl': pivot_spacer_solid(),
    }
    files = []
    manifest = []
    for name, solid in parts.items():
        tris = manifold_to_tris(solid)
        path = OUT/name
        write_binary_stl(path, tris, name)
        files.append(path)
        manifest.append({
            'file': name,
            'triangles': len(tris),
            'bytes': path.stat().st_size,
            'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
            'manifold_status': str(solid.status()),
            'volume_mm3': round(float(solid.volume()), 3),
            'bounds': [round(float(x), 3) for x in solid.bounding_box()],
        })
    files.append(write_readme())
    files.append(Path(__file__).resolve())
    manifest_path = OUT/'manifest.json'
    manifest_path.write_text(json.dumps(manifest, indent=2), encoding='utf-8')
    files.append(manifest_path)
    zip_path = OUT/'dual_monitor_arm_clevis_v2_stl_pack.zip'
    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
        for p in files:
            z.write(p, arcname=p.name)
    print('Generated:')
    for item in manifest:
        print(f"- {item['file']}: {item['triangles']} triangles, {item['bytes']} bytes, {item['manifold_status']}")
    print(f'- {zip_path.name}: {zip_path.stat().st_size} bytes')


if __name__ == '__main__':
    main()
