"""Ural-inspired six-wheel troop carrier. Clean bpy build; CC0 tiled Cycles bakes.
Authoring: X left, -Y forward, Z up; glTF converts to X left, Y up, +Z forward.
Target: 50–110k triangles, <=8 mats, <=12 MB, 1024 colour/normal, 512 ORM.
Cameras: front-left hero, side orthographic, grey clay and six-paint proof.
"""
import bpy, bmesh, math, os, json, random, urllib.request, subprocess
from mathutils import Vector, Matrix
from math import sin,cos,pi
from pathlib import Path
random.seed(4320)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene;S.unit_settings.system='METRIC';S.render.engine='CYCLES';S.cycles.device='GPU';S.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
S.cycles.use_denoising=True
M={};parts=[];groups={};current='Body'
PAINTS=[('Olive drab',[.08,.11,.06]),('NATO green',[.055,.085,.045]),('Desert tan',[.46,.34,.19]),('Field grey',[.19,.22,.18]),('Winter white',[.72,.75,.70]),('Dark green-black',[.018,.025,.017])]
SOURCES={'Paint':'metal_plate','Canvas':'denim_fabric','Rubber':'rubberized_track','Metal':'rusty_metal','Wood':'wood_planks_grey','Interior':'fabric_leather_02'}
def dl(id,mp):
    p=TEX/f'{id}_{mp}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
        subprocess.run(['curl','--fail','--retry','3','-L','-A','Mozilla/5.0','-sS',url,'-o',str(p)],check=True)
    im=bpy.data.images.load(str(p),check_existing=True)
    if mp!='diff':im.colorspace_settings.name='Non-Color'
    return im

