"""Hop City original street pack. Blender 5.2, metres, Z-up authoring, front -Y.
Run: blender -b --python-exit-code 1 --python build_street.py -- [slug|all|lineup]
No external images. Procedural albedo and packed roughness/metallic are Cycles-baked.
"""
import bpy, math, os, sys, json, random
from mathutils import Vector
from pathlib import Path
OUT=Path(__file__).resolve().parent
random.seed(42)
SPEC={'street-sweeper':([2,5,2.7],5000,600000),'tram':([2.3,16,3.3],7000,900000),'raft-2':([4,1.8,.45],2500,600000),'raft-3':([6,1.8,.45],2500,600000),'raft-4':([8,1.8,.45],2500,600000),'lily-pad':([1.7,1.7,.12],1200,600000),'donut':([.55,.55,.22],1200,600000),'tram-signal':([.5,.4,2.6],2000,600000),'bench':([1.8,.7,.9],2000,600000),'hedge':([1.8,1.8,1.3],2000,600000)}
PARTS=[]; MATS={}
def setup():
    global PARTS,MATS
    bpy.ops.wm.read_factory_settings(use_empty=True); PARTS=[]; MATS={}; random.seed(42)
    s=bpy.context.scene; s.render.engine='CYCLES'; s.cycles.samples=32
    s.cycles.use_denoising=True; s.cycles.preview_samples=8
    try:
        p=bpy.context.preferences.addons['cycles'].preferences; p.compute_device_type='OPTIX'; p.get_devices()
        for d in p.devices: d.use=d.type=='OPTIX'
        s.cycles.device='GPU'
    except Exception: s.cycles.device='CPU'
    s.render.resolution_x=s.render.resolution_y=800; s.render.resolution_percentage=100
    s.render.image_settings.file_format='PNG'; s.world=bpy.data.worlds.new('Daylight'); s.world.use_nodes=True
    s.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.85,1)
    s.world.node_tree.nodes['Background'].inputs[1].default_value=.55
    s.view_settings.view_transform='AgX'

def mat(name,color,rough=.55,metal=0,texture=None):
    if name in MATS:return MATS[name]
    m=bpy.data.materials.new(name); m.diffuse_color=(*color,1); m.use_nodes=True
    p=m.node_tree.nodes.get('Principled BSDF'); p.inputs['Base Color'].default_value=(*color,1)
    p.inputs['Roughness'].default_value=rough; p.inputs['Metallic'].default_value=metal
    m['rough']=rough; m['metal']=metal
    if texture:
        nt=m.node_tree; tex=nt.nodes.new('ShaderNodeTexNoise'); tex.inputs['Scale'].default_value=5 if texture=='wood' else 24
        tex.inputs['Detail'].default_value=3; tc=nt.nodes.new('ShaderNodeTexCoord')
        v=nt.nodes.new('ShaderNodeVectorMath'); v.operation='MULTIPLY'; v.inputs[1].default_value=(.7,28,12) if texture=='wood' else (1,1,1)
        nt.links.new(tc.outputs['Generated'],v.inputs[0]); nt.links.new(v.outputs[0],tex.inputs['Vector'])
        ramp=nt.nodes.new('ShaderNodeValToRGB'); ramp.color_ramp.elements[0].position=.18; ramp.color_ramp.elements[1].position=.82
        a,b=(.58,1.25) if texture in ('wood','leaf','stone') else (.91,1.06)
        ramp.color_ramp.elements[0].color=(*[c*a for c in color],1); ramp.color_ramp.elements[1].color=(*[min(c*b,1) for c in color],1)
        nt.links.new(tex.outputs['Fac'],ramp.inputs[0]); nt.links.new(ramp.outputs['Color'],p.inputs['Base Color'])
    MATS[name]=m; return m

def special():
    g=mat('Glass',(.12,.27,.32),.16,.12); p=g.node_tree.nodes.get('Principled BSDF'); p.inputs['Alpha'].default_value=.35; g.diffuse_color=(.12,.27,.32,.35); g.surface_render_method='BLENDED'
    e=mat('Glow',(1,.48,.05),.3); p=e.node_tree.nodes.get('Principled BSDF'); p.inputs['Emission Color'].default_value=(1,.32,.025,1); p.inputs['Emission Strength'].default_value=2
    return g,e

def finish_part(o,name,m,smooth=False):
    o.name=name; o.data.materials.clear(); o.data.materials.append(m); PARTS.append(o)
    if smooth:
        for p in o.data.polygons:p.use_smooth=True
    return o

def bevel(o,w=.02,segments=1):
    bpy.context.view_layer.objects.active=o; o.select_set(True)
    b=o.modifiers.new('Manufactured edge','BEVEL'); b.width=w; b.segments=segments
    bpy.ops.object.modifier_apply(modifier=b.name)
    try:
        n=o.modifiers.new('Weighted normals','WEIGHTED_NORMAL'); n.keep_sharp=True; n.weight=40; bpy.ops.object.modifier_apply(modifier=n.name)
    except Exception:pass
    return o

def box(name,loc,size,m,b=.02,rot=None):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.dimensions=size
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    if b:bevel(o,b,1)
    if rot:o.rotation_euler=rot
    return finish_part(o,name,m)

def cyl(name,loc,r,depth,m,n=16,axis='Z',r2=None):
    if r2 is None:bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc)
    else:bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r2,depth=depth,location=loc)
    o=bpy.context.object
    if axis=='X':o.rotation_euler[1]=math.pi/2
    elif axis=='Y':o.rotation_euler[0]=math.pi/2
    return finish_part(o,name,m,True)

def rod(name,a,b,r,m,n=8):
    d=Vector(b)-Vector(a); o=cyl(name,(Vector(a)+Vector(b))/2,r,d.length,m,n); o.rotation_euler=d.to_track_quat('Z','Y').to_euler(); return o

def mesh(name,verts,faces,m,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)
    return finish_part(o,name,m,smooth)

def profile(name,coords,width,m):
    # side profile listed counterclockwise in Y,Z; closed extrusion along X
    v=[(x,y,z) for x in (-width/2,width/2) for y,z in coords]; n=len(coords)
    f=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    o=mesh(name,v,f,m); bevel(o,.025);return o

