"""Gulp City original street props. Blender 5.2 / Cycles; no external assets.
Run: blender -b --python-exit-code 1 --python build_props.py [-- --only bus-shelter]
No args builds all from empty scenes; --lineup renders existing validated GLBs.
Blender coordinates: Z up, front -Y. glTF: +Y up, front +Z. Units metres.
"""
import bpy, math, os, sys, json, random
from mathutils import Vector
from pathlib import Path
ROOT=Path(__file__).resolve().parent
TAU=math.tau
PARTS=[]
SIZES={'bus-shelter':(4.2,1.6,2.6),'advertising-column':(1.3,1.3,3.6),'food-cart':(2.2,1.3,2.5),'flower-stall':(3,2,2.6),'cafe-terrace':(2.4,2.4,2.4),'fountain':(5,5,2.2),'statue':(2.2,2.2,5),'playground':(4.5,3,2.6)}
COLORS={'bus-shelter':'deep teal roof, brushed steel and warm timber','advertising-column':'bottle green dome and ochre/coral posters','food-cart':'red and ivory striped umbrella, stainless counter','flower-stall':'green and ivory striped awning, colourful flowers','cafe-terrace':'cream parasol and honey rattan','fountain':'pale limestone and blue water','statue':'green patina bronze and pale carved stone','playground':'yellow and red steel, dark green rubber'}

def clear():
    global PARTS
    bpy.ops.wm.read_factory_settings(use_empty=True); PARTS=[]
    sc=bpy.context.scene; sc.unit_settings.system='METRIC'; sc.render.engine='CYCLES'
    sc.cycles.samples=24; sc.cycles.use_denoising=True
    try:
        prefs=bpy.context.preferences.addons['cycles'].preferences
        prefs.compute_device_type='CUDA'; prefs.get_devices()
        for d in prefs.devices:d.use=d.type=='CUDA'
        if any(d.type=='CUDA' for d in prefs.devices):sc.cycles.device='GPU'
    except Exception:pass
    sc.render.image_settings.file_format='PNG'; sc.render.image_settings.color_mode='RGB'
    sc.view_settings.view_transform='AgX'
    sc.world=bpy.data.worlds.new('Daylight'); sc.world.use_nodes=True
    sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.8,1)
    sc.world.node_tree.nodes['Background'].inputs[1].default_value=.55

def mat(name,color,rough=.5,metal=0,grain=35,bump=.015):
    m=bpy.data.materials.new(name); m.use_nodes=True; m.diffuse_color=(*color,1)
    m['roughness']=rough; m['metallic']=metal
    nt=m.node_tree; p=nt.nodes.get('Principled BSDF'); p.inputs['Base Color'].default_value=(*color,1); p.inputs['Roughness'].default_value=rough; p.inputs['Metallic'].default_value=metal
    tex=nt.nodes.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=grain;tex.inputs['Detail'].default_value=2
    ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.15;ramp.color_ramp.elements[1].position=.85
    ramp.color_ramp.elements[0].color=(*(v*.77 for v in color),1);ramp.color_ramp.elements[1].color=(*(min(1,v*1.1) for v in color),1)
    nt.links.new(tex.outputs['Fac'],ramp.inputs[0]); nt.links.new(ramp.outputs[0],p.inputs['Base Color'])
    b=nt.nodes.new('ShaderNodeBump'); b.inputs['Strength'].default_value=.18;b.inputs['Distance'].default_value=bump
    nt.links.new(tex.outputs['Fac'],b.inputs['Height']);nt.links.new(b.outputs['Normal'],p.inputs['Normal'])
    return m

def glass(alpha=.35):
    m=bpy.data.materials.new('Glass');m.use_nodes=True;m.diffuse_color=(.35,.64,.69,alpha)
    p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=m.diffuse_color;p.inputs['Alpha'].default_value=alpha;p.inputs['Roughness'].default_value=.16;p.inputs['Metallic'].default_value=.08
    m.surface_render_method='BLENDED';m.use_backface_culling=False
    return m

def glow():
    m=bpy.data.materials.new('Glow');m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF')
    p.inputs['Base Color'].default_value=(1,.77,.42,1);p.inputs['Emission Color'].default_value=(1,.66,.3,1);p.inputs['Emission Strength'].default_value=1.8
    return m

def add(o,name,m,smooth=False,bev=0):
    o.name=name
    if m:o.data.materials.append(m)
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    if bev:
        mod=o.modifiers.new('Real edge radius','BEVEL');mod.width=bev;mod.segments=1
        bpy.ops.object.modifier_apply(modifier=mod.name)
    if smooth:
        for f in o.data.polygons:f.use_smooth=True
    PARTS.append(o);return o

def box(name,loc,size,m,bev=.01):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=size
    return add(o,name,m,False,bev)

def cyl(name,loc,r,depth,m,n=16,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)
    return add(bpy.context.object,name,m,True)

def ball(name,loc,scale,m,n=12,rings=6):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=n,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.scale=scale
    return add(o,name,m,True)

def rod(name,a,b,r,m,n=8):
    a,b=Vector(a),Vector(b);o=cyl(name,(a+b)/2,r,(b-a).length,m,n);o.rotation_euler=(b-a).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)
    if m:me.materials.append(m)
    for f in me.polygons:f.use_smooth=smooth
    PARTS.append(o);return o

def lathe(name,profile,m,n=24):
    verts=[(r*math.cos(i*TAU/n),r*math.sin(i*TAU/n),z) for r,z in profile for i in range(n)]
    faces=[]
    for j in range(len(profile)-1):
        for i in range(n):a=j*n+i;b=j*n+(i+1)%n;faces.append((a,b,b+n,a+n))
    return mesh(name,verts,faces,m,True)

def ring(name,loc,major,minor,m,n=20,k=5):
    bpy.ops.mesh.primitive_torus_add(major_segments=n,minor_segments=k,location=loc,major_radius=major,minor_radius=minor)
    return add(bpy.context.object,name,m,True)

def join(parts,name):
    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.join();o=bpy.context.object;o.name=name
    bpy.ops.object.transform_apply(location=True,rotation=True,scale=True);return o

def tris(o):o.data.calc_loop_triangles();return len(o.data.loop_triangles)

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