def make_material(name):
    m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=n.new('ShaderNodeTexCoord'); noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=110 if name=='Paint' else 7;noise.inputs['Detail'].default_value=5;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    ramp=n.new('ShaderNodeValToRGB');l.new(noise.outputs['Fac'],ramp.inputs[0]);ramp.color_ramp.elements[0].position=.22;ramp.color_ramp.elements[1].position=.8
    colors={'Paint':((.69,.69,.69,1),(.94,.94,.94,1)),'Canvas':((.115,.110,.071,1),(.18,.171,.113,1)),'Rubber':((.015,.016,.016,1),(.060,.055,.047,1)),'Metal':((.027,.03,.028,1),(.11,.10,.083,1)),'Wood':((.065,.045,.026,1),(.23,.16,.09,1)),'Interior':((.026,.030,.025,1),(.08,.087,.065,1)),'Glass':((.16,.23,.23,1),(.18,.25,.25,1)),'Lamps':((.62,.68,.63,1),(.75,.78,.69,1))}
    c0,c1=colors[name];ramp.color_ramp.elements[0].color=c0;ramp.color_ramp.elements[1].color=c1
    color=ramp.outputs['Color'];rough=None
    if name in SOURCES:
        imgs={mp:dl(SOURCES[name],mp) for mp in ['diff','nor_gl','arm']}
        tx={}
        for mp,im in imgs.items():
            t=n.new('ShaderNodeTexImage');t.image=im;l.new(uv.outputs['UV'],t.inputs['Vector']);tx[mp]=t
        bw=n.new('ShaderNodeRGBToBW');l.new(tx['diff'].outputs['Color'],bw.inputs[0])
        remap=n.new('ShaderNodeMapRange');l.new(bw.outputs[0],remap.inputs['Value']);remap.inputs['To Min'].default_value=.65;remap.inputs['To Max'].default_value=1.1
        mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.08 if name=='Paint' else .45;l.new(color,mix.inputs[1]);l.new(remap.outputs[0],mix.inputs[2]);color=mix.outputs[0]
        if name=='Paint':
            # Neutral paint-only weather: panel-edge chips, lower grime and fine rain streaks.
            xyz=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],xyz.inputs[0])
            def mathnode(op,a,b):
                q=n.new('ShaderNodeMath');q.operation=op
                for idx,val in enumerate([a,b]):
                    if isinstance(val,(float,int)):q.inputs[idx].default_value=val
                    else:l.new(val,q.inputs[idx])
                return q.outputs[0]
            dist=mathnode('MINIMUM',mathnode('MINIMUM',xyz.outputs['X'],mathnode('SUBTRACT',1.,xyz.outputs['X'])),mathnode('MINIMUM',xyz.outputs['Y'],mathnode('SUBTRACT',1.,xyz.outputs['Y'])))
            fine=n.new('ShaderNodeTexNoise');fine.inputs['Scale'].default_value=115;l.new(uv.outputs['UV'],fine.inputs['Vector'])
            chipped=mathnode('MULTIPLY',mathnode('LESS_THAN',dist,.027),mathnode('GREATER_THAN',fine.outputs['Fac'],.59))
            chipmix=n.new('ShaderNodeMixRGB');l.new(chipped,chipmix.inputs[0]);l.new(color,chipmix.inputs[1]);chipmix.inputs[2].default_value=(.36,.36,.36,1);color=chipmix.outputs[0]
            vmap=n.new('ShaderNodeVectorMath');vmap.operation='MULTIPLY';vmap.inputs[1].default_value=(55,3,1);l.new(uv.outputs['UV'],vmap.inputs[0])
            streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=1.;streak.inputs['Detail'].default_value=3;l.new(vmap.outputs[0],streak.inputs['Vector'])
            low=mathnode('MULTIPLY',mathnode('SUBTRACT',1.,xyz.outputs['Y']),mathnode('MULTIPLY',streak.outputs['Fac'],.28))
            stain=n.new('ShaderNodeMixRGB');stain.blend_type='MULTIPLY';l.new(low,stain.inputs[0]);l.new(color,stain.inputs[1]);stain.inputs[2].default_value=(.45,.45,.45,1);color=stain.outputs[0]
        sep=n.new('ShaderNodeSeparateColor');l.new(tx['arm'].outputs['Color'],sep.inputs[0]);rough=sep.outputs['Green']
        norm=n.new('ShaderNodeNormalMap');l.new(tx['nor_gl'].outputs['Color'],norm.inputs['Color']);norm.inputs['Strength'].default_value=.018 if name=='Paint' else .6
        bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.08 if name=='Paint' else .15;bump.inputs['Distance'].default_value=.001 if name=='Paint' else .007;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    if name=='Lamps':
        sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0]);r=n.new('ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT';r.color_ramp.elements[0].color=(.7,.75,.7,1);r.color_ramp.elements[1].position=.66;r.color_ramp.elements[1].color=(.5,.008,.003,1);e=r.color_ramp.elements.new(.33);e.color=(.9,.23,.012,1);l.new(sep.outputs['X'],r.inputs[0]);color=r.outputs[0]
    l.new(color,bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=.8
    if rough:
        rm=n.new('ShaderNodeMapRange');l.new(rough,rm.inputs['Value']);rm.inputs['To Min'].default_value=.55 if name=='Paint' else .65;rm.inputs['To Max'].default_value=.92;rough=rm.outputs[0];l.new(rough,bs.inputs['Roughness'])
    bs.inputs['Metallic'].default_value=.7 if name=='Metal' else 0
    # Bake a material tile, preserving density on the vehicle using repeating UVs.
    bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.name='BakeTile';plane.data.materials.append(m)
    savednormal=bs.inputs['Normal'].links[0].from_socket if bs.inputs['Normal'].is_linked else None
    imgs={}
    for kind,res in [('Base',1024),('Normal',1024 if name in ['Paint','Canvas'] else 512),('ORM',512)]:
        im=bpy.data.images.new(name+'_'+kind,res,res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='Base' else 'Non-Color'
        dest=n.new('ShaderNodeTexImage');dest.image=im;n.active=dest
        if kind=='Normal':
            l.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=8)
        else:
            em=n.new('ShaderNodeEmission')
            if kind=='Base':l.new(color,em.inputs[0])
            else:
                combine=n.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;combine.inputs['Green'].default_value=.8;combine.inputs['Blue'].default_value=.7 if name=='Metal' else 0
                if rough:l.new(rough,combine.inputs['Green'])
                l.new(combine.outputs[0],em.inputs[0])
            l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=8)
        im.filepath_raw=str(TEX/(name+'_'+kind+('.jpg' if kind=='Base' else '.png')));im.file_format='JPEG' if kind=='Base' else 'PNG';im.save();imgs[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
    tx={}
    for kind,im in imgs.items():
        t=n.new('ShaderNodeTexImage');t.image=im;tx[kind]=t
    l.new(tx['Base'].outputs['Color'],bs.inputs['Base Color'])
    if name=='Paint':
        mult=n.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(*PAINTS[0][1],1);l.new(tx['Base'].outputs[0],mult.inputs[1]);l.new(mult.outputs[0],bs.inputs['Base Color']);m['paint_mix']=mult.name
    sep=n.new('ShaderNodeSeparateColor');l.new(tx['ORM'].outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
    norm=n.new('ShaderNodeNormalMap');l.new(tx['Normal'].outputs[0],norm.inputs['Color']);l.new(norm.outputs[0],bs.inputs['Normal'])
    # Standard exporter occlusion socket.
    ng=bpy.data.node_groups.get('glTF Material Output')
    if ng is None:
        ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=n.new('ShaderNodeGroup');gn.node_tree=ng;l.new(sep.outputs['Red'],gn.inputs['Occlusion'])
    if name=='Glass':
        for lk in list(bs.inputs['Roughness'].links):l.remove(lk)
        bs.inputs['Alpha'].default_value=.24;bs.inputs['Roughness'].default_value=.13;m.surface_render_method='BLENDED';m.use_transparency_overlap=False
    if name=='Lamps':l.new(tx['Base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.25;bs.inputs['Roughness'].default_value=.22
    M[name]=m;print('BAKED',name,flush=True)

# Geometry primitives are construction parts, all bevelled at physical scale.
def register(o,name,mat,bevel=0):
    o.name=name;o.data.materials.append(M[mat]);groups.setdefault(current,[]).append(o)
    bpy.context.view_layer.objects.active=o
    if bevel:
        mod=o.modifiers.new('Machined edge','BEVEL');mod.width=bevel;mod.segments=1 if name=='Interlocking tread block' else 5 if name=='Cab roof' else 2
        bpy.ops.object.modifier_apply(modifier=mod.name)
    return o

def box(name,p,dim,mat='Paint',bev=.012):
    bpy.ops.mesh.primitive_cube_add(size=1,location=p);o=bpy.context.object;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,name,mat,bev)
    if max(dim)>.12 and name!='Interlocking tread block':
        for f in o.data.polygons:f.use_smooth=True
        wn=o.modifiers.new('Weighted manufactured normals','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50
        bpy.ops.object.modifier_apply(modifier=wn.name)
    return o
def mesh(name,verts,faces,mat='Paint',bev=0,smooth=False):
    me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);bpy.ops.object.select_all(action='DESELECT');o.select_set(True);register(o,name,mat,bev)
    for p in me.polygons:p.use_smooth=smooth
    return o

def cyl(name,p,r,depth,mat='Metal',axis='X',n=32,bev=.004):
    bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=p);o=bpy.context.object
    if axis=='X':o.rotation_euler[1]=pi/2
    if axis=='Y':o.rotation_euler[0]=pi/2
    bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);register(o,name,mat,bev)
    for f in o.data.polygons:f.use_smooth=len(f.vertices)==4
    return o

def line(name,pts,r=.01,mat='Metal'):
    cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=2;sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
    for p,co in zip(sp.points,pts):p.co=(*co,1)
    o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);bpy.context.view_layer.objects.active=o;bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.ops.object.convert(target='MESH');register(o,name,mat);return o

def torus(name,p,major,minor,mat='Rubber',axis='X',majorseg=48,minorseg=12):
    bpy.ops.mesh.primitive_torus_add(major_segments=majorseg,minor_segments=minorseg,location=p,major_radius=major,minor_radius=minor);o=bpy.context.object
    if axis=='X':o.rotation_euler[1]=pi/2
    if axis=='Y':o.rotation_euler[0]=pi/2
    bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);register(o,name,mat)
    for f in o.data.polygons:f.use_smooth=True
    return o

