"""UC1 casting review derivative. Millimetres; R2 input geometry is immutable.

Python 3.12, build123d 0.11.0, cadquery-ocp 7.9.3.1, trimesh.
Run: python source/build_Concept_27_UC1.py
Finished nominal parts and casting masters are intentionally separate.
This local BRep derivative is not a new Adam-generated task or native timeline.
"""
from pathlib import Path
import copy, csv, hashlib, json, math, shutil, time
import build123d as b
import numpy as np
import trimesh
from OCP.BRepCheck import BRepCheck_Analyzer
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder

ROOT=Path(__file__).resolve().parents[1]
INPUT=ROOT/'inputs/R2'
PARAMS={'boss_release_degrees':1.0,'mesh_linear_mm':0.06,'mesh_angular_rad':0.15,
        'boss_root_land_mm':0.10,'shrink_scale':1.0,'casting_parts':[1,2,9,16]}
PARTS={}; BASE={}; CHANGES=[]
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def write(p,d):p.write_text(json.dumps(d,indent=2)+'\n')
def vol(s):return sum(x.volume for x in s.solids())
def bounds(s):
 q=s.bounding_box();return [q.min.X,q.max.X,q.min.Y,q.max.Y,q.min.Z,q.max.Z]
def difference(s,t):
 op=BRepAlgoAPI_Cut(s.wrapped,t.wrapped);op.SetFuzzyValue(1e-6);op.Build()
 assert op.IsDone()
 return b.Compound(op.Shape())
def one(s):
 if isinstance(s,(list,tuple,b.ShapeList)):s=b.Compound(children=list(s))
 assert len(s.solids())==1, len(s.solids())
 s=s.solids()[0]
 assert BRepCheck_Analyzer(s.wrapped,True,False).IsValid() and s.volume>0
 return s
def cylinder(x,y,r,z0,z1):return b.Solid.make_cylinder(r,z1-z0,b.Plane(origin=(x,y,z0)))
def number(name):
 a=name.split('_')[2];return int(a) if a.isdigit() else 0
def export(s,path):
 b.export_brep(s,ROOT/'validation'/('checkpoint-'+path.stem+'.brep'))
 print('EXPORT',path.name,flush=True)
 b.export_step(s,path,write_pcurves=True,precision_mode=b.PrecisionMode.AVERAGE)
 check=b.import_step(path)
 assert len(check.solids())==len(s.solids())
 assert all(BRepCheck_Analyzer(q.wrapped,True,False).IsValid() for q in check.solids())
 assert abs(vol(check)-vol(s))<max(1e-5,vol(s)*1e-8)
def mesh(s,p):
 b.export_stl(s,p,tolerance=PARAMS['mesh_linear_mm'],angular_tolerance=PARAMS['mesh_angular_rad'])
 m=trimesh.load_mesh(p,process=True);f=m.faces
 keep=(f[:,0]!=f[:,1])&(f[:,1]!=f[:,2])&(f[:,0]!=f[:,2])
 if not keep.all():m.update_faces(keep);m.remove_unreferenced_vertices();m.export(p)
 assert m.is_watertight and m.is_winding_consistent and m.volume>0,p
 return {'watertight':True,'windingConsistent':True,'triangles':len(m.faces),'sha256':sha(p)}
def taper(name,x,y,r,z0,z1,tip):
 """Remove only the outer wedge; retain axial seat and bore coordinates."""
 old=PARTS[name];h=z1-z0;slope=math.tan(math.radians(PARAMS['boss_release_degrees']));rr=r+PARAMS['boss_root_land_mm']*slope;rt=rr-h*slope
 radii=(rt,rr) if tip=='low' else (rr,rt)
 cone=b.Solid.make_cone(*radii,h,b.Plane(origin=(x,y,z0)))
 wedge=cylinder(x,y,r,z0,z1).cut(cone)
 new=one(old.cut(wedge));removed=old.volume-new.volume
 assert removed>0.0001
 print('TAPER',name,x,y,'removed',removed,flush=True)
 PARTS[name]=new
 CHANGES.append({'part':name,'feature':'boss outer wall','centerXY':[x,y],
  'zRange':[z0,z1],'draftDegrees':PARAMS['boss_release_degrees'],
  'rootRadius':r,'tipRadius':rt,'tipEnd':tip,'removedMm3':removed,
  'note':'Axial seat and central bore preserved; small reduction in annular bearing area requires physical load/torque qualification.'})