def shelter():
    teal=mat('roof enamel',(.018,.22,.23),.36,.35);steel=mat('galvanized steel',(.21,.27,.28),.31,.8)
    dark=mat('rubber seals',(.018,.024,.025),.8);wood=mat('oiled timber',(.34,.19,.078),.58,grain=12)
    white=mat('poster ivory',(.78,.75,.62));coral=mat('poster coral',(.73,.17,.075));blue=mat('poster navy',(.025,.10,.16));g=glass();light=glow()
    box('opaque folded teal roof',(0,0,2.51),(4.2,1.6,.18),teal,.035)
    box('roof underside',(0,0,2.405),(4.05,1.47,.04),steel,.008)
    for x in [-1.92,0,1.92]:
        box('upright',(x,.65,1.19),(.065,.08,2.38),steel,.008)
        box('anchoring foot',(x,.65,.025),(.19,.19,.05),steel,.008)
    for x in [-.96,.96]:
        box('rear glass pane',(x,.66,1.26),(1.82,.015,2.14),g,0)
        box('lower rail',(x,.65,.2),(1.88,.07,.045),steel,.007)
        box('glass safety band',(x,.645,1.05),(1.78,.02,.025),white,0)
    for y in [-.63,.65]:box('end upright',(-1.94,y,1.2),(.07,.07,2.4),steel,.01)
    box('side glass',(-1.94,0,1.25),(.015,1.16,2.14),g,0)
    box('side sill',(-1.94,0,.18),(.07,1.28,.045),steel,.008)
    for x in [-1.1,.75]:
        box('bench leg',(x,.25,.245),(.075,.38,.49),steel,.01)
        box('bench foot',(x,.24,.045),(.29,.47,.07),steel,.01)
    for y in [.03,.17,.31,.45]:box('seat timber',(-.15,y,.52),(2.6,.115,.06),wood,.015)
    for z in [.73,.87]:box('backrest timber',(-.15,.52,z),(2.6,.065,.11),wood,.015)
    for x in [-1.38,1.08]:rod('arm rest',(x,.08,.7),(x,.47,.7),.023,steel)
    box('advert cabinet',(1.82,-.02,1.38),(.14,1.18,1.98),dark,.025)
    box('luminous surround',(1.741,-.02,1.4),(.008,1.09,1.82),light,0)
    box('poster paper',(1.73,-.02,1.4),(.012,.98,1.69),white,0)
    box('poster blue block',(1.72,-.1,1.08),(.013,.81,.73),blue,0)
    box('poster coral block',(1.712,.2,1.75),(.014,.43,.88),coral,0)
    box('poster teal block',(1.702,-.28,1.88),(.012,.40,.29),teal,0)
    # outward poster on other side, visible from the street
    box('outer poster',(1.9,-.02,1.4),(.009,1.05,1.81),white,0)
    box('outer coral',(1.908,-.14,1.65),(.009,.79,.85),coral,0)
    box('outer navy',(1.915,.15,.94),(.009,.57,.45),blue,0)

BUILDERS={'bus-shelter':shelter}

def curved_panel(name,r,z0,z1,a0,a1,m,n=5):
    verts=[]
    for z in (z0,z1):
        verts.extend((r*math.cos(a0+(a1-a0)*i/n),r*math.sin(a0+(a1-a0)*i/n),z) for i in range(n+1))
    return mesh(name,verts,[(i,i+1,i+n+2,i+n+1) for i in range(n)],m)

def advertising_column():
    green=mat('historic green enamel',(.024,.13,.085),.34,.5,grain=45,bump=.006)
    iron=mat('cast iron shadow',(.021,.035,.03),.62,.5)
    gold=mat('aged brass trim',(.35,.25,.09),.38,.65)
    paper=mat('cream poster paper',(.79,.73,.56),.88,grain=55,bump=.002)
    coral=mat('ochre red print',(.61,.145,.065),.85,bump=.001)
    blue=mat('petrol blue print',(.025,.20,.25),.85,bump=.001)
    ochre=mat('warm yellow print',(.83,.47,.09),.85,bump=.001)
    lathe('moulded cast iron foot',[(.01,0),(.57,0),(.59,.05),(.59,.13),(.54,.18),(.54,.29),(.51,.34),(.51,.47)],iron,24)
    lathe('lower green collar',[(.51,.33),(.55,.34),(.55,.4),(.51,.44),(.505,.55)],green,24)
    cyl('poster cylinder',(0,0,1.58),.503,2.12,paper,32)
    for i in range(6):
        a=i*TAU/6+.05; b=(i+1)*TAU/6-.05
        curved_panel('colour field',.505,.6,2.58,a,b,[ochre,paper,blue,paper,coral,paper][i])
        curved_panel('large abstract print',.509,1.37,2.4,a+.035,b-.10,[blue,coral,ochre,blue,ochre,coral][i])
        curved_panel('small cream rectangle',.512,1.76,2.25,a+.16,b-.045,paper,4)
        curved_panel('lower ink block',.514,.73,1.16,a+.08,b-.25,[coral,blue,paper,ochre,blue,ochre][i],4)
        rod('poster seam',(math.cos(a)*.515,math.sin(a)*.515,.54),(math.cos(a)*.515,math.sin(a)*.515,2.65),.009,green,6)
    lathe('upper cornice',[(.505,2.62),(.54,2.64),(.54,2.72),(.59,2.74),(.59,2.83),(.64,2.88),(.65,2.94),(.65,3.0),(.59,3.03)],green,24)
    lathe('brass cornice bead',[(.591,2.82),(.60,2.835),(.60,2.86),(.591,2.875)],gold,24)
    lathe('segmented dome',[(.60,3.02),(.57,3.13),(.48,3.25),(.32,3.36),(.11,3.43),(.035,3.44)],green,24)
    cyl('finial collar',(0,0,3.44),.08,.05,gold,12)
    cyl('finial stem',(0,0,3.5),.035,.12,green,12)
    ball('finial ball',(0,0,3.555),(.045,.045,.045),gold,12,6)

BUILDERS['advertising-column']=advertising_column

def umbrella(name,center,radius,zrim,zpeak,mats,sectors=12,square=False):
    # Doubled fabric with curved radial profile; visible rims are real geometry.
    cx,cy=center
    rings=[(.02,zpeak),(.34,zpeak-.055),(.72,zrim+.13),(1,zrim)]
    for i in range(sectors):
        a0=TAU*i/sectors;a1=TAU*(i+1)/sectors
        def xy(a,f):
            scale=radius/max(abs(math.cos(a)),abs(math.sin(a))) if square else radius
            return cx+scale*f*math.cos(a),cy+scale*f*math.sin(a)
        verts=[]
        for f,z in rings:
            for a in (a0,(a0+a1)/2,a1):
                x,y=xy(a,f);verts.append((x,y,z))
        count=len(verts);verts+= [(x,y,z-.014) for x,y,z in verts]
        faces=[]
        for j in range(3):
            for k in range(2):
                a=j*3+k;faces += [(a,a+3,a+4,a+1),(a+count+1,a+count+4,a+count+3,a+count)]
        for k in range(2):faces.append((9+k,10+k,10+k+count,9+k+count))
        mesh(name+' canopy stripe',verts,faces,mats[i%len(mats)],True)