def panel(name,pts,thickness=.025,mat='Paint',bev=.01):
    # polygon in arbitrary plane, solidify then bevel
    o=mesh(name,pts,[tuple(range(len(pts)))],mat);mod=o.modifiers.new('Sheet thickness','SOLIDIFY');mod.thickness=thickness;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
    if bev:mod=o.modifiers.new('Fold radii','BEVEL');mod.width=bev;mod.segments=3;bpy.ops.object.modifier_apply(modifier=mod.name)
    return o

def bolt(p,axis='X',mat='Metal',r=.015):return cyl('Fastener',p,r,.012,mat,axis,6,.002)
def window(name,pts):
    rounded=[]
    for i,p in enumerate(pts):
        p=Vector(p);a=p.lerp(Vector(pts[(i-1)%len(pts)]),.09);b=p.lerp(Vector(pts[(i+1)%len(pts)]),.09)
        for j in range(6):
            t=j/5;rounded.append(tuple((1-t)**2*a+2*(1-t)*t*p+t*t*b))
    pts=rounded
    panel(name,pts,.012,'Glass',.015);line('Rubber glazing gasket',pts+[pts[0]],.024,'Rubber');
    cen=sum((Vector(p) for p in pts),Vector())/len(pts);outer=[tuple(cen+(Vector(p)-cen)*1.065) for p in pts];line('Stamped window flange',outer+[outer[0]],.017,'Paint')

def wheel(x,y,z=.605,spare=False):
    # Continuous shaped casing with broad tread and rolled sidewalls.
    profile=[(-.205,.34),(-.22,.43),(-.205,.52),(-.17,.574),(-.125,.588),(.125,.588),(.17,.574),(.205,.52),(.22,.43),(.205,.34)]
    vs=[];N=64
    for a,r in profile:
        for j in range(N):t=2*pi*j/N;vs.append((x+a,y+sin(t)*r,z+cos(t)*r))
    fs=[]
    for k in range(len(profile)):
        for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,((k+1)%len(profile))*N+(j+1)%N,((k+1)%len(profile))*N+j))
    mesh('Heavy pneumatic casing',vs,fs,'Rubber',smooth=True)
    for j in range(36):
        t=j*2*pi/36
        for s in [-1,1]:
            # alternating chevrons, oriented tangent to tyre
            o=box('Interlocking tread block',(x+s*.095,y+sin(t)*.593,z+cos(t)*.593),(.205,.063,.064),'Rubber',.009);o.rotation_euler=(Matrix.Rotation(-t,4,'X')@Matrix.Rotation(s*.50,4,'Z')).to_euler()
    for s in [-1,1]:
        torus('Moulded sidewall bead',(x+s*.207,y,z),.47,.008,majorseg=48,minorseg=6)
        torus('Rim rolled lip',(x+s*.215,y,z),.315,.017,'Metal',majorseg=40,minorseg=8)
        cyl('Dish rim',(x+s*.14,y,z),.308,.035,'Metal',n=40)
        rv=[];rf=[];steps=[(.315,.211),(.277,.217),(.244,.199),(.194,.168),(.134,.178),(.11,.215)]
        for rr,depth in steps:
            for j in range(40):a=j*2*pi/40;rv.append((x+s*depth,y+sin(a)*rr,z+cos(a)*rr))
        for k in range(len(steps)-1):
            for j in range(40):rf.append((k*40+j,k*40+(j+1)%40,(k+1)*40+(j+1)%40,(k+1)*40+j))
        mesh('Recessed pressed wheel dish',rv,rf,'Paint',smooth=True)
        cyl('Axle hub',(x+s*.253,y,z),.115,.105,'Metal',n=32,bev=.014)
        for j in range(8):
            a=j*pi/4;bolt((x+s*.24,y+sin(a)*.172,z+cos(a)*.172),r=.022)
        for j in range(8):
            a=j*pi/4+.2;cyl('Rim vent shadow',(x+s*.24,y+sin(a)*.235,z+cos(a)*.235),.032,.009,'Rubber',n=12,bev=.003)

for name in ['Paint','Canvas','Rubber','Metal','Wood','Interior','Glass','Lamps']:make_material(name)
# Ladder frame and running gear.
for x in [-.48,.48]:box('Chassis C rail',(x,.15,.80),(.15,6.90,.23),'Metal',.016)
for y in [-3,-1.8,-.5,.8,2.2,3.4]:box('Chassis crossmember',(0,y,.80),(1.12,.14,.16),'Metal')
for y in [-2.4,1.10,2.4]:
    cyl('Axle tube',(0,y,.605),.085,2.1,'Metal');
    bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,location=(0,y,.605));o=bpy.context.object;o.scale=(.25,.21,.20);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,'Differential housing','Metal')
    for x in [-.56,.56]:
        for k in range(5):
            pts=[(x,y+(i-6)*.105,.67+k*.018+.07*((i-6)/6)**2) for i in range(13)];line('Laminated leaf spring',pts,.012,'Metal')
        for dy in [-.40,.40]:box('Spring hanger',(x,y+dy,.79),(.11,.08,.23),'Metal')
for y1,y2 in [(-2.4,1.1),(1.1,2.4)]:line('Drive prop shaft',[(0,y1,.62),(0,y2,.70)],.047)
for label,y in [('F',-2.4),('R',1.1),('R2',2.4)]:
    for side,x in [('L',.96),('R',-.96)]:
        current='Wheel_'+('RL2' if label=='R2' and side=='L' else 'RR2' if label=='R2' else label+side);wheel(x,y)