def main():
 start=time.time()
 for d in ('cad/parts','cad/hardware','cad/free_state_pads','cad/casting_masters','meshes','meshes/casting_masters','audit','documents'):(ROOT/d).mkdir(parents=True,exist_ok=True)
 locked=json.loads((INPUT/'SHA256.json').read_text())
 assert all(sha(INPUT/p)==digest for p,digest in locked.items())
 baseline=json.loads((INPUT/'baseline_manifest.json').read_text())
 for row in baseline['part_manifest']:
  old=row['name'];name=old.replace('C27_R2_','C27_UC1_')
  s=one(b.import_step(INPUT/'parts'/f'{old}.step'));BASE[name]=copy.copy(s);PARTS[name]=s
 front=next(n for n in PARTS if number(n)==1);rear=next(n for n in PARTS if number(n)==2)
 for x in (-33.4,33.4):
  for y in (-56,56):
   taper(front,x,y,2.8,-12,-10,'low')
   taper(rear,x,y,2.8,-14,-12,'high')
 for x in (-9.1,9.1):
  for y in (18.4,65.6):taper(front,x,y,2.55,-4.95,-2.1,'low')
 for x in (-14,14):
  for y in (-32,64):taper(rear,x,y,2.7,-21.81,-20.83,'high')
 # Identify only the through-front W grille cylinders, not hidden locators.
 holes=[]
 for face in BASE[front].faces():
  a=BRepAdaptor_Surface(face.wrapped,True)
  if a.GetType()!=GeomAbs_Cylinder:continue
  c=a.Cylinder();x,y,z=c.Location().Coord();bb=bounds(face)
  if abs(abs(c.Axis().Direction().Z())-1)<1e-8 and abs(bb[5])<1e-6 and abs(bb[4]+2.2)<1e-6 and 20<y<65 and c.Radius()<1.11:
   holes.append({'x':round(x,9),'y':round(y,9),'diameter':round(2*c.Radius(),6),'front_z':0,'back_z':-2.2})
 assert len(holes)==121,len(holes)
 assert len({(r['x'],r['y']) for r in holes})==121
 holes.sort(key=lambda h:(h['y'],h['x']))
 # Blanked face omits the 121 small holes from the silicone mold.
 # A 0.01 mm radial overlap makes the boolean robust; the final exact-radius
 # recut is checked against the nominal finished model below.
 plugs=[cylinder(h['x'],h['y'],h['diameter']/2+.01,-2.2,0) for h in holes]
 x0=min(h['x']-h['diameter']/2 for h in holes)-.1; x1=max(h['x']+h['diameter']/2 for h in holes)+.1
 y0=min(h['y']-h['diameter']/2 for h in holes)-.1; y1=max(h['y']+h['diameter']/2 for h in holes)+.1
 slab=b.Solid.make_box(x1-x0,y1-y0,2.2,b.Plane(origin=(x0,y0,-2.2)))
 master=one(PARTS[front].fuse(slab).clean());master.label='C27_UC1_FRONT_CASTING_MASTER_NO_GRILLE'
 recut=one(master.cut(*[cylinder(h['x'],h['y'],h['diameter']/2,-2.201,.001) for h in holes]))
 assert vol(difference(recut,PARTS[front]))<1e-5 and vol(difference(PARTS[front],recut))<1e-5
 masters={master.label:master}
 for n in PARTS:
  if number(n) in (2,9,16):masters[n+'_CASTING_MASTER']=copy.copy(PARTS[n])
 for n,s in masters.items():
  s.label=n;export(s,ROOT/'cad/casting_masters'/f'{n}.step');mesh(s,ROOT/'meshes/casting_masters'/f'{n}.stl')
 manifest=[];diffs=[]
 for row in baseline['part_manifest']:
  name=row['name'].replace('C27_R2_','C27_UC1_');s=PARTS[name];s.label=name
  material=row['material'];num=number(name)
  process=('Cast rigid urethane; ABS-like functional target, resin to be selected with RPS' if num in (1,2) else
   'Cast elastomeric urethane; Shore A 80-85 evaluation target, sample approval required' if num in (9,16) else row['process'])
  export(s,ROOT/'cad/parts'/f'{name}.step');m=mesh(s,ROOT/'meshes'/f'{name}.stl')
  remove=BASE[name].volume-s.volume;assert remove>=-1e-5
  add=0.0  # Construction proof: device operations are cuts only, never unions.
  if num not in (1,2):assert remove<1e-5
  assert np.max(abs(np.array(bounds(s))-np.array(bounds(BASE[name]))))<1e-6
  manifest.append({**row,'name':name,'process':process,'volume_mm3':s.volume,'bounds_mm':bounds(s),'valid':True,'solids':1,'mesh':m})
  diffs.append({'part':name,'addedMm3':add,'removedMm3':remove,'boundsUnchanged':True,'method':'volume delta and subtractive-only construction; not an independent coincident-face Boolean difference'})
  print('PART',name,flush=True)
 fast=[];oem=[]
 for p in sorted((INPUT/'hardware').glob('*.step')):
  imported=b.import_step(p);name=p.stem.replace('C27_R2_','C27_UC1_')
  # Flatten imported presentation hierarchy; solids and placement are unchanged.
  children=list(imported.solids())
  for i,q in enumerate(children,1):q.label=name+'_'+str(i)
  s=b.Compound(label=name,children=children)
  export(s,ROOT/'cad/hardware'/f'{name}.step')
  (oem if '_REF_' in name else fast).append(s)
 for p in sorted((INPUT/'free_state_pads').glob('*.step')):
  s=one(b.import_step(p));s.label=p.stem.replace('C27_R2_','C27_UC1_');export(s,ROOT/'cad/free_state_pads'/f'{s.label}.step')
 case=b.Compound(label='Walkie_One_Concept_27_UC1_CASE',children=[copy.deepcopy(q) for q in [*PARTS.values(),*fast]])
 ref=b.Compound(label='Walkie_One_Concept_27_UC1_REFERENCE_ASSEMBLY',children=[copy.deepcopy(q) for q in [*PARTS.values(),*fast,*oem]])
 export(case,ROOT/'cad/Walkie_One_Concept_27_UC1_CASE.step')
 export(ref,ROOT/'cad/Walkie_One_Concept_27_UC1_REFERENCE_ASSEMBLY.step')
 counts={'manufactured_parts':len(PARTS),'fasteners':len(fast),'electronics_groups':len(oem),'case_solids':len(case.solids()),'reference_solids':len(ref.solids())}
 assert counts==baseline['assembly_counts'],counts
 with (ROOT/'documents/Grille_Secondary_Drill_Coordinates.csv').open('w',newline='') as f:
  w=csv.DictWriter(f,fieldnames=['hole','x','y','diameter','front_z','back_z']);w.writeheader()
  for i,h in enumerate(holes,1):w.writerow({'hole':i,**h})
 write(ROOT/'audit/Concept_27_UC1_build_manifest.json',{**baseline,'part_manifest':manifest,'assembly_counts':counts,'uc1Parameters':PARAMS,'uc1Changes':CHANGES,'sourceInputManifestSha256':sha(INPUT/'SHA256.json')})
 write(ROOT/'audit/UC1_geometry_review.json',{'inputChecksumsVerified':True,'exportRoundTripsVerified':True,'manufacturedMeshesWatertight':True,'nominalNoAddedInterferences':True,'basis':'All device edits remove only boss side-wall material. Every other device part and every OEM placement retains R2 geometry. This does not establish loads, tolerances or physical performance.','partDifferences':diffs,'bossTapers':CHANGES,'castingMasters':list(masters),'masterGrilleHoleCount':0,'secondaryDrillHoleCount':121,'drilledMasterMatchesFinishedPart':True,'shrinkCompensationApplied':False,'physicalQualification':False,'moldReleaseApproved':False,'seconds':time.time()-start})
 write(ROOT/'source/Concept_27_UC1_parameters.json',PARAMS)
 print('COMPLETE',counts,'boss tapers',len(CHANGES),'seconds',time.time()-start,flush=True)
if __name__=='__main__':main()
