"""Validate the photo-informed apron reconstruction; run from repository root.

Native source checks and conditional photo comparison do not establish fitted
dimensions, operating clearance, safe support or a replacement/removal procedure.
"""
from pathlib import Path
import json,subprocess,hashlib,math,sys
import cadquery as cq
import numpy as np
CAD=Path('cars/1975-mg-midget/cad');sys.path.insert(0,str(CAD))
from cowl_apron_layout import metadata
S=2032/2.4
report=dict(schema='cowl-apron-validation-v1',baseline_commit='590aae4f',scope='Broader apron, rear-trimmed bonnet, connected reference wiper drive/arms and rigidly relocated hinge assemblies. No fitted dimensions, operating clearance or removal proof.',reconstruction=metadata(S),native_sources={},contacts_mm3={},held_source_sha256={})
def load(stem):return cq.Assembly.importStep(str(CAD/(stem+'-reference.step')))
def obj(a,n):
 v=a.objects[n].obj;return v.val()if isinstance(v,cq.Workplane)else v
assemblies={k:load(k)for k in ['wiper-drive','bonnet-structure','bonnet-hinge']}
for stem,a in assemblies.items():
 whole=cq.importers.importStep(str(CAD/(stem+'-reference.step'))).val();assert whole.isValid() and all(x.Volume()>0 for x in whole.Solids())
 d=json.loads((CAD/(stem+'-reference.json')).read_text());before=json.loads(subprocess.check_output(['git','show','590aae4f:'+str(CAD/(stem+'-reference.json'))]));old={p['name']:p for p in before['parts']};same=[p['name']for p in d['parts']if p==old[p['name']]]
 report['native_sources'][stem]=dict(mesh_groups=len(d['parts']),valid_positive_solids=len(whole.Solids()),unchanged_mesh_groups=same)
assert len(report['native_sources']['wiper-drive']['unchanged_mesh_groups'])==9
assert len(report['native_sources']['bonnet-structure']['unchanged_mesh_groups'])==3
new=json.loads((CAD/'bonnet-hinge-reference.json').read_text());old=json.loads(subprocess.check_output(['git','show','590aae4f:'+str(CAD/'bonnet-hinge-reference.json')]))
for side in ['left','right']:
 a,b=new['nodes'][side],old['nodes'][side];deltas=[]
 for key in ['body_pivots_yz_mm','carried_pivots_yz_mm']:
  deltas.extend(np.array(a[key])-np.array(b[key]))
 assert np.allclose(deltas,deltas[0],atol=1e-7)
 assert np.allclose(a['estimated_link_lengths_mm'],b['estimated_link_lengths_mm'],atol=1e-7)
 report.setdefault('hinge_rigid_translation_yz_mm',{})[side]=list(deltas[0])
report['hinge_link_lengths_and_pivot_separations_held']=True
skin=obj(assemblies['bonnet-structure'],'bonnet_thin_curved_skin_revised_transverse_crown_reference')
assert abs(skin.BoundingBox().zmin-.32*S)<.01
# The old rear portion is genuinely absent; front closure/stay meshes are held.
assert skin.intersect(cq.Solid.makeCylinder(2,300,cq.Vector(0,700,.20*S),cq.Vector(0,1,0))).Volume()<1e-6
apron=obj(assemblies['wiper-drive'],'scuttle_thin_top_three_spindle_passages_and_edge_returns_reference')
def contact(key,a,b):
 ba,bb=a.BoundingBox(),b.BoundingBox()
 v=a.intersect(b).Volume() if all(getattr(ba,k+'min')<=getattr(bb,k+'max') and getattr(bb,k+'min')<=getattr(ba,k+'max')for k in ['x','y','z'])else 0
 report['contacts_mm3'][key]=round(v,5);return v
assert contact('apron__bonnet_skin',apron,skin)<1e-3
for side in ['left','center','right']:
 assert contact('bonnet_skin__'+side+'_wiper_bezel',skin,obj(assemblies['wiper-drive'],'wiper_'+side+'_bezel_seal_nut_comparison'))<1e-3
screen=load('windscreen')
for name,item in screen.objects.items():
 if item.obj is not None:
  vol=contact('apron__'+name,apron,obj(screen,name))
  if vol<1e-5:report['contacts_mm3'].pop('apron__'+name)
body=json.loads(Path('cars/1975-mg-midget/body-data.json').read_text());p=body['native_heater_assembly_placement'];T=tuple(v*S-a for v,a in zip(p['body_anchor_units'],p['native_anchor_mm']));heater=load('heater-assembly')
for n in ['heater_case_and_necks_reference','heater_blower_motor_and_plate_reference']:
 contact('apron__'+n,apron,obj(heater,n).translate(T))
for stem in ['windscreen','midget-1500-measured-core','front-footwell','front-wheelhouse','front-hub','wheel-profile']:
 for ext in ['json','step']:
  path=CAD/(stem+('-reference' if stem!='midget-1500-measured-core'else '')+'.'+ext);data=path.read_bytes();assert data==subprocess.check_output(['git','show','590aae4f:'+str(path)]),path
  report['held_source_sha256'][path.name]=hashlib.sha256(data).hexdigest()