current='Body'
# Cab lower shell with rounded corners, footwells and solid rear.
box('Cab floor',(0,-1.23,1.03),(1.94,1.43,.14))
box('Cab rear wall',(0,-.55,1.80),(1.94,.095,1.48),bev=.065)
box('Cowl firewall',(0,-1.88,1.61),(1.92,.22,.78),bev=.06)
# tapered bonnet loft in longitudinal rings
rings=[(-3.43,.67,1.60,1.72),(-3.27,.76,1.58,1.81),(-2.75,.78,1.57,1.91),(-2.05,.82,1.57,1.98),(-1.91,.82,1.57,1.99)]
vs=[];ring_n=19
for y,w,b,t in rings:
    vs.append((-w,y,b))
    for j in range(17):
        ang=-pi/2+pi*j/16;vs.append((w*sin(ang),y,t-.105*(1-cos(ang))))
    vs.append((w,y,b))
fs=[]
for k in range(len(rings)-1):
    for j in range(ring_n-1):fs.append((k*ring_n+j,k*ring_n+j+1,(k+1)*ring_n+j+1,(k+1)*ring_n+j))
for k in range(len(rings)-1):fs.append((k*ring_n,(k+1)*ring_n,(k+1)*ring_n+ring_n-1,k*ring_n+ring_n-1))
fs.extend([tuple(reversed(range(ring_n))),tuple(range(4*ring_n,5*ring_n))]);bonnet=mesh('Tapered pressed steel bonnet',vs,fs,'Paint',.014,True)
line('Bonnet centre seam',[(0,-3.40,1.729),(0,-3.27,1.814),(0,-2.75,1.914),(0,-2.05,1.984),(0,-1.92,1.994)],.004,'Metal')
for s in [-1,1]:
    valance=box('Engine side valance',(s*.773,-2.67,1.60),(.075,1.44,.28),bev=.022)
    for i in range(8):
        y=-2.97+i*.12
        bpy.ops.mesh.primitive_cube_add(size=1,location=(s*.78,y,1.66));cut=bpy.context.object;cut.dimensions=(.30,.064,.115);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
        bevel=cut.modifiers.new('Rounded punched opening','BEVEL');bevel.width=.007;bevel.segments=3;bpy.ops.object.modifier_apply(modifier=bevel.name)
        for panel_obj in [valance,bonnet]:
            bpy.context.view_layer.objects.active=panel_obj
            boolean=panel_obj.modifiers.new('Actual recessed cooling opening','BOOLEAN');boolean.operation='DIFFERENCE';boolean.solver='EXACT';boolean.object=cut;bpy.ops.object.modifier_apply(modifier=boolean.name)
        bpy.data.objects.remove(cut,do_unlink=True)
        box('Dark louvre throat',(s*.70,y,1.66),(.006,.063,.114),'Rubber',.005)
        box('Stamped louvre return',(s*.806,y-.033,1.66),(.009,.009,.110),bev=.003)
    for y in [-3.20,-2.03]:
        box('Hood latch base',(s*.83,y,1.57),(.025,.08,.08),'Metal',.007);line('Bonnet spring latch',[(s*.85,y,1.53),(s*.85,y,1.69)],.012)
# Opaque engine compartment closes the wheel-well sightline.
box('Engine casing',(0,-2.60,1.29),(1.46,1.44,.58),'Metal',.07)
for side in [-1,1]:
    box('Inner fender apron',(side*.738,-2.64,1.34),(.025,1.46,.44),'Metal',.012)
# Front grille frame, slotted grille with real depth.
box('Radiator dark cavity',(0,-3.435,1.38),(1.27,.06,.62),'Rubber',.05)
for x in [-.68,.68]:box('Grille edge',(x,-3.48,1.40),(.10,.10,.66),bev=.034)
for z in [1.07,1.73]:box('Grille top bottom',(0,-3.475,z),(1.40,.1,.09),bev=.025)
for x in [i*.105 for i in range(-5,6)]:box('Radiator vertical bar',(x,-3.50,1.40),(.039,.068,.54),bev=.018)
box('Front bumper',(0,-3.605,.91),(2.35,.16,.18),'Metal',.015)
for x in [-.60,.60]:
    box('Front frame horn',(x,-3.39,.91),(.17,.43,.19),'Metal',.012)
    box('Bumper attachment plate',(x,-3.51,.91),(.25,.08,.25),'Metal',.012)
    for dx in [-.075,.075]:
        for z in [.855,.965]:bolt((x+dx,-3.693,z),'Y',r=.019)
for x in [-.93,.93]:
    box('Front lamp wing',(x,-3.17,1.28),(.43,.46,.42),bev=.055)
    cyl('Headlight bucket',(x,-3.42,1.31),.143,.08,'Metal','Y',48,.014)
    cyl('Headlight glass',(x,-3.467,1.31),.112,.023,'Lamps','Y',48,.010)
    torus('Headlight bezel',(x,-3.484,1.31),.124,.014,'Metal','Y')
    for dx in [-.055,-.027,0,.027,.055]:line('Headlight lens fluting',[(x+dx,-3.482,1.225),(x+dx,-3.482,1.395)],.0028,'Glass')
    cyl('Amber turn lamp',(x*1.08,-3.42,1.53),.049,.024,'Lamps','Y',24)
    torus('Tow eye',(x*.81,-3.56,1.04),.053,.015,'Metal','Y',24,8)