def torus(name,loc,major,minor,m,N=24,n=6,rot=None):
    bpy.ops.mesh.primitive_torus_add(major_radius=major,minor_radius=minor,major_segments=N,minor_segments=n,location=loc)
    o=bpy.context.object
    if rot:o.rotation_euler=rot
    return finish_part(o,name,m,True)

def sweeper():
    orange=mat('Enamel orange',(.95,.20,.018),.36,.12,'paint'); dark=mat('Rubber charcoal',(.025,.032,.035),.78)
    steel=mat('Brushed alloy',(.32,.37,.38),.34,.7); white=mat('Ivory stripe',(.88,.89,.83),.4)
    glass,glow=special(); inner=mat('Cab interior',(.035,.073,.085),.65)
    box('Ladder chassis',(0,.1,.52),(1.5,4.6,.27),dark,.06)
    # Broad chamfered cab with sloped panoramic windscreen, short bonnet, upright rear.
    profile('Orange forward cab',[(-2.43,.69),(-2.48,1.12),(-2.34,1.48),(-2.10,2.40),(-1.86,2.52),(-.54,2.52),(-.44,.73)],1.75,orange)
    box('Cab roof',(0,-1.30,2.53),(1.86,1.69,.13),orange,.07)
    # Front glazing seated on the sloped cab skin. Split by a narrow orange mullion.
    for x1,x2 in [(-.77,-.035),(.035,.77)]:
        pts=[(x1,-2.331,1.56),(x2,-2.331,1.56),(x2,-2.13,2.31),(x1,-2.13,2.31)]
        mesh('Windscreen dark interior',pts,[(0,1,2,3)],inner)
        mesh('Windscreen glass',[(x,y-.009,z) for x,y,z in pts],[(0,1,2,3)],glass)
    for s in [-1,1]:
        x=s*.882
        pts=[(x,-2.06,1.56),(x,-1.91,2.31),(x,-.69,2.31),(x,-.64,1.56)]
        mesh('Door glass backing',pts,[(0,1,2,3)],inner)
        mesh('Door glass',[(x+s*.009,y,z) for x,y,z in pts],[(0,1,2,3)],glass)
        box('Door white stripe',(s*.888,-1.37,1.35),(.014,1.5,.14),white,.005)
        box('Door handle',(s*.91,-.83,1.52),(.045,.21,.045),dark,.01)
        rod('Mirror arm',(s*.8,-1.96,1.65),(s*.985,-2.04,1.95),.023,steel)
        box('Mirror',(s*.99,-2.05,2.0),(.09,.18,.26),dark,.025)
        box('Boarding step',(s*.87,-.85,.65),(.22,.55,.1),steel,.02)
    box('Hopper lower shell',(0,1.06,1.18),(1.88,2.62,.91),orange,.11)
    profile('Hopper shoulder',[(-.24,1.4),(-.16,2.43),(.1,2.56),(2.19,2.56),(2.43,2.17),(2.43,1.4)],1.88,orange)
    box('Hopper lid',(0,1.07,2.58),(1.63,2.35,.075),orange,.045)
    for s in [-1,1]:
        box('Long white service stripe',(s*.946,1.13,1.50),(.02,2.35,.20),white,.005)
        box('Hopper service door',(s*.951,1.15,1.95),(.022,1.56,.51),orange,.035)
        for yy in [.52,.65,.78,.91,1.04]:box('Vent slot',(s*.967,yy,2.05),(.012,.045,.28),dark,.005)
        for yy in [.37,1.95]:box('Hopper latch',(s*.98,yy,1.57),(.03,.1,.27),steel,.012)
    # Four black pneumatic tyres and recessed metal wheel hubs.
    for x in [-.79,.79]:
        for y in [-1.46,1.58]:
            torus('Tyre',(x,y,.46),.335,.12,dark,20,6,(0,math.pi/2,0))
            cyl('Wheel hub',(x+(.065 if x>0 else -.065),y,.46),.255,.15,steel,16,'X')
            cyl('Hub centre',(x+(.15 if x>0 else -.15),y,.46),.115,.02,dark,12,'X')
    box('Front bumper',(0,-2.43,.72),(1.85,.18,.2),dark,.045)
    box('Front grille',(0,-2.47,1.10),(.67,.05,.31),dark,.025)
    for z in [.99,1.08,1.17]:box('Grille slat',(0,-2.504,z),(.6,.012,.016),steel,.003)
    for s in [-1,1]:
        box('Headlight surround',(s*.65,-2.415,1.16),(.34,.075,.22),white,.035)
        box('Headlight',(s*.65,-2.46,1.17),(.24,.017,.13),glow,.025)
        # Side brushes flare beyond the body, a ring of individual bristle tufts.
        cx=s*.66; cy=-2.05
        rod('Brush hydraulic arm',(s*.58,-1.5,.62),(cx,cy,.29),.052,steel)
        cyl('Brush bristle skirt',(cx,cy,.15),.37,.21,dark,24,r2=.29)
        cyl('Orange brush disc',(cx,cy,.27),.295,.065,orange,24)
        for j in range(20):
            a=2*math.pi*j/20
            rod('Bristle tuft',(cx+.255*math.cos(a),cy+.255*math.sin(a),.23),(cx+.38*math.cos(a),cy+.38*math.sin(a),.05),.017,dark,5)
    box('Beacon foot',(0,-1.1,2.63),(.35,.24,.075),dark,.025)
    cyl('Amber beacon',(0,-1.1,2.76),.115,.2,glow,16,r2=.09)
    cyl('Rear exhaust',( .63,2.01,2.67),.065,.24,steel,10)
    for yy in [-2.22,-2.28]:rod('Wiper',(-.56,yy,1.60),(-.15,yy+.04,1.94),.014,dark,6)

def bounds(ob):
    pts=[ob.matrix_world@Vector(v) for v in ob.bound_box]
    return Vector([min(p[i] for p in pts) for i in range(3)]),Vector([max(p[i] for p in pts) for i in range(3)])