def food_cart():
    red=mat('red canvas',(.66,.025,.018),.82,grain=80,bump=.006);cream=mat('ivory canvas',(.84,.80,.65),.82,grain=80,bump=.006)
    steel=mat('brushed stainless',(.47,.53,.55),.29,.8,grain=95,bump=.002)
    green=mat('cart olive enamel',(.16,.22,.14),.42,.3,grain=50,bump=.004)
    dark=mat('tyre rubber',(.015,.017,.017),.9,grain=85,bump=.004)
    gold=mat('cooked crepe',(.65,.36,.095),.86,grain=60,bump=.008)
    sausage=mat('grilled sausage',(.25,.045,.018),.65,grain=45,bump=.002)
    yellow=mat('mustard plastic',(.79,.48,.015),.55)
    box('cart body',(0,0,.67),(1.6,.93,.82),green,.035)
    box('lower frame',(0,0,.28),(1.7,1.01,.10),steel,.014)
    box('stainless countertop',(0,0,1.13),(1.78,1.12,.08),steel,.025)
    box('front raised panel',(0,-.482,.72),(1.32,.025,.47),steel,.015)
    for x in [-.63,.63]:
        rod('cart bumper',(x,-.53,.34),(x,-.53,1.05),.017,steel)
    for x in [-.52,.52]:
        for y in [-.35,.35]:
            if x<0:rod('parking foot',(x,y,.02),(x,y,.32),.027,steel)
    for y in [-.55,.55]:
        o=cyl('rubber road wheel',(.48,y,.24),.24,.11,dark,20);o.rotation_euler.x=math.pi/2
        o=cyl('wheel hub',(.48,y*1.105,.24),.14,.013,steel,16);o.rotation_euler.x=math.pi/2
        o=cyl('hub axle',(.48,y*1.13,.24),.04,.018,green,12);o.rotation_euler.x=math.pi/2
    rod('axle',(.48,-.58,.24),(.48,.58,.24),.035,steel)
    for y in [-.38,.38]:rod('push handle support',(-.77,y,1.03),(-1.17,y,1.03),.025,steel)
    rod('push handle',(-1.17,-.38,1.03),(-1.17,.38,1.03),.034,dark)
    box('fold out serving ledge',(1.01,0,1.10),(.34,.87,.045),steel,.012)
    for y in [-.3,.3]:rod('ledge brace',(.77,y,.74),(1.13,y,1.08),.015,steel)
    cyl('crepe griddle',(-.36,-.12,1.20),.24,.055,dark,24)
    cyl('crepe on plate',(-.36,-.12,1.23),.195,.008,gold,20)
    box('hot dog grill',(.32,-.1,1.205),(.49,.47,.09),dark,.015)
    for x in [.16,.28,.40]:
        rod('cooking hot dog',(x,-.27,1.264),(x,.05,1.264),.029,sausage,8)
    for x in [-.6,-.4]:
        cyl('squeeze bottle',(x,.37,1.31),.04,.24,red if x<-.5 else yellow,10,r2=.032)
        cyl('bottle nozzle',(x,.37,1.46),.018,.06,cream,8,r2=.004)
    # Circular 1.3m canopy retains the strict 1.3m-depth footprint.
    rod('umbrella pole',(0,.05,1.17),(0,.05,2.48),.024,steel,12)
    umbrella('red and ivory umbrella',(0,0),.65,2.20,2.48,[red,cream],12)
    cyl('umbrella finial',(0,0,2.49),.025,.02,steel,12)
    for i in range(6):
        a=i*TAU/6;rod('umbrella rib',(0,0,2.43),(.63*math.cos(a),.63*math.sin(a),2.185),.008,steel,6)

BUILDERS['food-cart']=food_cart

def flower_head(loc,r,m):
    x,y,z=loc;verts=[(x,y,z+.045)]
    # Five rounded lobes, four perimeter samples each; no star-shaped tips.
    for i in range(20):
        a=i*TAU/20;rr=r*([.56,.96,1,.96][i%4]);verts.append((x+rr*math.cos(a),y+rr*math.sin(a),z+.013*(i%4!=0)))
    return mesh('five rounded petals',verts,[(0,1+i,1+(i+1)%20) for i in range(20)],m,True)

def leaf(loc,a,m,length=.15):
    x,y,z=loc;d=Vector((math.cos(a),math.sin(a),.4))*length;side=Vector((-math.sin(a),math.cos(a),0))*length*.3;c=Vector(loc)
    return mesh('pointed leaf',[c,c+d*.5+side,c+d,c+d*.5-side,c+d*.5+Vector((0,0,.02))],[(0,1,4),(1,2,4),(2,3,4),(3,0,4)],m)