# Characteristic full front mudguards: rolled plate sweeping back into step.
for s in [-1,1]:
    pts=[(-3.39,1.57),(-3.12,1.58),(-2.76,1.60),(-2.39,1.60),(-2.08,1.58),(-1.91,1.48),(-1.72,1.19),(-1.51,.80)]
    control=[Vector(p) for p in pts];curved=[]
    for i in range(len(control)-1):
        a=control[max(0,i-1)];b=control[i];c=control[i+1];d=control[min(len(control)-1,i+2)]
        for j in range(5):
            t=j/5;v=.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t);curved.append(tuple(v))
    pts=curved+[tuple(control[-1])];npts=len(pts)
    verts=[(s*x,y,z) for x in [.79,1.18] for y,z in pts];faces=[(i,i+1,npts+i+1,npts+i) for i in range(npts-1)];o=mesh('Swept front mudguard',verts,faces,'Paint');mod=o.modifiers.new('Steel gauge','SOLIDIFY');mod.thickness=.035;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
    line('Rolled fender outer edge',[(s*1.18,y,z) for y,z in pts],.022,'Paint')
    panel('Rear fender apron',[(s*.8,-1.66,.79),(s*1.16,-1.66,.79),(s*1.16,-1.8,1.25),(s*.8,-1.8,1.25)],.025,'Paint')
    box('Cab step',(s*1.03,-1.03,.82),(.30,.69,.10),'Metal',.014)
    for y in [-1.27,-1.15,-1.03,-.91,-.79]:box('Step grip rib',(s*1.035,y,.88),(.28,.022,.015),'Metal',.004)
    # door lower panel; sculpted waist and upright window posts
    box('Door lower skin',(s*.981,-1.18,1.42),(.072,1.16,.79),bev=.045)
    box('Door belt moulding',(s*1.022,-1.17,1.85),(.025,1.18,.045),bev=.012)
    sidepts=[(s*1.0,-1.73,1.91),(s*.955,-1.58,2.47),(s*.955,-.68,2.47),(s*1.0,-.64,1.91)]
    window('Door glazing',sidepts)
    line('Quarter light divider',[(s*.994,-1.43,1.94),(s*.957,-1.40,2.46)],.013,'Metal')
    for y in [-1.77,-.59]:line('Cab pillar',[(s*.98,y,1.80),(s*.95,y+( .16 if y<-1 else 0),2.51)],.048,'Paint')
    for z in [1.12,1.73]:box('Door hinge',(s*1.029,-1.70,z),(.035,.070,.105),'Metal',.01)
    line('Door seam',[(s*1.024,-1.73,1.79),(s*1.025,-1.73,1.03),(s*1.025,-.66,1.03),(s*1.025,-.62,1.83)],.005,'Rubber')
    box('Handle escutcheon',(s*1.03,-.84,1.74),(.018,.14,.055),'Metal',.02);line('Door pull',[(s*1.06,-.9,1.74),(s*1.08,-.85,1.74),(s*1.06,-.78,1.74)],.013,'Metal')
    line('Mirror steel arm',[(s*1.02,-1.72,1.82),(s*1.22,-1.86,2.02),(s*1.22,-1.86,2.26)],.014,'Metal')
    box('Mirror housing',(s*1.218,-1.855,2.17),(.064,.17,.29),'Rubber',.045);box('Mirror glass',(s*1.218,-1.763,2.17),(.050,.008,.245),'Glass',.020)
# windscreen is split and raked backward
for s in [-1,1]:
    panel('Wrapped A pillar',[(s*.86,-1.982,1.94),(s*.84,-1.731,2.49),(s*.96,-1.58,2.49),(s*1.,-1.73,1.94)],.032,'Paint',.015)
    window('Split front windshield',[(s*.055,-1.982,1.97),(s*.86,-1.982,1.97),(s*.84,-1.731,2.46),(s*.055,-1.731,2.46)])
    line('Wiper arm',[(s*.50,-2.015,1.955),(s*.36,-1.888,2.21)],.010,'Metal');line('Wiper blade',[(s*.18,-1.903,2.18),(s*.58,-1.903,2.18)],.009,'Rubber')
line('Windshield central pillar',[(0,-2.0,1.93),(0,-1.72,2.51)],.035,'Paint')
roofverts=[];rooffaces=[];nx=20;nyroof=16
for j in range(nyroof+1):
    yy=-.675+1.35*j/nyroof;corner=max(0,abs(yy)-.555);half=.885+math.sqrt(max(0,.12**2-corner**2))
    for i in range(nx+1):
        xx=half*(-1+2*i/nx);zz=2.535+.09*cos(xx/half*pi/2)*cos(yy/.675*pi/2)
        roofverts.append((xx,-1.155+yy,zz))
for j in range(nyroof):
    for i in range(nx):a=j*(nx+1)+i;rooffaces.append((a,a+1,a+nx+2,a+nx+1))
roof=mesh('Crowned stamped cab roof',roofverts,rooffaces,'Paint',smooth=True)
mod=roof.modifiers.new('Roof sheet thickness','SOLIDIFY');mod.thickness=.043;bpy.context.view_layer.objects.active=roof;bpy.ops.object.modifier_apply(modifier=mod.name)
line('Roof drip gutter',[(-1.015,-1.91,2.51),(-1.02,-.5,2.51),(1.02,-.5,2.51),(1.015,-1.91,2.51)],.018,'Metal')
for x in [-.42,0,.42]:
    rz=2.535+.09*cos(x/1.05*1.25)*cos(-.565/.675*pi/2)
    box('Roof lamp gasket',(x,-1.72,rz+.003),(.083,.114,.012),'Rubber',.009)
    box('Roof clearance lamp',(x,-1.72,rz+.025),(.070,.10,.044),'Lamps',.02)