def assemble(slug):
    bpy.ops.object.select_all(action='DESELECT')
    for o in PARTS:o.select_set(True)
    bpy.context.view_layer.objects.active=PARTS[0]; bpy.ops.object.convert(target='MESH'); bpy.ops.object.join(); ob=bpy.context.object; ob.name=slug; ob.data.name=slug
    bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
    lo,hi=bounds(ob); target=SPEC[slug][0]; scale=Vector((target[0]/(hi.x-lo.x),target[1]/(hi.y-lo.y),target[2]/(hi.z-lo.z)))
    c=Vector(((hi.x+lo.x)/2,(hi.y+lo.y)/2,lo.z))
    for v in ob.data.vertices:v.co=(v.co-c)*scale
    bpy.context.scene.cursor.location=(0,0,0); bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    # Recalculate consistent winding for closed solids, preserve intentionally two-sided panels.
    import bmesh
    bm=bmesh.new(); bm.from_mesh(ob.data); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bmesh.ops.triangulate(bm,faces=list(bm.faces)); bad=[f for f in bm.faces if f.calc_area()<1e-10]; bmesh.ops.delete(bm,geom=bad,context='FACES'); bm.to_mesh(ob.data); bm.free()
    bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT'); bpy.ops.uv.smart_project(angle_limit=math.radians(66),island_margin=.018); bpy.ops.object.mode_set(mode='OBJECT')
    return ob

def bake(ob,slug):
    s=bpy.context.scene; s.cycles.samples=8; s.render.bake.margin=5
    mats=list(ob.data.materials); opaque=[m for m in mats if m.name not in ('Glass','Glow')]
    originals={m.name:m.node_tree.nodes.get('Principled BSDF') for m in opaque}
    images=[]
    for kind in ['base','orm']:
        img=bpy.data.images.new(slug+'_'+kind,512,512,alpha=False)
        if kind=='orm':img.colorspace_settings.name='Non-Color'
        for m in mats:
            nt=m.node_tree; tex=nt.nodes.new('ShaderNodeTexImage'); tex.image=img; nt.nodes.active=tex
            if m not in opaque:continue
            p=originals[m.name]; out=nt.nodes.get('Material Output'); em=nt.nodes.new('ShaderNodeEmission')
            if kind=='base':
                inp=p.inputs['Base Color']
                if inp.is_linked:nt.links.new(inp.links[0].from_socket,em.inputs['Color'])
                else:em.inputs['Color'].default_value=inp.default_value
            else:em.inputs['Color'].default_value=(1,m.get('rough',.55),m.get('metal',0),1)
            nt.links.new(em.outputs[0],out.inputs['Surface'])
        bpy.context.view_layer.objects.active=ob; ob.select_set(True); bpy.ops.object.bake(type='EMIT')
        img.filepath_raw=str(OUT/(slug+'_'+kind+'.png')); img.file_format='PNG'; img.save(); images.append(img)
        for m in opaque:
            nt=m.node_tree; nt.links.new(originals[m.name].outputs[0],nt.nodes.get('Material Output').inputs[0])
    body=mat('Body',(1,1,1)); nt=body.node_tree; p=nt.nodes.get('Principled BSDF')
    t=nt.nodes.new('ShaderNodeTexImage'); t.image=images[0]; nt.links.new(t.outputs['Color'],p.inputs['Base Color'])
    t=nt.nodes.new('ShaderNodeTexImage'); t.image=images[1]; sep=nt.nodes.new('ShaderNodeSeparateColor'); nt.links.new(t.outputs['Color'],sep.inputs[0]); nt.links.new(sep.outputs['Green'],p.inputs['Roughness']); nt.links.new(sep.outputs['Blue'],p.inputs['Metallic'])
    keep=[body]+[m for m in mats if m.name in ('Glass','Glow')]
    mapping={i:(keep.index(m) if m in keep else 0) for i,m in enumerate(mats)}
    ids=[mapping[p.material_index] for p in ob.data.polygons]; ob.data.materials.clear()
    for m in keep:
        ob.data.materials.append(m)
        if m.name in ('Glass','Glow'):
            for node in list(m.node_tree.nodes):
                if node.type=='TEX_IMAGE':m.node_tree.nodes.remove(node)
    for p,i in zip(ob.data.polygons,ids):p.material_index=i
    s.cycles.samples=32

def stage(ob,side=False,water=False,wide=False):
    s=bpy.context.scene
    for o in list(s.objects):
        if o!=ob:bpy.data.objects.remove(o,do_unlink=True)
    lo,hi=bounds(ob); center=(lo+hi)/2
    ground=mat('Preview ground',(.105,.125,.14) if not water else (.045,.20,.22),.85 if not water else .26,0,'stone' if not water else 'paint')
    box('Preview ground',(0,0,-.045),(2000,2000,.08),ground,0)
    grid=mat('Scale grid',(.14,.17,.18),.9)
    for axis in [0,1]:
        for offset in [-1,1]:
            loc=[0,0,.003]; loc[axis]=offset
            size=[2.012,.012,.004] if axis==1 else [.012,2.012,.004]
            box('Two metre outline',loc,size,grid,0)
    bpy.ops.object.light_add(type='AREA',location=( -3,-4,10)); key=bpy.context.object; key.data.energy=1700; key.data.shape='DISK'; key.data.size=7
    key.rotation_euler=(Vector((0,0,0))-key.location).to_track_quat('-Z','Y').to_euler()
    bpy.ops.object.light_add(type='SUN',location=(4,1,8)); sun=bpy.context.object; sun.data.energy=1.5; sun.data.angle=.18; sun.rotation_euler=(.35,-.4,-.5)
    bpy.ops.object.camera_add(); cam=bpy.context.object; s.camera=cam; cam.data.type='ORTHO'; cam.data.clip_end=3000
    d=Vector((0,-1,.06) if ob.name.startswith('raft') else (1,0,.06)).normalized() if side else Vector((.57,-.82,math.tan(math.radians(55)))).normalized()
    cam.location=center+d*40; cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler()
    inv=cam.rotation_euler.to_matrix().transposed(); pts=[inv@(Vector(v)-center) for v in ob.bound_box]
    aspect=s.render.resolution_x/s.render.resolution_y
    cam.data.ortho_scale=max(max(p.x for p in pts)-min(p.x for p in pts),(max(p.y for p in pts)-min(p.y for p in pts))*aspect)*1.25
    if not side:cam.data.ortho_scale=max(cam.data.ortho_scale,2.7 if max(hi-lo)>1 else .8)
    if side:cam.location=center+d*max(40,cam.data.ortho_scale*22)
    s.render.film_transparent=False

