"""Parametric twin-rail bench vise concept. Dimensions in mm.

Run with Python 3.12 and CadQuery 2.8; see README.md. No hardware is controlled.
Thread forms, fasteners and spindle retention are intentionally simplified.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
import cadquery as cq

OUT = Path(__file__).resolve().parent

def block(x, y, z, dx, dy, dz, chamfer=0):
    w = cq.Workplane('XY').box(dx, dy, dz)
    if chamfer:
        w = w.edges().chamfer(chamfer)
    return w.translate((x, y, z))

def cylinder_x(x, y, z, radius, length):
    return cq.Workplane('YZ', origin=(x, y, z)).circle(radius).extrude(length)

def make_parts(opening=30.0, jaw_width=64.0):
    if not 0 <= opening <= 60:
        raise ValueError('Jaw opening must be between 0 and 60 mm')
    if not 60 <= jaw_width <= 80:
        raise ValueError('Jaw width must be between 60 and 80 mm')
    bw = jaw_width + 16
    rail_y = jaw_width / 2 - 10
    base = block(0, 0, 4, 160, bw, 8, 1.2)
    fixed = block(-61, 0, 27, 20, jaw_width, 38, 0.8)
    rear = block(67, 0, 24, 14, jaw_width, 32, 0.8)
    base = base.union(fixed).union(rear)
    for x in (-68, 68):
        for y in (-bw/2+5, bw/2-5):
            hole = cq.Workplane('XY', origin=(x,y,-1)).circle(2.7).extrude(10)
            base = base.cut(hole)
    for y in (-rail_y, rail_y):
        base = base.cut(cylinder_x(-73,y,18,4.1,150))
    base = base.cut(cylinder_x(58,0,27,5.3,18))
    nut_cut = cq.Workplane('YZ', origin=(61.8,0,27)).polygon(6,18.6).extrude(12.5)
    base = base.cut(nut_cut)
    # Moving jaw has running clearance above the bed and around both guide rods.
    mx = -36 + opening
    mover = block(mx,0,27.15,18,jaw_width,37.7,0.7)
    for y in (-rail_y,rail_y):
        mover = mover.cut(cylinder_x(mx-10,y,18,4.25,20))
    mover = mover.cut(cylinder_x(mx-10,0,27,5.3,20))
    # Removable pads: shallow crosswise grooves on the working faces.
    pad_f = block(-49.5,0,39.5,3,jaw_width-2,13,0.25)
    pad_m = block(-46.5+opening,0,39.5,3,jaw_width-2,13,0.25)
    for y in range(-int((jaw_width-6)/2),int((jaw_width-6)/2)+1,4):
        pad_f = pad_f.cut(block(-48.05,y,39.5,0.5,0.65,14))
        pad_m = pad_m.cut(block(-47.95+opening,y,39.5,0.5,0.65,14))
    rod_a = cylinder_x(-70,-rail_y,18,4,143)
    rod_b = cylinder_x(-70, rail_y,18,4,143)
    nut = cq.Workplane('YZ',origin=(62,0,27)).polygon(6,18).extrude(10)
    nut = nut.cut(cylinder_x(61,0,27,5.2,12))
    # Major-diameter envelope, not a helical thread. A flange contacts jaw rear.
    screw = cylinder_x(mx-7,0,27,5,151)
    screw = screw.union(cylinder_x(mx+9.2,0,27,8,2.5))
    hx = mx+137
    wheel = cylinder_x(hx,0,27,21,9)
    for i in range(7):
        a = 2*math.pi*i/7
        wheel = wheel.cut(cylinder_x(hx-1,24*math.cos(a),27+24*math.sin(a),6,11))
    wheel = wheel.cut(cylinder_x(hx-1,0,27,5.25,11))
    wheel = wheel.edges('|X').fillet(0.65)
    # A hub extends the handwheel axially and is part of its solid.
    hub = cylinder_x(hx-5,0,27,9,6).cut(cylinder_x(hx-6,0,27,5.25,8))
    wheel = wheel.union(hub)
    # Metadata accompanies both export and interactive tessellation.
    rows = [
        ('frame','Frame + fixed jaw',base,'#237c88',False,[0,0,-18],'Concept machined body'),
        ('moving_jaw','Moving jaw',mover,'#329ba5',True,[0,0,30],'Sliding body; 0.5 mm diametral rail clearance'),
        ('fixed_pad','Fixed jaw pad',pad_f,'#455264',False,[-18,0,20],'Grooved replaceable pad; fastening not detailed'),
        ('moving_pad','Moving jaw pad',pad_m,'#455264',True,[18,0,48],'Grooved replaceable pad; fastening not detailed'),
        ('guide_front','Front guide rod',rod_a,'#becbd5',False,[0,-24,10],'8 mm diameter x 143 mm'),
        ('guide_rear','Rear guide rod',rod_b,'#becbd5',False,[0,24,10],'8 mm diameter x 143 mm'),
        ('drive_nut','Drive nut',nut,'#c89540',False,[18,0,26],'Simplified thread bore; 10 mm axial length'),
        ('spindle','Spindle',screw,'#becbd5',True,[25,0,10],'10 mm thread envelope; retention not detailed'),
        ('handwheel','Seven-lobe handwheel',wheel,'#ee9253',True,[43,0,10],'42 mm outside diameter; hub fixing not detailed'),
    ]
    return [dict(id=i,name=n,shape=s.val(),color=c,moving=m,explode=e,note=note) for i,n,s,c,m,e,note in rows]

def validate(parts):
    checks=[]
    for p in parts:
        s=p['shape']
        assert s.isValid() and len(s.Solids()) == 1 and s.Volume()>0, p['id']
    for i,a in enumerate(parts):
        for b in parts[i+1:]:
            volume=a['shape'].intersect(b['shape']).Volume()
            if volume>0.001:
                checks.append(dict(a=a['id'],b=b['id'],intersection_mm3=round(volume,6)))
    assert not checks, checks
    return {'solids_valid':len(parts),'interferences_over_0_001_mm3':checks}

def main():
    ap=argparse.ArgumentParser()
    ap.add_argument('--opening',type=float,default=30)
    ap.add_argument('--jaw-width',type=float,default=64)
    ap.add_argument('--output',type=Path,default=OUT)
    ap.add_argument('--check-travel',action='store_true')
    args=ap.parse_args()
    out=args.output
    out.mkdir(parents=True,exist_ok=True)
    (out/'stl').mkdir(exist_ok=True)
    parts=make_parts(args.opening,args.jaw_width)
    print('Built 9 components; checking geometry...',flush=True)
    report={'units':'mm','opening_mm':args.opening,'jaw_width_mm':args.jaw_width,'nominal':validate(parts)}
    if args.check_travel:
        report['travel_samples']={str(g):validate(make_parts(g,args.jaw_width)) for g in (0,15,30,45,60)}
    assembly=cq.Assembly(name='Twin_rail_bench_vise')
    mesh=[]
    bom=[]
    for p in parts:
        s=p['shape']
        color=p['color']
        rgb=tuple(int(color[i:i+2],16)/255 for i in (1,3,5))
        assembly.add(s,name=p['id'],color=cq.Color(*rgb))
        cq.exporters.export(s,str(out/'stl'/f"{p['id']}.stl"),tolerance=0.08,angularTolerance=0.15)
        verts,tri=s.tessellate(0.12,0.22)
        mesh.append({k:v for k,v in p.items() if k!='shape'} | {'vertices':[[round(v.x,3),round(v.y,3),round(v.z,3)] for v in verts], 'triangles':tri})
        bb=s.BoundingBox()
        bom.append({'part':p['name'],'quantity':1,'volume_cm3':round(s.Volume()/1000,3),'x_mm':round(bb.xlen,3),'y_mm':round(bb.ylen,3),'z_mm':round(bb.zlen,3),'notes':p['note']})
    assembly.export(str(out/'bench-vise.step'))
    reloaded=cq.importers.importStep(str(out/'bench-vise.step')).val()
    assert reloaded.isValid() and len(reloaded.Solids())==len(parts)
    original_volume=sum(p['shape'].Volume() for p in parts)
    assert abs(reloaded.Volume()-original_volume)<0.01
    report['step_roundtrip']={'solid_count':len(reloaded.Solids()),'volume_error_mm3':abs(reloaded.Volume()-original_volume)}
    (out/'mesh.json').write_text(json.dumps({'opening':args.opening,'jawWidth':args.jaw_width,'parts':mesh},separators=(',',':')))
    (out/'validation.json').write_text(json.dumps(report,indent=2))
    with (out/'parts-list.csv').open('w',newline='',encoding='utf-8') as f:
        writer=csv.DictWriter(f,fieldnames=bom[0].keys());writer.writeheader();writer.writerows(bom)
    cq.exporters.export(cq.Compound.makeCompound([p['shape'] for p in parts]),str(out/'isometric.svg'),opt={'width':1200,'height':700,'projectionDir':(1,-1,0.85),'showHidden':False,'strokeWidth':0.5})
    print(json.dumps(report,indent=2),flush=True)
    print(f'Exported to {out}',flush=True)

if __name__=='__main__':
    main()