# Cab visible interior
box('Dashboard',(0,-1.78,1.80),(1.73,.25,.20),'Interior',.04)
for x in [.43,.57,.69]:cyl('Instrument bezel',(x,-1.625,1.84),.052 if x==.43 else .036,.012,'Metal','Y',24)
for x in [.43,.57,.69]:cyl('Instrument black face',(x,-1.616,1.84),.042 if x==.43 else .027,.009,'Rubber','Y',24)
for x in [.43,.57,.69]:line('Gauge needle',[(x,-1.609,1.84),(x+.014,-1.609,1.858)],.002,'Lamps')
for x in [-.47,.49]:
    box('Seat cushion',(x,-1.02,1.27),(.66,.53,.16),'Interior',.075);o=box('Seat back',(x,-.73,1.62),(.65,.14,.66),'Interior',.070);o.rotation_euler[0]=-.10
    for dx in [-.24,0,.24]:line('Seat upholstery seam',[(x+dx,-1.25,1.358),(x+dx,-.79,1.358)],.003,'Metal')
line('Steering column',[(.5,-1.56,1.36),(.5,-1.38,1.87)],.028,'Metal')
current='SteeringWheel';torus('Steering rim',(.5,-1.38,1.87),.205,.018,'Rubber','Z',48,10)
cyl('Steering hub',(.5,-1.38,1.87),.044,.045,'Metal','Z',24)
for a in [0,2*pi/3,4*pi/3]:line('Steering spoke',[(.5,-1.38,1.87),(.5+cos(a)*.19,-1.38+sin(a)*.19,1.87)],.012,'Metal')
# Wheel plane tilts perpendicular to its raked column.
for o in groups['SteeringWheel']:
    o.matrix_world=Matrix.Translation((.5,-1.38,1.87))@Matrix.Rotation(-.34,4,'X')@Matrix.Translation((-.5,1.38,-1.87))@o.matrix_world
current='Body';line('Gear stick',[(.02,-1.19,1.08),(.05,-1.07,1.58)],.016,'Metal');cyl('Gear knob',(.05,-1.07,1.58),.032,.05,'Rubber','Z',16,.014)
# Tanks and spare behind cab.
for s in [-1,1]:
    box('Diesel fuel tank',(s*.79,.13,.95),(.51,1.15,.46),'Paint',.085)
    for y in [-.29,.53]:box('Tank strap',(s*.795,y,.95),(.525,.045,.475),'Metal',.065)
    cyl('Fuel filler',(s*.87,-.11,1.205),.065,.065,'Metal','Z',24)
    box('Tank chassis bracket',(s*.72,.13,.70),(.45,.98,.06),'Metal')
# spare is behind cab, vertical and mounted across width axis
spare_start=len(groups['Body']);wheel(.65,-.28,1.92,True)
for o in groups['Body'][spare_start:]:
    o.matrix_world=Matrix.Translation((0,-.26,1.92)) @ Matrix.Rotation(pi/2,4,'Z') @ Matrix.Translation((-.65,.28,-1.92)) @ o.matrix_world
box('Spare tyre rack',(0,-.26,1.33),(1.10,.46,.15),'Metal')
box('Tool locker',(-.66,-.26,1.65),(.48,.49,.67),'Paint',.035)
# Bed platform and metal framed timber side gates.
box('Load bed sill',(0,1.82,1.31),(2.34,3.74,.13),'Metal',.018)
for i in range(12):box('Bed floor board',(-1.055+i*.192,1.81,1.40),(.185,3.72,.065),'Wood',.005)
for s in [-1,1]:
    for z in [1.46,1.81]:box('Side gate channel',(s*1.17,1.82,z),(.067,3.76,.075),'Paint',.012)
    for z in [1.55,1.67]:box('Timber sideboard',(s*1.17,1.82,z),(.050,3.72,.106),'Wood',.005)
    for y in [-.05,.88,1.82,2.76,3.69]:
        box('Side stake',(s*1.20,y,1.64),(.077,.068,.46),'Paint',.009)
        for z in [1.49,1.77]:bolt((s*1.245,y,z),r=.014)
    for y in [.39,1.35,2.28,3.20]:
        box('Gate hinge',(s*1.22,y,1.46),(.023,.12,.058),'Metal',.007);cyl('Hinge pin',(s*1.23,y,1.46),.019,.17,'Metal','Y',16)
    # inward-facing benches, seat and backrest
    for dx in [.78,.95]:box('Troop bench plank',(s*dx,1.80,1.79),(.155,3.45,.065),'Wood',.010)
    for z in [2.00,2.16]:box('Troop backrest',(s*1.055,1.81,z),(.055,3.42,.105),'Wood',.008)
    for y in [.25,1.75,3.35]:
        line('Bench support',[(s*1.06,y,1.43),(s*.79,y,1.73),(s*.79,y,1.79)],.025,'Metal')
# tailgate
for z in [1.55,1.67]:box('Tailgate plank',(0,3.70,z),(2.29,.052,.105),'Wood',.006)
for z in [1.45,1.81]:box('Tailgate rail',(0,3.73,z),(2.36,.08,.065),'Paint',.012)
for x in [-1.14,0,1.14]:box('Tailgate brace',(x,3.745,1.64),(.06,.04,.36),'Paint')
box('Rear bumper',(0,3.655,.93),(2.20,.13,.16),'Metal')
for s in [-1,1]:
    box('Rear mud flap',(s*.96,3.09,.73),(.47,.045,.65),'Rubber',.018)
    box('Tail lamp housing',(s*.96,3.727,1.21),(.20,.055,.105),'Rubber',.018);box('Tail lamp lens',(s*.96,3.762,1.21),(.16,.022,.075),'Lamps',.014)
    line('Rear gate chain',[(s*1.15,3.72,1.79),(s*1.05,3.78,1.55),(s*.84,3.73,1.47)],.008,'Metal')
line('Rear tow hitch',[(0,3.66,.85),(0,3.76,.84),(0,3.76,.93)],.033,'Metal')
# Canvas: continuous shaped cross section with sag between five hoops, tailored skirt folds.
y0=-.05;y1=3.70
profile=[(-1.18,1.82),(-1.18,2.10),(-1.17,2.43),(-1.13,2.66),(-1.04,2.77),(-.80,2.825),(-.40,2.855),(0,2.865),(.40,2.855),(.80,2.825),(1.04,2.77),(1.13,2.66),(1.17,2.43),(1.18,2.10),(1.18,1.82)]
# interpolate profile for cloth deformation
prof=[]
for a,b in zip(profile,profile[1:]):
    for q in range(3):t=q/3;prof.append((a[0]*(1-t)+b[0]*t,a[1]*(1-t)+b[1]*t))