def render(ob,slug,side=False,clay=False):
    stage(ob,side,slug.startswith('raft') or slug=='lily-pad')
    s=bpy.context.scene
    if clay:s.view_layers[0].material_override=mat('Clay',(.45,.45,.45),.75)
    s.render.filepath=str(OUT/(slug+('-clay' if clay else '-side' if side else '')+'.png')); bpy.ops.render.render(write_still=True)
    s.view_layers[0].material_override=None

def export_validate(ob,slug):
    bpy.ops.object.select_all(action='DESELECT'); ob.select_set(True); bpy.context.view_layer.objects.active=ob
    bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
    path=OUT/(slug+'.glb'); bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_cameras=False,export_lights=False,export_image_format='AUTO')
    bpy.ops.wm.read_factory_settings(use_empty=True); bpy.ops.import_scene.gltf(filepath=str(path))
    obs=list(bpy.context.scene.objects); assert len(obs)==1 and obs[0].type=='MESH',[(x.name,x.type) for x in obs]
    ob=obs[0];lo,hi=bounds(ob); size=[hi.x-lo.x,hi.y-lo.y,hi.z-lo.z]
    assert all(abs(a-b)<b*.1 for a,b in zip(size,SPEC[slug][0])),size
    assert abs(lo.z)<.0001 and abs(lo.x+hi.x)<.0001 and abs(lo.y+hi.y)<.0001,(list(lo),list(hi))
    assert ob.location.length<.0001,list(ob.location)
    ob.data.calc_loop_triangles();tri=len(ob.data.loop_triangles); assert tri<=SPEC[slug][1],(slug,tri)
    names=[m.name for m in ob.data.materials]; assert len(names)<=4 and names[0]=='Body',names
    assert all(n in ['Body','Glass','Glow'] for n in names),names
    assert path.stat().st_size<=SPEC[slug][2],path.stat().st_size
    report={'slug':slug,'glb':slug+'.glb','size_m':[round(v,5) for v in size],'tris':tri,'materials':names}
    (OUT/(slug+'-validation.json')).write_text(json.dumps({**report,'bytes':path.stat().st_size,'bounds_blender':[list(lo),list(hi)],'origin':list(ob.location),'mesh_objects':1,'fresh_import':True},indent=2))
    manifest=OUT/'street.json'; rows=json.loads(manifest.read_text()) if manifest.exists() else [];rows=[r for r in rows if r['slug']!=slug]+[report];rows.sort(key=lambda r:list(SPEC).index(r['slug']));manifest.write_text(json.dumps(rows,indent=2))
    print('VALIDATED',json.dumps(report),flush=True)
    # Setup render configuration again while retaining only the actual round-tripped mesh.
    # The imported object itself is the sole render subject.
    # Cannot factory reset here (would erase the mesh): explicitly configure scene.
    s=bpy.context.scene;s.render.engine='CYCLES';s.cycles.samples=32;s.cycles.use_denoising=True
    try:s.cycles.device='GPU'
    except:pass
    s.render.resolution_x=s.render.resolution_y=800;s.render.resolution_percentage=100;s.render.image_settings.file_format='PNG';s.view_settings.view_transform='AgX'
    s.world=bpy.data.worlds.new('Soft daylight');s.world.use_nodes=True;s.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.85,1);s.world.node_tree.nodes['Background'].inputs[1].default_value=.55
    global MATS;MATS={}
    render(ob,slug);render(ob,slug,side=True)
    return report

