"""Validate exported GLB data and assembly contracts, independent of Blender state."""
import json,struct,hashlib,math
from pathlib import Path
from PIL import Image
R=Path(__file__).resolve().parent
k=json.loads((R/'kit.json').read_text());s=json.loads((R/'materials.json').read_text())['materials'];raw=(R/'kit.glb').read_bytes();n=struct.unpack_from('<I',raw,12)[0];g=json.loads(raw[20:20+n]);binary=raw[28+n:];checks=[]
def check(label,ok):
 checks.append({'check':label,'pass':bool(ok)})
 if not ok:raise AssertionError(label)
expected='brace_storey brace_straight bressummer_1m joist_end jetty_bracket peg_cluster window_leaded window_leaded_stone window_small door_plank door_arched_double window_shop tile_a tile_b tile_c tile_d tile_strip_a tile_strip_b roof_underlay ridge_tile_round verge_board_1m rafter_end dormer chimney_stone tavern_sign lantern_iron bench_wood barrel corner_stone'.split()
matnames='Rubble_stone Rubble_stone_dark Oak_frame Oak_frame_grey Plaster_ochre Plaster_white Plaster_pink Clay_tile Clay_tile_dark Dressed_limestone Wrought_iron Leaded_glass Door_paint Sign_paint'.split()
roots=g['scenes'][g.get('scene',0)]['nodes'];rootmap={g['nodes'][i]['name']:i for i in roots};atoms={a['node_name']:a for a in k['atoms']}
check('29 exact master names',set(rootmap)==set(expected)==set(atoms));check('14 exact shelf names',set(s)==set(matnames));check('No images or textures in GLB',not g.get('images') and not g.get('textures'));check('Metres and Y-up',k['units']=='metres' and k['up']=='+Y');check('Identity root transforms',all(not any(t in g['nodes'][i] for t in ['matrix','translation','rotation','scale']) for i in roots))
def descendants(i):
 yield i
 for child in g['nodes'][i].get('children',[]):yield from descendants(child)
triangles={};primitives=0
for name,i in rootmap.items():
 ts=0
 for j in descendants(i):
  node=g['nodes'][j]
  if 'mesh' not in node:continue
  for p in g['meshes'][node['mesh']]['primitives']:
   ts+=g['accessors'][p['indices']]['count']//3;primitives+=1
   check(f'{name}: PBR attributes present',all(t in p['attributes'] for t in ['POSITION','NORMAL','TEXCOORD_0','TANGENT']))
   check(f'{name}: shelf binding',g['materials'][p['material']]['name'] in s)
 triangles[name]=ts;check(name+': triangle budget and manifest agree',ts==atoms[name]['triangles'] and ts<=atoms[name]['triangle_budget'])
check('Total triangles agree',sum(triangles.values())==k['total_triangles'])
for name,w,h,d in [('window_leaded',.7,1,.2),('window_leaded_stone',.7,1,.5),('window_small',.45,.55,.2),('door_plank',1,2,.5),('door_arched_double',1.8,2.6,.5),('window_shop',1.8,1.4,.2)]:
 a=atoms[name]['opening'];check(name+': clear opening contract',a['width']==w and a['height']==h and a['wall_thickness']==d)
for a in atoms.values():
 for c in a.get('children',[]):
  node=next((g['nodes'][i] for i in descendants(rootmap[a['node_name']]) if g['nodes'][i]['name']==c['node_name']),None)
  check(c['node_name']+': child pivot preserved',node is not None and max(abs(x-y) for x,y in zip(node.get('translation',[0,0,0]),c['translation']))<1e-6)
for name in ['tile_a','tile_b','tile_c','tile_d','tile_strip_a','tile_strip_b']:
 a=atoms[name];span=a['bounding_box']['max'][0]-a['bounding_box']['min'][0];check(name+': width/gauge',abs(span-(1.32 if 'strip' in name else .165))<1e-6 and a.get('course_gauge_metres',a.get('gauge_metres'))==.1)
gap=min(.16*.1-.012+.0035*(math.sin(math.pi*z/.265)-math.sin(math.pi*(z-.1)/.265))-.0004 for z in [.1+j*.165/1000 for j in range(1001)])
check('Plain tile overlap stays separated at 100mm gauge including strip jitter',gap>0)
check('Chimney 2.5m high',abs(atoms['chimney_stone']['bounding_box']['max'][1]-2.5)<1e-6)
check('Joist projects 0.45m',abs(atoms['joist_end']['projection_metres']-.45)<1e-6)
texturebytes=0
for name,a in s.items():
 for kind,p in a['maps'].items():
  im=Image.open(R/p);check(name+': '+kind+' dimensions',im.size==(a['resolution'],a['resolution']));texturebytes+=(R/p).stat().st_size
 if 'height_map' in a:
  data=(R/a['height_map']).read_bytes();check(name+': 16-bit grayscale Height',data[24]==16 and data[25]==0);texturebytes+=len(data)
check('Shelf within 64 MiB',texturebytes<64*1024**2);check('Primitive draw-call budget',primitives<=95)
audit=json.loads((R/'audit.json').read_text());check('Strict audit matches final GLB',audit['sha256']==hashlib.sha256(raw).hexdigest());check('Strict audit zero errors',not audit.get('errors'))
out={'status':'PASS','glb_sha256':hashlib.sha256(raw).hexdigest(),'atoms':len(atoms),'triangles':sum(triangles.values()),'per_atom_triangles':triangles,'primitives':primitives,'external_shelf_png_bytes':texturebytes,'checks':checks};(R/'validation.json').write_text(json.dumps(out,indent=2));print(json.dumps({key:val for key,val in out.items() if key not in ['checks','per_atom_triangles']}))