prof.append(profile[-1]);N=len(prof);vs=[];ny=100
for j in range(ny+1):
    t=j/ny;y=y0+(y1-y0)*t;bay=t*4;sag=sin(pi*(bay%1))**2
    for k,(x,z) in enumerate(prof):
        side=abs(x)/1.18;fold=.009*sin(y*16+z*12)+.007*sin(y*28-z*20)
        xx=x+(1 if x>=0 else -1)*side*(fold*(.35+.65*sag)-.045*sag)
        zz=z-.052*sag*(1-.6*side)+.012*sin(y*9)*side*(max(0,2.15-z)/.33)
        vs.append((xx,y,zz))
fs=[]
for j in range(ny):
    for k in range(N-1):a=j*N+k;fs.append((a,a+1,a+N+1,a+N))
o=mesh('Tailored canvas tilt',vs,fs,'Canvas',smooth=True);mod=o.modifiers.new('Canvas thickness','SOLIDIFY');mod.thickness=.008;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
# front closed canvas; rear open with rolled valance, allows bench view.
panel('Canvas front curtain',[(x,y0+.007,z) for x,z in profile],.008,'Canvas',.006)
for y in [y0,y0+.94,y0+1.875,y0+2.81,y1]:
    line('Internal hoop',[(x*.978,y,z-.025) for x,z in profile],.016,'Metal')
    line('Canvas stitched hoop seam',[(x*1.003,y,z+.005) for x,z in profile],.004,'Canvas')
for s in [-1,1]:
    line('Canvas reinforced hem',[(s*1.186,y0+i*(y1-y0)/100,1.835+.010*sin(i*.33)) for i in range(101)],.013,'Canvas')
    for i in range(13):
        y=y0+.06+i*.302;torus('Brass canvas eyelet',(s*1.205,y,1.838),.015,.004,'Metal','X',12,6)
        if i<12:line('Tie-down lacing',[(s*1.208,y,1.837),(s*1.222,y+.151,1.70),(s*1.208,y+.302,1.837)],.006,'Canvas')
    panel('Rear canvas corner flap',[(s*1.17,3.708,1.84),(s*.91,3.708,1.84),(s*.91,3.708,2.73),(s*1.08,3.708,2.72)],.01,'Canvas',.01)
cyl('Rolled rear canvas curtain',(0,3.69,2.70),.088,1.88,'Canvas','X',40,.004)
for x in [-.67,.67]:torus('Roll canvas strap',(x,3.69,2.70),.09,.010,'Canvas','X',24,6)
# exhaust stack and underbody small fittings
line('Exhaust pipe',[(-.6,-1.65,.62),(-.67,-.3,.64),(-1.04,.0,.77),(-1.11,.50,.88)],.038,'Metal')
cyl('Muffler',(-.65,-.65,.64),.10,.80,'Metal','Y',24)
for s in [-1,1]:
    for y in [.65,2.94]:line('Bed diagonal brace',[(s*.54,y,.81),(s*1.12,y+.28,1.30)],.027,'Metal')

# Physical tiled UVs after all modifiers; primitives retain curvature in geometry.
def uv_project(o):
    me=o.data
    uv=me.uv_layers.active or me.uv_layers.new(name='UVMap')
    mat=o.data.materials[0].name
    scale={'Paint':1.7,'Canvas':1.3,'Rubber':2.4,'Metal':2.0,'Wood':1.1,'Interior':2.0,'Glass':1,'Lamps':1}.get(mat,1)
    for p in me.polygons:
        n=p.normal;ax=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=ax]
        for li in p.loop_indices:
            v=o.matrix_world@me.vertices[me.loops[li].vertex_index].co
            if mat=='Lamps':
                u=.15
                if 'Amber' in o.name or 'clearance' in o.name:u=.5
                if 'Tail lamp lens' in o.name:u=.85
                uv.data[li].uv=(u,.5)
            elif mat=='Paint':
                local=me.vertices[me.loops[li].vertex_index].co
                # A repeating trim per constructed panel gives wear a physical edge.
                bounds=[(min(c[i] for c in o.bound_box),max(c[i] for c in o.bound_box)) for i in axes]
                uv.data[li].uv=tuple(.003+.994*(local[a]-b[0])/max(.0001,b[1]-b[0]) for a,b in zip(axes,bounds))
            else:uv.data[li].uv=(v[axes[0]]*scale,v[axes[1]]*scale)

for obs in groups.values():
    for o in obs:uv_project(o)
# Join rigid assemblies, origins at wheel centres. Apply transforms; no helpers exported.
objects=[]
for name,obs in groups.items():
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:o.select_set(True)
    bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
    bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
    if name.startswith('Wheel_'):
        suf=name[6:];x=.96 if 'L' in suf else -.96;y=-2.4 if suf.startswith('F') else 2.4 if suf.endswith('2') else 1.1;origin=(x,y,.605)
    elif name=='SteeringWheel':origin=(.5,-1.38,1.87)
    else:origin=(0,0,0)
    S.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects.append(o)
# Budget reduction on complete assemblies; actual source normals/UVs retained by collapse.
for o in objects:
    if o.type=='MESH':
        bpy.context.view_layer.objects.active=o
        mod=o.modifiers.new('Game triangle budget','DECIMATE');mod.ratio=.56;mod.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=mod.name)