def tram():
    cream=mat('Warm cream',(.83,.77,.57),.42,.08,'paint'); teal=mat('Deep teal',(.025,.36,.33),.35,.18,'paint')
    rubber=mat('Tram rubber',(.018,.026,.028),.8); metal=mat('Tram steel',(.28,.33,.34),.34,.72)
    windows=mat('Tram window backing',(.025,.07,.085),.28,.18); glass,glow=special()
    gp=glow.node_tree.nodes.get('Principled BSDF');gp.inputs['Base Color'].default_value=(1,.88,.56,1);gp.inputs['Emission Color'].default_value=(1,.8,.42,1)
    def shell(name,sections,m):
        verts=[]
        for y,w,b,t in sections:
            c=.14; verts.extend([(-w/2+c,y,b), (w/2-c,y,b),(w/2,y,b+c),(w/2,y,t-.22),(w/2-.2,y,t),(-w/2+.2,y,t),(-w/2,y,t-.22),(-w/2,y,b+c)])
        faces=[tuple(range(7,-1,-1)),tuple(range(len(verts)-8,len(verts)))]
        for k in range(len(sections)-1):
            for i in range(8):faces.append((k*8+i,k*8+(i+1)%8,(k+1)*8+(i+1)%8,(k+1)*8+i))
        return mesh(name,verts,faces,m)
    for sign in [-1,1]:
        # Ends taper into a short raked nose; broad parallel passenger saloon.
        secs=[(.37,2.22,.42,2.79),(6.94,2.22,.42,2.79),(7.64,1.96,.45,2.66),(8,1.54,.65,2.31)]
        secs=[(sign*y,w,b,t) for y,w,b,t in secs]
        shell('Cream articulated saloon',secs,cream)
        shell('Teal low floor skirt',[(sign*y,w+.015,b,.88) for y,w,b,t in [( .4,2.22,.34,0),(6.94,2.22,.34,0),(7.67,1.95,.45,0),(8.01,1.54,.65,0)]],teal)
        shell('Teal roof cap',[(sign*y,w,2.65,t) for y,w,b,t in [( .40,2.17,0,2.90),(6.86,2.17,0,2.90),(7.5,1.90,0,2.74)]],teal)
        for x in [-1.119,1.119]:
            for y in [1.22,2.5,4.92,6.13]:
                box('Window rubber gasket',(x,sign*y,1.9),(.022,1.09,1.14),rubber,.045)
                box('Window inner dark',(x*1.009,sign*y,1.9),(.012,.99,1.04),windows,.025)
                box('Large passenger Glass',(x*1.018,sign*y,1.9),(.009,.99,1.04),glass,.025)
            # Two broad sliding doors with a centre split, both sides.
            box('Door frame',(x,sign*3.72,1.43),(.025,1.20,2.02),teal,.02)
            for off in [-.286,.286]:
                box('Door glazing',(x*1.015,sign*3.72+off,1.67),(.024,.52,1.46),windows,.025)
                box('Door Glass',(x*1.03,sign*3.72+off,1.67),(.012,.49,1.42),glass,.02)
                box('Door cream sill',(x*1.02,sign*3.72+off,.72),(.022,.52,.26),cream,.01)
            box('Door threshold',(x*1.028,sign*3.72,.40),(.075,1.19,.09),metal,.01)
        # Raked, wide driver windshield across the chamfered nose.
        pts=[(-.67,sign*8.032,1.28),(.67,sign*8.032,1.28),(.67,sign*8.032,2.21),(-.67,sign*8.032,2.21)]
        mesh('Driver window',pts,[(0,1,2,3)],windows)
        mesh('Driver Glass',[(x,y+sign*.012,z) for x,y,z in pts],[(0,1,2,3)],glass)
        rod('Driver wiper',(-.04,sign*8.057,1.32),(.4,sign*8.057,1.86),.016,rubber,6)
        for sx in [-1,1]:
            pts=[(sx*1.077,sign*7.23,1.29),(sx*.91,sign*7.80,1.29),(sx*.91,sign*7.80,2.21),(sx*1.077,sign*7.23,2.43)]
            mesh('Cab wraparound dark',pts,[(0,1,2,3)],windows)
            mesh('Cab wraparound Glass',[(x+sx*.012,y,z) for x,y,z in pts],[(0,1,2,3)],glass)
        for x in [-.58,.58]:
            box('Front light black bezel',(x,sign*7.994,1.00),(.28,.06,.18),rubber,.04)
            box('End headlight',(x,sign*8.03,1.015),(.19,.027,.08),glow,.025)
        box('Nose buffer',(0,sign*8.025,.69),(1.12,.11,.12),rubber,.035)
        for y in [1.42,6.10]:
            box('Bogie frame',(0,sign*y,.36),(1.71,1.25,.28),rubber,.04)
            for x in [-.94,.94]:
                for off in [-.36,.36]:
                    cyl('Rail wheel',(x,sign*y+off,.31),.31,.16,rubber,12,'X')
                    cyl('Wheel alloy inset',(x+( .09 if x>0 else -.09),sign*y+off,.31),.17,.025,metal,12,'X')
        box('Roof AC pod',(0,sign*4.32,2.99),(1.25,1.63,.18),teal,.07)
        for y in [3.78,3.95,4.12,4.29,4.46,4.63,4.80]:box('Roof cooling grille',(0,sign*y,3.087),(.90,.042,.012),rubber,.003)
    # Flexible accordion connector, visibly separate from the saloons.
    box('Articulation inner',(0,0,1.58),(1.89,.83,2.23),rubber,.12)
    for y in [-.30,-.20,-.10,0,.10,.20,.30]:
        for x in [-1.02,1.02]:box('Accordion side fold',(x,y,1.59),(.08,.047,2.07),rubber,.02)
        box('Accordion roof fold',(0,y,2.65),(2.05,.047,.08),rubber,.02)
    # Low diamond pantograph, readable as a diamond from the high camera.
    box('Pantograph base',(0,-1.7,2.93),(1.0,1.35,.12),rubber,.03)
    for x in [-.32,.32]:
        rod('Pantograph lower', (x,-2.17,3.0),(x,-1.55,3.34),.027,metal)
        rod('Pantograph upper', (x,-1.55,3.34),(x,-.95,3.05),.027,metal)
    rod('Collector contact',(-.73,-1.55,3.35),(.73,-1.55,3.35),.045,rubber)
    for y in [-2.17,-.95]:rod('Pantograph crossbar',(-.36,y,3.01),(.36,y,3.01),.025,metal)

def tube_path(name,points,r,m,sides=6):
    verts=[]
    for i,p in enumerate(points):
        p=Vector(p); tang=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
        tang.normalize(); up=Vector((0,0,1)) if abs(tang.z)<.9 else Vector((0,1,0)); a=tang.cross(up).normalized();b=tang.cross(a).normalized()
        verts.extend([p+r*(math.cos(j*2*math.pi/sides)*a+math.sin(j*2*math.pi/sides)*b) for j in range(sides)])
    faces=[]
    for i in range(len(points)-1):
        for j in range(sides):faces.append((i*sides+j,i*sides+(j+1)%sides,(i+1)*sides+(j+1)%sides,(i+1)*sides+j))
    faces.extend([tuple(range(sides-1,-1,-1)),tuple(range(len(verts)-sides,len(verts)))])
    return mesh(name,verts,faces,m,True)

def raft(length):
    wood=mat('Honey deck',(.40,.205,.071),.77,0,'wood'); pale=mat('Sun bleached deck',(.50,.29,.12),.8,0,'wood')
    bark=mat('Warm log bark',(.22,.105,.035),.91,0,'wood'); end=mat('Log end grain',(.47,.29,.12),.88,0,'wood')
    rope=mat('Hemp rope',(.60,.44,.23),.9,0,'wood'); nail=mat('Iron pegs',(.07,.065,.051),.58,.5)
    for y in [-.56,.56]:
        cyl('Buoyant whole log',(0,y,.12),.12,length-.05,bark,14,'X')
        for sign in [-1,1]:
            cyl('Sawn log end',(sign*(length/2-.023),y,.12),.103,.01,end,14,'X')
            torus('Growth ring',(sign*(length/2-.017),y,.12),.067,.005,bark,12 if length==4 else 8,4 if length==4 else 3,(0,math.pi/2,0))
    n=round(length/.34); step=(length-.18)/n
    for i in range(n):
        x=-length/2+.09+step*(i+.5); z=.265+random.uniform(-.007,.007)
        o=box('Individual deck plank',(x,random.uniform(-.012,.012),z),(step-.022,1.76-random.uniform(0,.045),.095),pale if i%4==0 else wood,.013)
        o.rotation_euler.z=random.uniform(-.008,.008)
    for x in [-length/2+.4,length/2-.4]:
        # Continuous rope lash passes over deck and under supporting logs.
        pts=[(x,-.76,.327),(x,.76,.327),(x,.77,.12),(x,.6,.035),(x,-.6,.035),(x,-.77,.12),(x,-.76,.327)]
        tube_path('Deck cross lashing',pts,.025,rope,6)
        # A visible knot at the bankward edge.
        torus('Lashing knot',(x,-.70,.34),.047,.017,rope,10 if length==4 else 8,5 if length==4 else 4)
    # Small rope coil gives the rafts a warm top-read without obstructing hops.
    turns=2.1 if length==4 else 2.8; points=[]
    steps=56 if length==4 else 32
    for i in range(steps):
        t=i/(steps-1); a=t*turns*2*math.pi; r=.06+.15*t
        points.append((length/2-.77+r*math.cos(a),.37+r*math.sin(a),.349))
    tube_path('Coiled mooring rope',points,.019,rope,5 if length==4 else 4)
    tube_path('Loose rope end',[(length/2-.60,.38,.35),(length/2-.45,.23,.35),(length/2-.35,.13,.345)],.019,rope,5)
    if length>4:
        # A low wooden supply crate at one edge, within the 45 cm total height.
        cx=-length/2+.8;cy=.46
        box('Low supply crate',(cx,cy,.348),(.57,.58,.19),wood,.02)
        for off in [-.18,0,.18]:box('Crate lid plank',(cx+off,cy,.451),(.167,.58,.024),pale,.004)
        for x in [cx-.21,cx+.21]:box('Crate band',(x,cy,.466),(.037,.61,.019),bark,0)
        for y in [cy-.30,cy+.30]:
            for x in [cx-.23,cx+.23]:box('Crate corner batten',(x,y,.354),(.04,.022,.20),pale,0)