def flower_stall():
    wood=mat('weathered oak',(.34,.20,.095),.73,grain=18,bump=.013)
    pale=mat('end grain',(.47,.32,.16),.77,grain=25,bump=.01)
    green=mat('green canvas',(.036,.28,.095),.9,grain=90,bump=.004)
    ivory=mat('ivory canvas',(.83,.82,.65),.9,grain=90,bump=.004)
    zinc=mat('galvanized flower buckets',(.30,.37,.37),.46,.7,grain=65,bump=.004)
    foliage=mat('fresh leaf green',(.055,.20,.027),.82,grain=35,bump=.002)
    petals=[mat('flowers coral',(.83,.09,.045),.8),mat('flowers gold',(.94,.57,.018),.8),mat('flowers pink',(.62,.055,.24),.8),mat('flowers ivory',(.89,.84,.63),.8)]
    for x in [-1.35,1.35]:
        box('rear awning upright',(x,.76,1.25),(.075,.075,2.50),wood,0)
        box('front awning upright',(x,-.29,1.1),(.065,.065,2.2),wood,0)
        rod('corner brace',(x,.76,1.85),(x,.36,2.38),.026,wood,6)
    box('stall rear panel',(0,.77,.7),(2.7,.06,.85),wood,0)
    for z,y,depth in [(.32,-.72,.4),(.63,-.29,.48),(.97,.2,.57)]:
        box('tier shelf',(0,y,z),(2.72,depth,.07),pale,.012)
        for x in [-1.12,1.12]:box('tier leg',(x,y,z/2),(.085,.10,z),wood,0)
        box('tier front fascia',(0,y-depth/2,z-.065),(2.76,.04,.17),wood,0)
    for i in range(12):
        x0=-1.5+i*.25;x1=x0+.25;m=green if i%2==0 else ivory
        # solid sloping canvas, actual thickness and hanging valance
        v=[(x0,-.43,2.25),(x1,-.43,2.25),(x1,.9,2.6),(x0,.9,2.6),(x0,-.43,2.235),(x1,-.43,2.235),(x1,.9,2.585),(x0,.9,2.585)]
        mesh('striped canvas roof',v,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(3,2,6,7),(0,3,7,4),(1,5,6,2)],m)
        mesh('scalloped valance',[(x0,-.43,2.25),(x1,-.43,2.25),(x1,-.43,2.10),((x0+x1)/2,-.43,2.065),(x0,-.43,2.10)],[(0,1,2,3,4)],m)
    rod('front awning bar',(-1.45,-.43,2.21),(1.45,-.43,2.21),.025,wood,8)
    arrangements=[(-1.06,-.76,.36),(-.37,-.76,.36),(.36,-.76,.36),(1.04,-.76,.36),(-.90,-.26,.67),(0,-.26,.67),(.91,-.26,.67),(-.95,.25,1.01),(0,.25,1.01),(.94,.25,1.01)]
    for i,(x,y,z) in enumerate(arrangements):
        cyl('tapered flower bucket',(x,y,z+.14),.15,.28,zinc,6,r2=.20)
        mesh('dark bucket interior',[(x+.141*math.cos(k*TAU/8),y+.141*math.sin(k*TAU/8),z+.283) for k in range(8)],[tuple(range(8))],foliage)
        for j in range(3):
            a=(j*TAU/3)+i*.8;tip=(x+.1*math.cos(a),y+.1*math.sin(a),z+.40+.055*(j%2))
            rod('flower stem',(x,y,z+.24),tip,.009,foliage,3)
            leaf((x,y,z+.34),a,foliage,.24)
            flower_head(tip,.145,petals[(i+j//2)%4])

BUILDERS['flower-stall']=flower_stall

def tube_path(name,points,r,m,sides=6):
    verts=[];previous_u=None
    for i,p in enumerate(points):
        tangent=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
        axis=tangent.normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.01:u=axis.cross(Vector((0,1,0)))
        u.normalize()
        if previous_u is not None and u.dot(previous_u)<0:u.negate()
        previous_u=u.copy();v=axis.cross(u)
        for j in range(sides):verts.append(Vector(p)+r*(math.cos(j*TAU/sides)*u+math.sin(j*TAU/sides)*v))
    faces=[]
    for i in range(len(points)-1):
        for j in range(sides):a=i*sides+j;b=i*sides+(j+1)%sides;faces.append((a,b,b+sides,a+sides))
    faces.extend([tuple(reversed(range(sides))),tuple((len(points)-1)*sides+j for j in range(sides))])
    return mesh(name,verts,faces,m,True)

def cafe_terrace():
    cream=mat('cream parasol canvas',(.86,.78,.59),.87,grain=85,bump=.005)
    edge=mat('warm canvas binding',(.47,.34,.18),.82,grain=75,bump=.003)
    cane=mat('rattan frame',(.39,.23,.10),.48,grain=22,bump=.004)
    woven=mat('woven rattan',(.55,.37,.18),.72,grain=95,bump=.008)
    # Crossed waves bake actual fine wicker colour and rough relief.
    nt=woven.node_tree;p=nt.nodes.get('Principled BSDF');tc=nt.nodes.new('ShaderNodeTexCoord')
    wave=nt.nodes.new('ShaderNodeTexWave');wave.bands_direction='X';wave.inputs['Scale'].default_value=75
    nt.links.new(tc.outputs['Generated'],wave.inputs['Vector']);ramp=nt.nodes.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].color=(.18,.085,.026,1);ramp.color_ramp.elements[1].color=(.65,.44,.21,1)
    nt.links.new(wave.outputs['Color'],ramp.inputs[0]);nt.links.new(ramp.outputs[0],p.inputs['Base Color'])
    iron=mat('black wrought iron',(.024,.03,.026),.47,.7)
    marble=mat('bistro stone top',(.73,.69,.57),.35,grain=15,bump=.003)
    ceramic=mat('white glazed ceramic',(.89,.86,.73),.22)
    coffee=mat('coffee',(.034,.015,.006),.3)
    carafe=glass(.35)
    for i,a in enumerate([math.radians(20),math.radians(145),math.radians(260)]):
        before=len(PARTS)
        cyl('woven chair seat',(0,0,.45),.235,.045,woven,12)
        for x in [-.17,.17]:
            for y in [-.16,.16]:rod('rattan chair leg',(x*1.19,y*1.22,.015),(x,y,.45),.022,cane,6)
        for y in [-.15,.15]:rod('chair stretcher',(-.2,y,.23),(.2,y,.23),.015,cane,6)
        points=[(-.21,.14,.44),(-.24,.17,.68),(-.22,.18,.90),(-.12,.19,1.02),(.12,.19,1.02),(.22,.18,.90),(.24,.17,.68),(.21,.14,.44)]
        tube_path('bent rattan back hoop',points,.025,cane,6)
        mesh('woven curved back',[(-.21,.18,.63),(0,.21,.63),(.21,.18,.63),(-.20,.19,.89),(0,.22,.98),(.20,.19,.89)],[(0,1,4,3),(1,2,5,4)],woven)
        theta=-a;cx=.95*math.sin(a);cy=.95*math.cos(a)-.08
        for o in PARTS[before:]:
            # Transform the complete local chair (including rotated rods).
            from mathutils import Matrix
            o.matrix_world=Matrix.Translation((cx,cy,0))@Matrix.Rotation(theta,4,'Z')@o.matrix_world
    cyl('round bistro table',(0,-.10,.76),.44,.05,marble,24)
    cyl('table stem',(0,-.1,.41),.045,.67,iron,12)
    for a in [0,TAU/3,2*TAU/3]:rod('splayed table foot',(0,-.1,.13),(.36*math.cos(a),-.1+.36*math.sin(a),.03),.027,iron,8)
    cyl('parasol weighted foot',(.06,.24,.045),.25,.09,iron,16)
    rod('parasol mast',(.06,.24,.09),(.06,.24,2.38),.027,cane,12)
    umbrella('square cream parasol',(.06,.24),.88,2.10,2.39,[cream],8,True)
    cyl('parasol finial',(.06,.24,2.395),.026,.01,edge,12)
    for a in [math.pi/4,3*math.pi/4,5*math.pi/4,7*math.pi/4]:
        rod('parasol underside rib',(.06,.24,2.36),(.06+ .87*math.cos(a)*math.sqrt(2),.24+ .87*math.sin(a)*math.sqrt(2),2.09),.009,cane,6)
    cyl('saucer',(-.22,-.26,.796),.095,.016,ceramic,16)
    o=lathe('cup',[(.046,.806),(.05,.82),(.06,.90),(.047,.90),(.037,.82)],ceramic,12);o.location=(-.22,-.26,0)
    cyl('coffee surface',(-.22,-.26,.888),.046,.006,coffee,12)
    o=ring('cup handle',(-.288,-.26,.857),.031,.009,ceramic,10,4);o.rotation_euler.x=math.pi/2
    o=lathe('glass carafe',[(.07,.79),(.08,.83),(.08,.99),(.037,1.07),(.03,1.16),(.044,1.19),(.032,1.19),(.024,1.16)],carafe,12);o.location=(.19,-.04,0)

BUILDERS['cafe-terrace']=cafe_terrace

def fountain():
    stone=mat('honed limestone',(.59,.56,.45),.78,grain=32,bump=.021)
    pale=mat('worn rim limestone',(.72,.68,.55),.61,grain=44,bump=.012)
    dark=mat('wet limestone',(.27,.32,.25),.61,grain=31,bump=.012)
    water=glass(.60);water.diffuse_color=(.25,.60,.75,.60)
    p=water.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=water.diffuse_color;p.inputs['Roughness'].default_value=.13
    lathe('deep basin and sitting rim',[(.015,0),(2.34,0),(2.45,.1),(2.47,.4),(2.5,.45),(2.5,.52),(2.46,.57),(2.17,.57),(2.13,.52),(2.13,.46),(2.18,.40),(2.18,.17),(.015,.17)],pale,32)
    cyl('basin floor',(0,0,.18),2.18,.035,dark,40)
    mesh('still blue water',[(2.16*math.cos(i*TAU/40),2.16*math.sin(i*TAU/40),0.412) for i in range(40)],[tuple(range(40))],water)
    lathe('column stepped pedestal',[(.49,.20),(.49,.36),(.42,.41),(.38,.45),(.30,.46),(.27,.63),(.24,.74),(.29,.93),(.34,.99)],stone,20)
    lathe('lower carved bowl',[(.28,.96),(.42,1.01),(.66,1.10),(.85,1.23),(.87,1.29),(.83,1.34),(.77,1.32),(.62,1.2),(.38,1.11),(.23,1.09)],pale,24)
    mesh('lower bowl water',[(0.79*math.cos(i*TAU/32),0.79*math.sin(i*TAU/32),1.291) for i in range(32)],[tuple(range(32))],water)
    lathe('upper column',[(.19,1.09),(.22,1.17),(.15,1.29),(.13,1.56),(.18,1.73)],stone,24)
    lathe('upper carved bowl',[(.17,1.69),(.26,1.75),(.40,1.82),(.49,1.91),(.5,1.97),(.47,2.01),(.41,1.98),(.29,1.86),(.12,1.80)],pale,20)
    mesh('upper bowl water',[(0.45*math.cos(i*TAU/24),0.45*math.sin(i*TAU/24),1.961) for i in range(24)],[tuple(range(24))],water)
    cyl('bronze nozzle',(0,0,2.005),.047,.13,dark,12,r2=.025)
    ball('rising water',(0,0,2.115),(.034,.034,.085),water,10,6)
    for radius,z,count,drop in [(.46,1.97,6,.67),(.83,1.30,8,.89)]:
        for j in range(count):
            a=j*TAU/count+.12
            pts=[]
            for k in range(4):
                t=k/3;r=radius+.18*t;pts.append((r*math.cos(a),r*math.sin(a),z-drop*t*t))
            tube_path('falling water stream',pts,.025 if radius>.5 else .018,water,4)
    for r in [1.09,1.42]:lathe('water ripple',[(r-.015,.419),(r,.428),(r+.015,.419)],water,32)
    # Slightly recessed radial joints divide the rim into real stone segments.
    for i in range(16):
        a=i*TAU/16
        rod('rim stone joint',(2.185*math.cos(a),2.185*math.sin(a),.568),(2.45*math.cos(a),2.45*math.sin(a),.568),.005,dark,4)

BUILDERS['fountain']=fountain

def sculpt_rings(name,sections,m,n=16,fold=0):
    verts=[]
    for j,(cx,cy,z,rx,ry) in enumerate(sections):
        for i in range(n):
            a=i*TAU/n;r=1+fold*math.cos(a*6+j*.15)
            verts.append((cx+rx*math.cos(a)*r,cy+ry*math.sin(a)*r,z))
    faces=[]
    for j in range(len(sections)-1):
        for i in range(n):a=j*n+i;b=j*n+(i+1)%n;faces.append((a,b,b+n,a+n))
    faces.extend([tuple(reversed(range(n))),tuple((len(sections)-1)*n+i for i in range(n))])
    return mesh(name,verts,faces,m,True)

def statue():
    stone=mat('pale carved limestone',(.64,.60,.49),.82,grain=26,bump=.019)
    light=mat('cut stone edges',(.75,.72,.62),.77,grain=38,bump=.012)
    inset=mat('carving recess',(.42,.40,.31),.9,grain=23,bump=.015)
    bronze=mat('patinated bronze',(.085,.24,.16),.59,.7,grain=15,bump=.009)
    polished=mat('bronze edge wear',(.21,.22,.105),.38,.75,grain=24,bump=.004)
    # Patina variation remains in the single baked Body atlas.
    nt=bronze.node_tree;ramp=next(n for n in nt.nodes if n.type=='VALTORGB')
    ramp.color_ramp.elements[0].color=(.032,.064,.035,1);ramp.color_ramp.elements[1].color=(.17,.37,.26,1)
    box('bottom stone step',(0,0,.10),(2.2,2.2,.20),stone,.03)
    box('upper stone step',(0,0,.28),(1.92,1.92,.18),light,.025)
    box('plinth foot moulding',(0,0,.43),(1.60,1.6,.12),stone,.025)
    box('monument plinth',(0,0,1.015),(1.31,1.31,1.05),stone,.025)
    box('plinth top moulding',(0,0,1.58),(1.43,1.43,.10),inset,.015)
    box('pale overhanging cap',(0,0,1.70),(1.57,1.57,.14),light,.022)
    box('recessed front panel',(0,-.665,1.04),(.87,.018,.58),inset,.012)
    box('unlettered carved panel',(0,-.678,1.04),(.75,.018,.46),stone,.012)
    # Three small carved grooves suggest masonry craftsmanship without text.
    for x in [-.48,0,.48]:box('base flute',(x,-.687,.54),(.038,.023,.095),inset,0)
    box('bronze mounting slab',(0,0,1.795),(.89,.65,.05),bronze,.01)
    for x,y in [(-.19,.06),(.19,-.13)]:
        ball('sculpted boot',(x,y-.055,1.90),(.115,.245,.1),bronze,10,6)
        sculpt_rings('trouser leg',[(x,y,1.94,.10,.11),(x,y+.03,2.20,.11,.10),(x*.8,y+.04,2.46,.12,.12)],bronze,10,.025)
    sculpt_rings('long folded coat',[(0,.035,2.20,.41,.28),(-.02,.02,2.28,.43,.29),(-.025,.035,2.57,.37,.265),(-.015,.04,2.88,.31,.23),(0,.035,3.12,.285,.21),(.005,.02,3.36,.32,.23),(.015,.005,3.60,.40,.23),(.015,.0,3.71,.385,.195),(.015,0,3.79,.16,.135)],bronze,16,.045)
    # Overlapping front coat lapels and hem split, lifted from the torso.
    mesh('left coat lapel',[(-.13,-.19,3.76),(-.30,-.206,3.52),(-.12,-.246,3.29),(.025,-.224,3.51)],[(0,1,2,3)],bronze)
    mesh('right coat lapel',[(.14,-.19,3.76),(.29,-.208,3.52),(.10,-.247,3.30),(-.025,-.226,3.51)],[(0,1,2,3)],bronze)
    rod('coat front seam',(0,-.275,2.24),(0,-.229,3.37),.012,polished,6)
    for z in [2.98,3.18,3.38]:ball('coat button',(.04,-.236,z),(.023,.012,.024),polished,8,4)
    cyl('neck',(.02,0,3.85),.10,.18,bronze,12)
    sculpt_rings('sculpted head',[(.02,.0,3.9,.085,.08),(.02,-.025,3.95,.13,.12),(.02,-.015,4.08,.155,.14),(.02,.005,4.23,.15,.145),(.02,.025,4.30,.10,.10),(.02,.025,4.32,.035,.04)],bronze,12)
    mesh('brow and nose',[(-.02,-.148,4.17),(.07,-.148,4.17),(.048,-.216,4.08),(-.004,-.204,4.07),(.02,-.145,4.01)],[(0,1,2,3),(3,2,4),(0,3,4),(1,4,2)],bronze)
    eye=mat('patina facial recess',(.023,.049,.030),.7,.5)
    for x in [-.045,.085]:ball('carved eye',(x,-.143,4.17),(.026,.013,.014),eye,6,3)
    for x in [-.135,.17]:ball('ear',(x,.0,4.10),(.03,.055,.069),bronze,8,4)
    # The raised gesture is bent and asymmetric, modelled as tapered sleeves.
    tube_path('lowered coat sleeve',[(-.33,0,3.65),(-.45,-.01,3.43),(-.49,-.03,3.16),(-.39,-.07,3.0)],.115,bronze,8)
    ball('lowered hand',(-.37,-.08,2.96),(.085,.075,.13),polished,10,6)
    tube_path('raised upper sleeve',[(.32,0,3.65),(.47,-.02,3.79),(.69,-.035,4.08)],.12,bronze,8)
    tube_path('raised forearm sleeve',[(.69,-.035,4.08),(.78,-.04,4.35),(.83,-.045,4.62)],.09,bronze,8)
    ball('raised open palm',(.84,-.052,4.76),(.09,.055,.145),polished,10,6)
    for j in range(4):
        x=.785+j*.037;rod('raised fingers',(x,-.057,4.80),(x+.006,-.048,4.95-(abs(j-1.5)*.025)),.016,bronze,6)
    rod('raised thumb',(.91,-.07,4.75),(.97,-.08,4.84),.023,bronze,6)

BUILDERS['statue']=statue

def playground():
    red=mat('red powdercoat',(.66,.035,.016),.4,.38,grain=70,bump=.003)
    yellow=mat('yellow powdercoat',(.94,.57,.025),.39,.3,grain=70,bump=.003)
    silver=mat('galvanized hardware',(.44,.49,.51),.33,.82,grain=65,bump=.002)
    rubber=mat('dark green safety rubber',(.025,.09,.047),.96,grain=150,bump=.014)
    dark=mat('swing seat rubber',(.018,.021,.018),.88,grain=90,bump=.006)
    wood=mat('weathered platform',(.35,.25,.11),.73,grain=30,bump=.008)
    box('bevelled rubber safety mat',(0,0,.045),(4.5,3,.09),rubber,.04)
    # Slide tower on the left, ladder at the back and open exit at the front.
    for x in [-1.91,-.99]:
        for y in [.20,1.04]:
            rod('tower upright',(x,y,.09),(x,y,2.32),.05,red,10)
            box('tower anchor',(x,y,.125),(.18,.18,.065),silver,.012)
    for i in range(6):box('platform plank',(-1.45,.22+i*.155,1.38),(1.04,.14,.075),wood,.012)
    for x in [-1.93,-.97]:
        rod('tower upper rail',(x,.19,1.95),(x,1.04,1.95),.032,yellow,8)
        rod('tower lower rail',(x,.19,1.65),(x,1.04,1.65),.025,red,8)
        for y in [.43,.67,.88]:rod('tower baluster',(x,y,1.41),(x,y,1.95),.022,red,8)
    # Pitched, thick yellow steel canopy with red fascia.
    mesh('pitched tower roof',[(-2.08,.02,2.28),(-.82,.02,2.28),(-2.08,.62,2.60),(-.82,.62,2.60),(-2.08,1.22,2.28),(-.82,1.22,2.28),(-2.08,.02,2.25),(-.82,.02,2.25),(-2.08,.62,2.57),(-.82,.62,2.57),(-2.08,1.22,2.25),(-.82,1.22,2.25)],[(0,1,3,2),(2,3,5,4),(8,9,7,6),(10,11,9,8),(0,6,7,1),(4,5,11,10),(0,2,8,6),(2,4,10,8),(1,7,9,3),(3,9,11,5)],yellow)
    for x in [-2.08,-.82]:tube_path('red roof edge',[(x,.02,2.28),(x,.62,2.60),(x,1.22,2.28)],.025,red,6)
    for x in [-1.75,-1.15]:
        rod('ladder rail',(x,1.36,.11),(x,.95,1.55),.03,yellow,8)
    for j in range(5):
        t=(j+1)/6;rod('ladder rung',(-1.75,1.36-.41*t,.11+1.44*t),(-1.15,1.36-.41*t,.11+1.44*t),.026,silver,8)
    # Continuous smooth slide bed: short top transition, slope and flat runout.
    yz=[(.20,1.425),(.02,1.39),(-.23,1.17),(-.75,.56),(-1.11,.27),(-1.36,.24)]
    verts=[]
    for y,z in yz:verts.extend([(-1.78,y,z),(-1.12,y,z),(-1.78,y,z-.035),(-1.12,y,z-.035)])
    faces=[]
    for j in range(len(yz)-1):a=j*4;b=a+4;faces.extend([(a,b,b+1,a+1),(a+3,b+3,b+2,a+2),(a,a+2,b+2,b),(a+1,b+1,b+3,a+3)])
    faces.extend([(0,1,3,2),(20,22,23,21)]);mesh('formed stainless slide bed',verts,faces,silver,True)
    for x in [-1.81,-1.09]:
        points=[(x,y,z+.11) for y,z in yz];tube_path('red slide safety rim',points,.045,red,8)
        rod('slide support',(x,-.43,.09),(x,-.43,.85),.025,yellow,8)
    # Two-seat A-frame swing on the right.
    for x in [-.13,1.93]:
        for y in [-.96,1.0]:
            rod('A frame swing leg',(x,y,.1),(x,.02,2.34),.06,yellow,10)
            box('swing foot plate',(x,y,.12),(.20,.23,.06),silver,.014)
        rod('A frame spreader',(x,-.66,.78),(x,.67,.78),.035,red,8)
        ball('swing frame knuckle',(x,.02,2.34),(.093,.09,.09),red,10,6)
    rod('swing top beam',(-.27,.02,2.35),(2.08,.02,2.35),.075,red,12)
    for x in [.39,1.38]:
        box('curved rubber swing seat',(x,-.08,.49),(.47,.29,.065),dark,.025)
        for dx in [-.18,.18]:
            # Steel cables read cleanly at phone scale; hanging eyes are actual rings.
            rod('swing hanger',(x+dx,.02,2.29),(x+dx,-.08,.52),.012,silver,6)
            o=ring('swing hanging eye',(x+dx,.02,2.29),.04,.01,silver,8,4);o.rotation_euler.y=math.pi/2
    for x in [-1.91,-.99]:
        for y in [.20,1.04]:ball('rail bolt',(x+.045,y,1.94),(.012,.022,.022),silver,8,4)

BUILDERS['playground']=playground

def setup_light_camera(ctr,span,elevation=65,res=(800,800)):
    sc=bpy.context.scene;sc.render.resolution_x=res[0];sc.render.resolution_y=res[1];sc.render.resolution_percentage=100
    for o in list(sc.objects):
        if o.type in {'LIGHT','CAMERA'}:bpy.data.objects.remove(o,do_unlink=True)
    bpy.ops.object.light_add(type='AREA',location=(-3,-4,9));o=bpy.context.object;o.data.energy=1100;o.data.shape='DISK';o.data.size=7
    bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam
    el=math.radians(elevation);direction=Vector((.52*math.cos(el),-.854*math.cos(el),math.sin(el)))
    cam.location=Vector(ctr)+direction*30;cam.rotation_euler=(Vector(ctr)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.type='ORTHO';cam.data.ortho_scale=span
    cam.data.lens=70

def person(x,y):
    m=mat('Scale silhouette grey',(.24,.27,.28),.85,bump=.001)
    cyl('human head',(x,y,1.61),.108,.22,m,12)
    ball('human shoulder',(x,y,1.35),(.22,.105,.18),m,12,6)
    cyl('human torso',(x,y,1.14),.145,.39,m,12,r2=.18)
    ball('human pelvis',(x,y,.92),(.16,.105,.15),m,12,6)
    for s in [-1,1]:
        rod('human leg',(x+s*.09,y,.79),(x+s*.10,y-.015,.09),.065,m,8)
        box('human foot',(x+s*.10,y-.06,.04),(.13,.23,.08),m,.018)
        rod('human arm',(x+s*.22,y,1.36),(x+s*.26,y-.04,.90),.046,m,8)
    ball('human crown',(x,y,1.66),(.105,.105,.09),m,12,6)

def stage(o,slug):
    lo,hi=bounds(o);w,d,h=hi-lo
    asphalt=mat('render asphalt',(.115,.125,.135),.88,grain=180,bump=.008)
    box('render ground',(0,0,-.065),(200,200,.12),asphalt,0)
    person(float(w/2+.7),0)
    center=(.3,0,float(h*.43));span=max(float(w+2.2),float(d+2),float(h+1.6))*1.12
    setup_light_camera(center,span)
    sc=bpy.context.scene;sc.render.filepath=str(ROOT/(slug+'.png'));bpy.ops.render.render(write_still=True)
    setup_light_camera(center,span,elevation=8)
    sc.render.filepath=str(ROOT/(slug+'-side.png'));bpy.ops.render.render(write_still=True)

def bake_body(o,slug):
    sc=bpy.context.scene;sc.cycles.samples=12;sc.render.bake.margin=5
    bpy.context.view_layer.objects.active=o;o.select_set(True)
    bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.smart_project(angle_limit=math.radians(65),island_margin=.02);bpy.ops.object.mode_set(mode='OBJECT')
    src=list(o.data.materials)
    def target(name,res,noncolor=False):
        im=bpy.data.images.new(slug+'_'+name,width=res,height=res,alpha=False)
        if noncolor:im.colorspace_settings.name='Non-Color'
        for m in src:
            node=m.node_tree.nodes.new('ShaderNodeTexImage');node.image=im;m.node_tree.nodes.active=node
        return im
    normal=target('normal',256,True);bpy.ops.object.bake(type='NORMAL')
    albedo=target('basecolor',512)
    # Emit the base-colour node directly: DIFFUSE baking incorrectly darkens metals.
    for m in src:
        nt=m.node_tree;p=nt.nodes.get('Principled BSDF');out=nt.nodes.get('Material Output')
        em=nt.nodes.new('ShaderNodeEmission')
        if p.inputs['Base Color'].is_linked:nt.links.new(p.inputs['Base Color'].links[0].from_socket,em.inputs[0])
        else:em.inputs[0].default_value=p.inputs['Base Color'].default_value
        nt.links.new(em.outputs[0],out.inputs['Surface'])
    bpy.ops.object.bake(type='EMIT')
    orm=target('orm',256,True)
    for m in src:
        nt=m.node_tree;out=nt.nodes.get('Material Output');em=nt.nodes.new('ShaderNodeEmission');rgb=nt.nodes.new('ShaderNodeCombineColor');rgb.mode='RGB'
        ao=nt.nodes.new('ShaderNodeAmbientOcclusion');ao.inputs['Distance'].default_value=.5
        remap=nt.nodes.new('ShaderNodeMapRange');remap.inputs['To Min'].default_value=.42;remap.inputs['To Max'].default_value=1
        nt.links.new(ao.outputs['AO'],remap.inputs['Value']);nt.links.new(remap.outputs[0],rgb.inputs[0]);rgb.inputs[1].default_value=m['roughness'];rgb.inputs[2].default_value=m['metallic']
        nt.links.new(rgb.outputs[0],em.inputs[0]);nt.links.new(em.outputs[0],out.inputs['Surface'])
    bpy.ops.object.bake(type='EMIT')
    for im,name,fmt in [(albedo,'basecolor','JPEG'),(normal,'normal','PNG'),(orm,'orm','PNG')]:
        im.filepath_raw=str(ROOT/(slug+'-'+name+('.jpg' if fmt=='JPEG' else '.png')));im.file_format=fmt;im.save()
    # Reload JPEG so the glTF exporter embeds the compact encoded file.
    albedo=bpy.data.images.load(albedo.filepath_raw,check_existing=False)
    body=bpy.data.materials.new('Body');body.use_nodes=True;nt=body.node_tree;p=nt.nodes.get('Principled BSDF')
    tx=nt.nodes.new('ShaderNodeTexImage');tx.image=albedo;nt.links.new(tx.outputs['Color'],p.inputs['Base Color'])
    tx=nt.nodes.new('ShaderNodeTexImage');tx.image=normal;n=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(tx.outputs[0],n.inputs['Color']);nt.links.new(n.outputs[0],p.inputs['Normal'])
    tx=nt.nodes.new('ShaderNodeTexImage');tx.image=orm;sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(tx.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],p.inputs['Roughness']);nt.links.new(sep.outputs[2],p.inputs['Metallic'])
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=group;nt.links.new(sep.outputs[0],gn.inputs['Occlusion'])
    o.data.materials.clear();o.data.materials.append(body)
    for f in o.data.polygons:f.material_index=0

def build_one(slug):
    clear();random.seed(10);BUILDERS[slug]()
    bodyparts=[o for o in PARTS if o.data.materials[0].name not in {'Glass','Glow'}]
    others=[o for o in PARTS if o not in bodyparts]
    o=join(bodyparts,slug)
    limit=3500 if SIZES[slug][0]>3 else 2000
    raw=tris(o)+sum(tris(x) for x in others)
    print('SOURCE TRIANGLES',slug,raw,flush=True)
    if raw>limit:raise RuntimeError(f'{slug}: geometry {raw} > {limit}; revise geometry')
    # Clay QA: retain source materials and render override before baking.
    clay=mat('Clay review',(.4,.4,.4),.65,bump=.001);bpy.context.scene.view_layers[0].material_override=clay
    setup_light_camera((0,0,SIZES[slug][2]*.42),max(SIZES[slug])+1.5)
    bpy.context.scene.render.filepath=str(ROOT/(slug+'-clay.png'));bpy.ops.render.render(write_still=True)
    bpy.context.scene.view_layers[0].material_override=None
    bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
    bake_body(o,slug)
    if others:o=join([o]+others,slug)
    bpy.context.scene.cursor.location=(0,0,0);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    # Force every mesh to the requested envelope, preserving its ground centre.
    lo,hi=bounds(o);size=hi-lo;target=SIZES[slug];shift=Vector(((lo.x+hi.x)/2,(lo.y+hi.y)/2,lo.z))
    for v in o.data.vertices:
        v.co-=shift
        for i in range(3):v.co[i]*=target[i]/size[i]
    tri=o.modifiers.new('Export triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
    import bmesh
    bm=bmesh.new();bm.from_mesh(o.data)
    bad=[f for f in bm.faces if f.calc_area()<1e-10]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
    loose=[v for v in bm.verts if not v.link_faces]
    if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
    bm.to_mesh(o.data);bm.free();o.data.update()
    bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
    path=ROOT/(slug+'.glb')
    bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_image_format='AUTO',export_cameras=False,export_lights=False,export_extras=False)
    # Validate actual serialized artifact in a fresh scene; renders use this import.
    clear();bpy.ops.import_scene.gltf(filepath=str(path));meshes=[x for x in bpy.context.scene.objects if x.type=='MESH']
    assert len(meshes)==1,(slug,'mesh count',len(meshes));o=meshes[0]
    lo,hi=bounds(o);size=hi-lo;count=tris(o);names=[m.name for m in o.data.materials]
    assert abs(lo.z)<.0001 and abs((lo.x+hi.x)/2)<.0001 and abs((lo.y+hi.y)/2)<.0001,(lo,hi)
    assert o.location.length<.0001 and all(abs(size[i]/target[i]-1)<.1 for i in range(3)),size
    assert set(names)<= {'Body','Glass','Glow'} and 'Body' in names,names
    assert count<=limit,count
    assert path.stat().st_size<=450000,path.stat().st_size
    entry={'slug':slug,'glb':slug+'.glb','size_m':[round(v,4) for v in size],'tris':count,'materials':names}
    (ROOT/(slug+'-validation.json')).write_text(json.dumps({**entry,'bytes':path.stat().st_size,'ground_center':[round((lo.x+hi.x)/2,6),round((lo.y+hi.y)/2,6),round(lo.z,6)],'fresh_import':True},indent=2))
    stage(o,slug)
    update_manifest()
    print('DELIVERED',json.dumps(entry),'bytes',path.stat().st_size,flush=True)

def update_manifest():
    entries=[]
    for slug in SIZES:
        p=ROOT/(slug+'-validation.json')
        if p.exists():entries.append({k:v for k,v in json.loads(p.read_text()).items() if k in {'slug','glb','size_m','tris','materials'}})
    (ROOT/'props.json').write_text(json.dumps(entries,indent=2))
    lines=['# Gulp City street props','', 'Original procedural geometry and Cycles-baked PBR. No external images; Poly Haven ids: none.','Blender Z-up / front -Y exports to glTF +Y-up / front +Z. Ground-centred origin, metres.','Body: 512px base colour JPEG, 256px ORM PNG (R=AO, G=roughness, B=metalness), 256px tangent normal PNG.','Glass is alpha blend; Glow is emissive. All GLBs round-trip through fresh Blender scenes before preview rendering.','', '## Delivered']
    for e in entries:lines.append(f"- {e['slug']}: {e['tris']} triangles; {COLORS[e['slug']]}; {', '.join(e['materials'])}.")
    lines+=['','## Per-prop weaker details',
        '- bus-shelter: opaque roof necessarily hides much of the bench/glazing at 65 degrees; side view verifies the interior.',
        '- advertising-column: poster blocks are deliberately generic and fine print is omitted; dome has a visible 24-segment silhouette in close-ups.',
        '- food-cart: the circular umbrella is 1.3m across to keep the requested 1.3m depth; fine grill fittings simplify on a phone.',
        '- flower-stall: petals and foliage are stylized low-poly lobes within the 2000-triangle budget; not photoreal close-up flowers.',
        '- cafe-terrace: the required square cream canopy occludes the tableware from above; thin rattan and weave simplify at distance.',
        '- fountain: water is static alpha-blended mesh, with discrete streams rather than simulated sheets; basin has a 32-segment perimeter.',
        '- statue: generic procedural face and hands are intentionally simplified; patina and coat folds carry the distant silhouette.',
        '- playground: swing hangers are simplified steel cables, with no linked chains or moving seats.',
        '', '## Limits / weaker details','Designed for 20–80 px game silhouettes. Tiny fittings and poster designs simplify at phone scale.','Runtime game integration / hundreds-of-instances frame timing are owned by the integrating worker and are not measured here.','Transparent panes and water may need renderer-specific depth sorting. No animation or water simulation.','', '## Rebuild','`blender -b --python-exit-code 1 --python build_props.py` builds every prop and lineup.','Use `-- --only <slug>` for one clean-scene build, or `-- --lineup` for the lineup from delivered GLBs.','All previews are Cycles, 800×800, game camera 65° downward / side camera 8°, soft daylight and a 1.75m grey scale figure.']
    (ROOT/'NOTES.md').write_text('\n'.join(lines)+'\n')

def lineup():
    clear();entries=json.loads((ROOT/'props.json').read_text());x=0;objs=[]
    for e in entries:
        bpy.ops.import_scene.gltf(filepath=str(ROOT/e['glb']));o=bpy.context.selected_objects[0];o.location.x=x+e['size_m'][0]/2;objs.append(o);x+=e['size_m'][0]+1
    asphalt=mat('lineup asphalt',(.115,.125,.135),.87,grain=100,bump=.005);box('ground',(x/2,0,-.065),(200,200,.12),asphalt,0);person(-.7,0)
    # Near side-on azimuth keeps the row readable; same 65-degree elevation.
    setup_light_camera((x/2-.5,0,1.2),x+3,65,(1600,800));cam=bpy.context.scene.camera
    direction=Vector((.06,-math.cos(math.radians(65)),math.sin(math.radians(65)))).normalized();ctr=Vector((x/2-.5,0,1.2));cam.location=ctr+direction*50;cam.rotation_euler=(ctr-cam.location).to_track_quat('-Z','Y').to_euler()
    bpy.context.scene.render.filepath=str(ROOT/'lineup.png');bpy.ops.render.render(write_still=True)

if __name__=='__main__':
    args=sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else []
    if '--lineup' in args:lineup()
    elif '--only' in args:build_one(args[args.index('--only')+1])
    else:
        for slug in SIZES:build_one(slug)
        lineup()