# Named sockets. Troop points face inwards and can carry game avatars.
sockets={'Seat_Driver':(.49,-1.02,1.37),'Door_L':(1.0,-1.73,1.73),'Door_R':(-1.0,-1.73,1.73),'Exhaust':(-1.11,.50,.88),'Light_Head_L':(.93,-3.48,1.31),'Light_Head_R':(-.93,-3.48,1.31),'Light_Tail_L':(.96,3.77,1.21),'Light_Tail_R':(-.96,3.77,1.21)}
for s in [-1,1]:
    for i,y in enumerate([.35,1.20,2.05,2.90]):sockets[f'Troop_{i+1+(4 if s<0 else 0)}']=(s*.86,y,1.84)
for name,p in sockets.items():o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=p;o.empty_display_size=.10;objects.append(o)
# Exact envelope normalization; common linear transform preserves wheel-centre pivots.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
mn=[min(p[i] for p in pts) for i in range(3)];mx=[max(p[i] for p in pts) for i in range(3)]
scales=(2.50/(mx[0]-mn[0]),7.37/(mx[1]-mn[1]),2.87/(mx[2]-mn[2]))
normalizer=Matrix.Diagonal((*scales,1)) @ Matrix.Translation((0,0,-mn[2]))
for o in objects:
    o.matrix_world=normalizer@o.matrix_world
    if o.type=='MESH':
        bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o;bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
# Remove collapsed bevel facets before serialization; no zero-area GLB triangles.
for o in objects:
    if o.type=='MESH':
        bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
        bad=[f for f in bm.faces if f.calc_area()<1e-10]
        if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
        bm.to_mesh(o.data);bm.free();o.data.update()
# Recompute after decimation/triangulation, which discard the per-part custom normals.
# Large plank and panel faces remain planar while their real bevels catch light.
body=next(o for o in objects if o.name=='Body')
bpy.context.view_layer.objects.active=body
wn=body.modifiers.new('Final manufactured surface normals','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50
bpy.ops.object.modifier_apply(modifier=wn.name)
# bounds and geometry counts, export
bpy.context.view_layer.update()
points=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in points) for i in range(3)];hi=[max(p[i] for p in points) for i in range(3)]
def game(p):return [round(p[0],6),round(p[2],6),round(-p[1],6)]
tris=sum(len(p.vertices)-2 for o in objects if o.type=='MESH' for p in o.data.polygons)
manifest={'contract':'Forge Garage v2','units':'metres','forward':'+Z','up':'+Y','nodes':{o.name:{'position':game(o.location),'type':o.type} for o in objects},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':tris,'materials':list(M),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'ural-truck.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT')
# Enforce exact portable Paint factor and Glass alpha in serialized glTF (exporter node inference varies by version).
import struct
p=ROOT/'ural-truck.glb';raw=p.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for mat in doc['materials']:
    if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[*PAINTS[0][1],1]
    if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['pbrMetallicRoughness']['baseColorFactor']=[1,1,1,.24];mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);p.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
print('EXPORTED',tris,p.stat().st_size,flush=True)
(ROOT/'NOTES.md').write_text(f'Ural-4320-inspired 6x6 troop carrier, detailed cab, drivetrain, eight troop places and canvas tilt; metres, Garage v2.\n{tris:,} triangles; six independently centred wheel nodes and SteeringWheel.\n8 materials: Paint, Canvas, Rubber, Metal, Wood, Interior, Glass (BLEND), Lamps (emissive).\nCycles-baked repeating colour 1024 px; normal 512/1024 px; packed ORM 512 px; GLB {p.stat().st_size/1048576:.2f} MiB.\nPoly Haven CC0 IDs: '+', '.join(sorted(set(SOURCES.values())))+'; https://polyhaven.com/license ; source maps from dl.polyhaven.org.\nDifferences: interpretive cab stamping, simplified engine and instruments; no logos or markings; rear curtain rolled open to reveal benches.\n')
# Render staging only (not in exported model).
world=bpy.data.worlds.new('Daylight');S.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.85,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor=bpy.data.materials.new('Studio ground');floor.diffuse_color=(.19,.22,.24,1);floor.use_nodes=True;floor.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.19,.22,.24,1);floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.86
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.017));ground=bpy.context.object;ground.data.materials.append(floor)
def area(name,p,power,size,color):
    bpy.ops.object.light_add(type='AREA',location=p);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.data.color=color;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight softbox',(4,-6,9),2100,7,(1,.92,.81));area('Sky fill',(-5,-1,6),1500,6,(.75,.85,1));area('Rear rim',(1,6,7),2000,5,(1,.97,.90))
bpy.ops.object.camera_add();cam=bpy.context.object;S.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=10.2
hero=(10,-13,5.9)
def aim(p,target=(0,.1,1.35)):
    cam.location=p;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler()
def render(name,w=1920,h=1080,samples=24):
    S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.image_settings.file_format='PNG';S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
S.view_settings.view_transform='AgX';aim(hero);render('final.png')
aim((12,.1,2.3));cam.data.ortho_scale=9.3;render('side.png',1920,1080)
aim(hero);cam.data.ortho_scale=10.2
clay=bpy.data.materials.new('Clay only');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.35,.35,.35,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.7
S.view_layers[0].material_override=clay;render('clay.png');S.view_layers[0].material_override=None
# Six same-camera actual model renders packed into contact sheet using Blender image pixels.
import numpy as np
w,h=640,360;grid=np.zeros((h*2,w*3,4),dtype=np.float32)
for i,(name,c) in enumerate(PAINTS):
    M['Paint'].node_tree.nodes[M['Paint']['paint_mix']].inputs[2].default_value=(*c,1)
    render('_paint.png',w,h,12);im=bpy.data.images.load(str(ROOT/'_paint.png'),check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;col=i%3;grid[row*h:(row+1)*h,col*w:(col+1)*w]=arr;bpy.data.images.remove(im)
im=bpy.data.images.new('Six Paints',w*3,h*2);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save();(ROOT/'_paint.png').unlink(missing_ok=True)
print('COMPLETE',flush=True)