def ellipsoid(name,loc,scale,m,segments=10,rings=5,rot=None):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=segments,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.scale=scale
    if rot:o.rotation_euler=rot
    return finish_part(o,name,m,True)

def lily():
    green=mat('Lily green',(.10,.35,.045),.51,0,'leaf');edge=mat('Lily veins',(.21,.46,.072),.55)
    pink=mat('Lotus rose',(.95,.12,.38),.42,0,'paint');pink2=mat('Lotus inner blush',(1,.39,.56),.42);gold=mat('Flower pollen',(.95,.55,.02),.7)
    # Radial fan with a deep V notch in the +X edge, including a closed 2 cm rim.
    outline=[(.12,0)]
    for i in range(37):
        a=.24+i*(2*math.pi-.48)/36;r=.835*(1+.013*math.sin(a*5));outline.append((r*math.cos(a),r*math.sin(a)))
    n=len(outline);verts=[(0,0,.033)]+[(x,y,.021+.006*math.sin(i*.5)) for i,(x,y) in enumerate(outline)]+[(x,y,0) for x,y in outline]
    faces=[]
    for i in range(n):
        j=(i+1)%n;faces.append((0,i+1,j+1));faces.append((i+1,n+i+1,n+j+1,j+1))
    faces.append(tuple(range(2*n,n,-1)));mesh('Notched fleshy lily leaf',verts,faces,green,True)
    for a in [.45,1.05,1.65,2.30,2.90,3.5,4.15,4.8,5.45,5.85]:
        pts=[(.04*math.cos(a),.04*math.sin(a),.035),(.35*math.cos(a),.35*math.sin(a),.034),(.71*math.cos(a+.04),.71*math.sin(a+.04),.03)]
        tube_path('Leaf radial vein',pts,.0045,edge,4)
    cx,cy=-.43,.35
    for i in range(8):
        a=i*math.pi/4
        ellipsoid('Outer rose petal',(cx+.105*math.cos(a),cy+.105*math.sin(a),.068),(.105,.045,.035),pink,8,4,(0,-.22,a))
    for i in range(5):
        a=i*2*math.pi/5+.4
        ellipsoid('Inner pink petal',(cx+.047*math.cos(a),cy+.047*math.sin(a),.095),(.065,.031,.028),pink2,8,4,(0,-.25,a))
    ellipsoid('Golden flower centre',(cx,cy,.113),(.038,.038,.016),gold,8,4)

def donut():
    dough=mat('Baked golden dough',(.59,.265,.065),.79,0,'stone');icing=mat('Pink strawberry icing',(.95,.065,.31),.32,0,'paint')
    torus('Raised dough ring',(0,0,.097),.174,.097,dough,28,10)
    # Glossy icing shell with a varying scalloped drip edge, open hole remains real geometry.
    verts=[];N=28;K=6
    for i in range(N):
        a=i*2*math.pi/N
        outer=-.13-.16*(.5+.5*math.sin(5*a+1))**4
        inner=math.pi+.10+.12*math.sin(4*a)**2
        for j in range(K):
            t=outer+(inner-outer)*j/(K-1);r=.174+.108*math.cos(t)
            verts.append((r*math.cos(a),r*math.sin(a),.100+.108*math.sin(t)))
    faces=[]
    for i in range(N):
        for j in range(K-1):faces.append((i*K+j,((i+1)%N)*K+j,((i+1)%N)*K+j+1,i*K+j+1))
    frost=mesh('Dripping pink icing',verts,faces,icing,True)
    bpy.context.view_layer.update()
    colors=[(.97,.76,.06),(.10,.65,.80),(.93,.9,.71),(.40,.12,.70),(.25,.66,.13)]
    sprinkles=[mat('Sugar '+str(i),c,.47) for i,c in enumerate(colors)]
    for i in range(23):
        a=i*2.39996323;v=.48+(i%4)*.67;r=.174+.104*math.cos(v);z=.103+.108*math.sin(v)
        cx,cy=r*math.cos(a),r*math.sin(a);angle=a+(i%3)*.9
        ends=[]
        for sign in [-1,1]:
            x=cx+sign*.014*math.cos(angle);y=cy+sign*.014*math.sin(angle)
            hit,loc,normal,idx=frost.ray_cast(Vector((x,y,1)),Vector((0,0,-1)))
            ends.append((x,y,loc.z+.003 if hit else z))
        rod('Candy sprinkle',ends[0],ends[1],.0058,sprinkles[i%5],4)