report['source_sha256']={f'{stem}-reference.{ext}':hashlib.sha256((CAD/f'{stem}-reference.{ext}').read_bytes()).hexdigest()for stem in assemblies for ext in ['json','step']}
report['limits']='Static shared seams/bores and zero sampled contacts are source checks, not fitted support, seal compression, cold/hot clearance, collision-free opening, active wiper park/sweep or verified replacement paths. Lower screen/body seal, corner/leg receivers, hinge/body support and complete fitted casing route remain unresolved. No body receiver is created.'
(CAD/'cowl-apron-validation.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({k:report[k]for k in ['native_sources','hinge_rigid_translation_yz_mm','contacts_mm3']},indent=2))

from pathlib import Path
import json,math,sys
import numpy as np
from scipy.optimize import least_squares
from PIL import Image
import matplotlib;matplotlib.use('Agg')
import matplotlib.pyplot as plt
CAD=Path('cars/1975-mg-midget/cad');sys.path.insert(0,str(CAD))
import fit_owner_side_view as fitter
from bonnet_surface import height_units
from cowl_apron_layout import WIPER_ROW_Z_UNITS,BONNET_REAR_UNITS
body=json.loads((CAD.parent/'body-data.json').read_text());side=json.loads((CAD/'owner-side-view-6329.json').read_text());obs=np.array([[1020.,581.],[1055.,535.],[1080.,502.]])
def pts(v):return [[-v[0],v[1],v[2]],[0,v[1],v[2]],[v[0],v[1],v[2]]]
def project(cam,p):
 q=np.array(p)@np.array(cam['world_to_camera_rotation']).T+cam['world_to_camera_translation'];assert np.all(q[:,2]>0);return q[:,:2]/q[:,2,None]*cam['focal_length_pixels']+np.array(side['image_size'])/2
f=24*math.hypot(*side['image_size'])/math.hypot(36,24);original=fitter.least_squares
try:
 fitter.least_squares=lambda fun,x0,**kw:least_squares(lambda p:np.r_[fun(p),(p[6]-math.log(f))*10000],x0,**kw)
 exif=fitter.fit(side,body)
finally:fitter.least_squares=original
cameras={'retained_wheel_camera':side['camera_fit'],'rounded_exif_focal_camera':exif};old=[.4,1.025,.109];new=[.4,1.025,WIPER_ROW_Z_UNITS];results={}
fig,axes=plt.subplots(1,2,figsize=(13,7),dpi=140);photo=Image.open('cars/assets/photos/img_6329.jpg')
for ax,(name,cam)in zip(axes,cameras.items()):
 before,after=project(cam,pts(old)),project(cam,pts(new));rms=lambda p:float(np.sqrt(np.mean((p-obs)**2)))
 assert rms(after)<rms(before)
 fit=least_squares(lambda p:(project(cam,pts(p))-obs).ravel(),old,bounds=([.2,.8,-.1],[.6,1.2,.4]))
 results[name]=dict(before_coordinate_rms_pixels=rms(before),after_coordinate_rms_pixels=rms(after),before_projected_bezels_pixels=before.tolist(),after_projected_bezels_pixels=after.tolist(),unapplied_optimum_half_spacing_y_z_body_units=fit.x.tolist())
 ax.imshow(photo)
 for xy,color,label in [(before,'#ff6655','Former wiper row'),(after,'#00aaff','Selected forward row')]:ax.plot(*xy.T,'o-',color=color,label=label)
 for z,color,label in [(.13,'#ff6655','Former bonnet rear'),(BONNET_REAR_UNITS,'#00aaff','Selected bonnet rear')]:
  q=project(cam,[[x,height_units(x,z),z]for x in np.linspace(-.67,.67,49)]);ax.plot(*q.T,color=color,ls='--',lw=1,label=label)
 ax.scatter(*obs.T,c='#ffdf3b',edgecolor='black',s=32,label='Approximate owner bezels')
 ax.set(xlim=(900,1160),ylim=(700,450),title=name.replace('_',' '));ax.legend(fontsize=6)
fig.suptitle('IMG_6329 · apron reconstruction informed by photo; no fitted dimensions or calibrated camera',fontsize=10);fig.tight_layout();fig.savefig(CAD.parent/'body-side-study/cowl-apron-photo.png');plt.close(fig)
p=CAD/'cowl-apron-validation.json';d=json.loads(p.read_text());d['photo_comparison']=dict(owner_photo='IMG_6329',image_size_pixels=side['image_size'],approximate_bezel_observations_pixels=obs.tolist(),approximate_sampling_uncertainty_pixels=8,old_half_spacing_y_z_units=old,selected_half_spacing_y_z_units=new,camera_results=results,bonnet_rear_basis='Rounded Z.32 construction guided by visible seam context under both retained cameras; no seam/width optimisation, owner length reading or factory station applied.',limits='Two sensitivity cameras share the same photo/wheel assumptions. Observations informed the correction and are not independent validation. EXIF equivalent focal length is rounded; curved panels, crop/distortion and landmark correspondence remain uncalibrated. No fitted wiper spacing/height or bonnet length is inferred.');p.write_text(json.dumps(d,indent=2)+'\n');print(json.dumps(results,indent=2))