def signal():
    black=mat('Signal charcoal',(.02,.028,.027),.52,.18,'paint');pole=mat('Green black post',(.044,.064,.055),.48,.42,'paint');metal=mat('Bell bronze',(.28,.235,.13),.36,.68,'paint')
    bolt=mat('Signal fittings',(.32,.36,.35),.36,.6);glow=mat('Glow',(.8,.009,.015),.25)
    p=glow.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.005,.009,1);p.inputs['Emission Strength'].default_value=3
    box('Foundation shoe',(0,0,.06),(.25,.25,.12),black,.02)
    cyl('Upright post',(0,.035,1.20),.045,2.35,pole,12)
    box('Twin red signal housing',(0,-.02,2.18),(.50,.17,.29),black,.045)
    for x in [-.128,.128]:
        cyl('Lamp bezel',(x,-.118,2.18),.097,.055,black,20,'Y')
        cyl('Red crossing lamp',(x,-.151,2.18),.073,.013,glow,20,'Y')
        # Sun hood, open lower half so the glowing disc is visible in gameplay.
        verts=[]
        for y in [-.12,-.148]:
            for i in range(9):
                a=i*math.pi/8;verts.append((x+.101*math.cos(a),y,2.18+.101*math.sin(a)))
        mesh('Lamp rain visor',verts,[(i,i+1,i+10,i+9) for i in range(8)],black)
    rod('Bell neck',(0,.035,2.29),(0,.035,2.48),.026,metal)
    cyl('Bell flare',(0,.035,2.46),.105,.115,metal,20,r2=.041)
    torus('Bell lower lip',(0,.035,2.405),.098,.009,metal,20,5)
    ellipsoid('Bell cap',(0,.035,2.525),(.048,.048,.044),metal,12,5)
    for x in [-.075,.075]:
        for y in [-.075,.075]:cyl('Foot mounting bolt',(x,y,.125),.015,.015,bolt,6)

def bench():
    green=mat('Park green enamel',(.024,.13,.067),.42,.42,'paint');wood=mat('Oiled oak slat',(.42,.20,.062),.61,0,'wood');wood2=mat('Oak slat variation',(.49,.26,.085),.62,0,'wood');bolt=mat('Bench screws',(.13,.14,.13),.4,.62)
    for i in range(5):
        box('Seat timber',(0,-.23+i*.109,.456),(1.8,.098,.047),wood2 if i%3==0 else wood,.012)
    for i in range(3):
        box('Backrest timber',(0,.287+i*.023,.615+i*.104),(1.8,.054,.087),wood2 if i==1 else wood,.012,(-.15,0,0))
    for x in [-.66,.66]:
        # Cast-metal shaped side frames, continuous legs and reclined back upright.
        tube_path('Front leg and seat rail',[(x,-.26,.045),(x,-.24,.32),(x,-.17,.425),(x,.25,.425),(x,.34,.85)],.031,green,8)
        tube_path('Rear splayed leg',[(x,.25,.425),(x,.31,.25),(x,.37,.045)],.034,green,8)
        tube_path('Curved armrest',[(x,-.2,.43),(x,-.24,.63),(x,-.14,.68),(x,.23,.68),(x,.29,.60)],.026,green,8)
        for y in [-.26,.37]:box('Foot pad',(x,y,.026),(.17,.11,.052),green,.015)
        for z in [.615,.719,.823]:cyl('Backrest screw',(x,.255+(z-.615)*.22,z),.012,.016,bolt,6,'Y')
    rod('Underseat stretcher',(-.68,.12,.245),(.68,.12,.245),.025,green,8)

def hedge():
    leaf=mat('Boxwood foliage',(.045,.22,.026),.84,0,'leaf');leaf2=mat('Sunlit boxwood',(.074,.29,.037),.82,0,'leaf');stone=mat('Weathered pale stone',(.35,.36,.28),.88,0,'stone');soil=mat('Rich planting soil',(.063,.042,.025),1,0,'stone')
    # Low hollow kerb, open soil inset. The foliage is rounded but retains a clipped block outline.
    box('Soil inset',(0,0,.095),(1.57,1.57,.15),soil,.03)
    for y in [-.835,.835]:box('Stone kerb',(0,y,.12),(1.80,.13,.24),stone,.025)
    for x in [-.835,.835]:box('Stone kerb',(x,0,.12),(.13,1.55,.24),stone,.025)
    # Rounded-box cage with deterministic leaf-scale dimples, rather than a stack of spheres.
    verts=[];faces=[];N=8
    # six sampled cube faces, each projected to a rounded rectangular shrub.
    for axis in range(3):
        for sign in [-1,1]:
            start=len(verts)
            for i in range(N+1):
                for j in range(N+1):
                    q=[0,0,0];q[axis]=sign;other=[a for a in range(3) if a!=axis];q[other[0]]=-1+2*i/N;q[other[1]]=-1+2*j/N
                    v=Vector(q); inner=Vector([max(-.62,min(.62,a)) for a in v]);v=inner+(v-inner).normalized()*.38
                    d=.015*math.sin(i*3.8+j*4.9+axis)+.012*math.cos(i*6.3-j*2.2)
                    v+=v.normalized()*d; verts.append((v.x*.79,v.y*.79,.74+v.z*.56))
            for i in range(N):
                for j in range(N):
                    a=start+i*(N+1)+j;faces.append((a,a+1,a+N+2,a+N+1))
    o=mesh('Clipped rounded boxwood',verts,faces,leaf,True)
    o.data.materials.append(leaf2)
    for p in o.data.polygons:
        if random.random()<.20:p.material_index=1
    # Few silhouette leaves, flattened pointed diamond pairs keep the trim from looking plastic.
    for i in range(35):
        a=random.uniform(0,2*math.pi);z=random.uniform(.43,1.13);r=.77
        x,y=r*math.cos(a),r*math.sin(a)
        if abs(x)<.56:x*=1.25
        if abs(y)<.56:y*=1.25
        vv=[(x-.027,y,z),(x,y-.018,z+.026),(x+.028,y,z+.012),(x,y+.018,z-.014)]
        mesh('Edge leaf',vv,[(0,1,2),(0,2,3)],leaf2,True)

BUILDERS={'street-sweeper':sweeper,'tram':tram,'raft-2':lambda:raft(4),'raft-3':lambda:raft(6),'raft-4':lambda:raft(8),'lily-pad':lily,'donut':donut,'tram-signal':signal,'bench':bench,'hedge':hedge}
DESCS={
 'street-sweeper':('Municipal chaser with hopper, front brushes and amber beacon','orange with white stripe'),
 'tram':('Two linked modern saloons, roof pantograph and end headlights','cream with teal roof/skirt'),
 'raft-2':('4 m plank raft on two logs, lashings and rope coil','warm honey wood'),
 'raft-3':('6 m plank raft on two logs, lashings, coil and low crate','warm honey wood'),
 'raft-4':('8 m plank raft on two logs, lashings, coil and low crate','warm honey wood'),
 'lily-pad':('Notched fleshy leaf with raised veins and pink flower','rich green and pink'),
 'donut':('Ring doughnut, scalloped icing and multicolour sugar sprinkles','strawberry pink and golden dough'),
 'tram-signal':('Twin red crossing lamps sharing Glow, rain hoods and bronze bell','charcoal, red and bronze'),
 'bench':('Slatted oak seat/back, curved metal armrests and splayed legs','warm oak and dark green'),
 'hedge':('Rounded clipped shrub inside an open low stone kerb','rich green and warm grey stone')}

def write_notes():
    rows=json.loads((OUT/'street.json').read_text())
    lines=['# Hop City street and canal pack','', 'Original generic designs, authored in Blender 5.2. Metres; GLB +Y up; front +Z. Rafts run along X. Origin is the footprint centre at the lowest point.','']
    for r in rows:
        desc,col=DESCS[r['slug']];lines.append(f"- **{r['slug']}** — {desc}; {r['tris']:,} triangles; {col}.")
    lines+=['','## Build and verification','',
      '`blender -b --python-exit-code 1 --python build_street.py -- all` rebuilds every asset from an empty scene, bakes and exports, re-imports each GLB into a fresh scene, checks bounds/origin/materials/triangles/file size, and renders the actual imported mesh. A slug instead of `all` builds only that asset. `lineup` only assembles the existing previews.',
      'Each asset has one mesh, one baked Body material, and optional Glass/Glow. Body has a 512×512 RGB albedo and a 512×512 packed roughness/metallic image (R=1, G=roughness, B=metallic). The exporter binds G/B as glTF metallicRoughnessTexture. Geometry carries bevels, leaf veins, planks, brush tufts and other silhouette detail. Glass is alpha BLEND at 0.35; Glow is untextured emission.',
      'Texture provenance: original Blender procedural nodes, baked with Cycles EMIT at 8 samples and 5 px dilation. No Poly Haven texture IDs used; no external image sources. Albedo contains material colour variation, not directional lighting. The packed map contains material roughness/metallic, not a baked AO pass. No normal map is needed for this low-poly geometry.',
      'Previews: 800×800 Cycles, 32 samples with denoising, soft daylight, orthographic 55-degree game camera or low side camera. Raft side views show the long edge. The faint outline is a real 2 m square; for the close-up doughnut it extends beyond the frame. Preview lights, cameras, ground and grid are not exported.',
      '`lineup.png` is a 1600×800 contact lineup of those same game-view renders, independently framed for readable comparison; the labels give real dimensions. It is not a common-scale scene. The lineup compositor uses system Python 3 with Pillow; all modeling, baking, export and rendering use bpy.',
      'Per-piece `*-validation.json` records the fresh Blender import. `*-audit.json` is the independent serialized-GLB audit (zero degenerate triangles, finite attributes, embedded textures). Runtime three.js draw calls and FPS are not measured by this asset worker.',
      '', '## What still looks weak','',
      '- The tram is deliberately economical: dark backed glass and no passenger interior, opening doors or animated pantograph.',
      '- The longer raft rope coils and the doughnut icing are visibly polygonal in extreme close-ups; they are designed for the stated phone-scale camera.',
      '- The hedge uses opaque modeled foliage with baked colour variation, not individual alpha-cutout leaves; its leaf detail is suggestive.',
      '- Signal lamps share one Glow material as requested, so alternating each lamp independently would need a runtime material/mesh split.',
      '- The 0.45 m raft height includes accessories: the longer rafts carry shallow crates to preserve the specified total bounds.',
      '- No in-game collision meshes, motion, sounds or integration code are included; this delivery is the static asset pack.', '']
    (OUT/'NOTES.md').write_text('\n'.join(lines))

def lineup():
    import subprocess
    # Compose the actual Cycles renders; never substitute painted concept images.
    code=r'''
from PIL import Image,ImageDraw,ImageFont
from pathlib import Path
import sys,json
p=Path(sys.argv[1]);rows=json.loads((p/'street.json').read_text());out=Image.new('RGB',(1600,800),(27,34,38));d=ImageDraw.Draw(out)
try:
 f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',18);small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',13)
except OSError:f=ImageFont.load_default();small=f
for i,r in enumerate(rows):
 x=(i%5)*320;y=(i//5)*400
 im=Image.open(p/(r['slug']+'.png')).convert('RGB').resize((312,312),Image.Resampling.LANCZOS);out.paste(im,(x+4,y+4))
 d.text((x+13,y+328),r['slug'],font=f,fill=(238,239,230))
 dims=' x '.join(f'{v:g}' for v in r['size_m'])+' m'
 d.text((x+13,y+355),dims+'  |  '+str(r['tris'])+' tris',font=small,fill=(167,188,190))
 d.text((x+13,y+376),'55 degree game view',font=small,fill=(116,143,145))
out.save(p/'lineup.png')
'''
    subprocess.run(['/usr/bin/python3','-c',code,str(OUT)],check=True)

def main():
    args=sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else ['all']
    if args and args[0]=='lineup':
        write_notes();lineup();return
    slugs=list(BUILDERS) if not args or args[0]=='all' else args
    if not args or args[0]=='all':(OUT/'street.json').write_text('[]')
    for slug in slugs:
        setup();BUILDERS[slug]();ob=assemble(slug);render(ob,slug,clay=True)
        # Remove preview stage before baking.
        for o in list(bpy.context.scene.objects):
            if o!=ob:bpy.data.objects.remove(o,do_unlink=True)
        bpy.context.view_layer.objects.active=ob;ob.select_set(True);bake(ob,slug);export_validate(ob,slug)
        write_notes();print('DELIVERED',slug,flush=True)
    if len(json.loads((OUT/'street.json').read_text()))==len(SPEC):lineup()
if __name__=='__main__':main()
