"""Gulp City original street props. Blender 5.2, no external assets.
Run: blender -b --python-exit-code 1 --python build_props.py -- [slug|all|lineup]
Each build starts from factory-empty state. Front is Blender -Y -> glTF +Z.
"""
import bpy, bmesh, math, os, sys, json, random, time
import numpy as np
from mathutils import Vector
from pathlib import Path
OUT=Path(__file__).resolve().parent
SPECS={
 'phone-booth':(1.05,1.05,2.45), 'newspaper-kiosk':(2.6,2.,3.2),
 'fire-hydrant':(.45,.45,.85), 'mailbox':(.55,.45,1.25),
 'trash-bin':(.6,.6,1.), 'vending-machine':(1.,.8,1.9),
 'parking-meter':(.35,.3,1.5), 'flower-planter':(1.2,1.2,1.1)}
PARTS=[]
M={}
def reset():
 bpy.ops.wm.read_factory_settings(use_empty=True)
 PARTS.clear(); M.clear(); random.seed(24)
 s=bpy.context.scene; s.render.engine='CYCLES'; s.cycles.samples=24
 s.cycles.use_denoising=True
 prefs=bpy.context.preferences.addons['cycles'].preferences
 try:
  prefs.compute_device_type='OPTIX'; prefs.get_devices()
  for d in prefs.devices: d.use=d.type!='CPU'
  s.cycles.device='GPU'
 except Exception: s.cycles.device='CPU'
 s.render.image_settings.file_format='PNG'; s.render.resolution_percentage=100
 s.view_settings.view_transform='AgX'
 s.world=bpy.data.worlds.new('Daylight'); s.world.use_nodes=True
 s.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.85,1)
 s.world.node_tree.nodes['Background'].inputs[1].default_value=.55
 s.render.film_transparent=False
 s.unit_settings.system='METRIC'

def mat(name,col,metal=.1,rough=.48,grain=.15):
 m=bpy.data.materials.new(name); m.use_nodes=True
 n=m.node_tree.nodes; l=m.node_tree.links; p=n.get('Principled BSDF')
 p.inputs['Base Color'].default_value=(*col,1); p.inputs['Metallic'].default_value=metal; p.inputs['Roughness'].default_value=rough
 tex=n.new('ShaderNodeTexNoise'); tex.inputs['Scale'].default_value=65; tex.inputs['Detail'].default_value=2
 coord=n.new('ShaderNodeTexCoord'); l.new(coord.outputs['Object'],tex.inputs['Vector'])
 ramp=n.new('ShaderNodeValToRGB'); ramp.color_ramp.elements[0].position=.12; ramp.color_ramp.elements[1].position=.88
 ramp.color_ramp.elements[0].color=(*(c*(1-grain) for c in col),1)
 ramp.color_ramp.elements[1].color=(*(min(1,c*(1+grain)) for c in col),1)
 l.new(tex.outputs['Fac'],ramp.inputs[0]); l.new(ramp.outputs[0],p.inputs['Base Color'])
 bump=n.new('ShaderNodeBump'); bump.inputs['Strength'].default_value=.16; bump.inputs['Distance'].default_value=.0012
 l.new(tex.outputs['Fac'],bump.inputs['Height']); l.new(bump.outputs['Normal'],p.inputs['Normal'])
 M[name]=m; return m

def palette():
 mat('paint',(.017,.07,.48),.35,.36)
 mat('dark',(.023,.03,.036),.35,.48)
 mat('steel',(.31,.35,.36),.8,.31)
 mat('rubber',(.007,.009,.011),0,.78)
 mat('paper',(.68,.66,.55),0,.8,.1)
 mat('cream',(.77,.75,.65),.05,.45)
 mat('red',(.55,.025,.012),.15,.42)
 mat('orange',(.93,.18,.012),.2,.4)
 mat('yellow',(.94,.59,.014),.22,.4)
 mat('green',(.013,.115,.052),.32,.42)
 mat('teal',(.012,.35,.30),.28,.37)
 mat('pink',(.8,.035,.22),0,.6)
 mat('leaf',(.045,.19,.025),0,.76,.45)
 mat('concrete',(.32,.30,.265),0,.86,.28)
 m=bpy.data.materials.new('Glass'); m.use_nodes=True
 p=m.node_tree.nodes.get('Principled BSDF'); p.inputs['Base Color'].default_value=(.55,.74,.8,.35)
 p.inputs['Alpha'].default_value=.35; p.inputs['Roughness'].default_value=.16; p.inputs['Metallic'].default_value=.05
 m.surface_render_method='BLENDED'; m.use_backface_culling=False; M['Glass']=m
 m=bpy.data.materials.new('Glow'); m.use_nodes=True
 p=m.node_tree.nodes.get('Principled BSDF'); p.inputs['Base Color'].default_value=(.8,.95,1,1)
 p.inputs['Emission Color'].default_value=(.68,.86,1,1); p.inputs['Emission Strength'].default_value=1.8; M['Glow']=m

def add(o,material):
 o.data.materials.append(M[material] if isinstance(material,str) else material); PARTS.append(o); return o

def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def apply(o):
 active(o); bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 for mod in list(o.modifiers): bpy.ops.object.modifier_apply(modifier=mod.name)

def box(name,loc,size,material,bevel=0):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.name=name; o.dimensions=size
 apply(o)
 if bevel:
  mod=o.modifiers.new('Edge radius','BEVEL'); mod.width=bevel; mod.segments=1; apply(o)
 return add(o,material)

def mesh(name,verts,faces,material,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)
 for p in me.polygons:p.use_smooth=smooth
 return add(o,material)

def lathe(name,profile,material,n=16,loc=(0,0,0)):
 verts=[]
 for r,z in profile:
  verts.extend([(loc[0]+r*math.cos(i*2*math.pi/n),loc[1]+r*math.sin(i*2*math.pi/n),loc[2]+z) for i in range(n)])
 faces=[tuple(reversed(range(n)))]
 for j in range(len(profile)-1):
  for i in range(n): faces.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
 faces.append(tuple((len(profile)-1)*n+i for i in range(n)))
 o=mesh(name,verts,faces,material)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 return o

def rod(name,a,b,r,material,n=10,r2=None):
 d=Vector(b)-Vector(a)
 o=lathe(name,[(r,0),(r if r2 is None else r2,d.length)],material,n)
 o.rotation_euler=d.to_track_quat('Z','Y').to_euler(); o.location=a; apply(o); return o

def tube(name,points,r,material,n=6):
 verts=[]
 for k,p in enumerate(points):
  tangent=Vector(points[min(k+1,len(points)-1)])-Vector(points[max(0,k-1)])
  q=tangent.to_track_quat('Z','Y')
  verts.extend([Vector(p)+q@Vector((r*math.cos(i*math.tau/n),r*math.sin(i*math.tau/n),0)) for i in range(n)])
 closed=(Vector(points[0])-Vector(points[-1])).length<1e-7
 faces=[] if closed else [tuple(reversed(range(n)))]
 for k in range(len(points)-1):
  for i in range(n): faces.append((k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i))
 if not closed:faces.append(tuple((len(points)-1)*n+i for i in range(n)))
 return mesh(name,verts,faces,material,True)

def rectloft(name,sections,material):
 verts=[]
 for z,w,d,c in sections:
  verts.extend([(x,y,z) for x,y in [(w/2-c,-d/2),(w/2,-d/2+c),(w/2,d/2-c),(w/2-c,d/2),(-w/2+c,d/2),(-w/2,d/2-c),(-w/2,-d/2+c),(-w/2+c,-d/2)]])
 faces=[tuple(reversed(range(8)))]
 for j in range(len(sections)-1):
  for i in range(8): faces.append((j*8+i,j*8+(i+1)%8,(j+1)*8+(i+1)%8,(j+1)*8+i))
 faces.append(tuple((len(sections)-1)*8+i for i in range(8)))
 return mesh(name,verts,faces,material)

def phone_booth():
 rectloft('Rubber foot and threshold',[(0,1,1,.05),(.055,1,1,.05),(.075,.95,.95,.035)],'dark')
 for x in [-.46,.46]:
  for y in [-.46,.46]: box('Slim extruded frame',(x,y,1.12),(.052,.052,2.1),'steel',.005)
 for z in [.22,1.12,2.13]:
  box('Rear rail',(0,.46,z),(.94,.035,.036),'steel',.004)
  for x in [-.46,.46]:box('Side rail',(x,0,z),(.035,.94,.036),'steel',.004)
 # Side and back glazing split at waist, leaving the full front open.
 for z,h in [(.665,.855),(1.62,.94)]:
  box('Back glass',(0,.467,z),(.876,.008,h),'Glass')
  for x in [-.467,.467]: box('Side glass',(x,0,z),(.008,.876,h),'Glass')
 for x in [-.468,.468]:box('Blue kick panel',(x,0,.14),(.022,.88,.14),'paint',.005)
 box('Back kick panel',(0,.468,.14),(.88,.022,.14),'paint',.005)
 rectloft('Cobalt roof fascia',[(2.13,1.05,1.05,.075),(2.17,1.05,1.05,.075),(2.30,1.05,1.05,.075),(2.325,.99,.99,.065)],'paint')
 box('Roof inset',(0,0,2.329),(.84,.84,.014),'paint',.018)
 box('Lamp plinth',(0,0,2.35),(.57,.26,.04),'dark',.016)
 box('Plain illuminated crown',(0,0,2.405),(.52,.22,.09),'Glow',.015)
 box('Phone stainless backplate',(0,.405,1.37),(.35,.085,.54),'steel',.025)
 box('Phone dark interface',(.055,.353,1.41),(.15,.014,.25),'dark',.007)
 box('Display',(.055,.341,1.47),(.106,.008,.043),'Glow',.002)
 for x in [.018,.056,.094]:
  for z in [1.32,1.355,1.39]:box('Key',(x,.34,z),(.021,.009,.019),'steel',.002)
 box('Coin return',(.08,.347,1.18),(.07,.015,.031),'rubber',.003)
 tube('Black handset',[(-.13,.29,1.58),(-.17,.265,1.56),(-.177,.26,1.49),(-.15,.28,1.33),(-.16,.26,1.28),(-.12,.27,1.255)],.029,'rubber',8)
 tube('Handset cable',[(-.12,.31,1.28),(-.17,.30,1.16),(-.13,.30,1.05),(-.02,.32,1.06),(.01,.34,1.13)],.009,'rubber',6)
 box('Writing shelf',(0,.265,1.015),(.64,.36,.035),'steel',.009)
 for x in [-.24,.24]:rod('Shelf bracket',(x,.43,.85),(x,.16,.99),.012,'steel',6)

def newspaper_kiosk():
 rectloft('Kiosk plinth',[(0,2.36,1.76,.16),(.12,2.36,1.76,.16),(.17,2.28,1.68,.13)],'dark')
 rectloft('Lower panelwork',[(.12,2.27,1.66,.14),(1.04,2.27,1.66,.14)],'green')
 box('Back wall',(0,.745,1.73),(2.20,.10,1.43),'green',.012)
 for x in [-1.07,1.07]:
  box('Side wall',(x,.03,1.73),(.1,1.46,1.43),'green',.012)
  box('Counter upright',(x,-.73,1.75),(.105,.1,1.48),'green',.009)
 box('Front opening lintel',(0,-.73,2.37),(2.24,.14,.21),'green',.012)
 box('Counter black lining',(0,.66,1.69),(1.93,.035,1.06),'dark')
 box('Front counter',(0,-.72,1.075),(2.35,.37,.075),'steel',.018)
 for x in [-.78,0,.78]:box('Front panel molding',(x,-.838,.60),(.68,.025,.70),'green',.012)
 # Inset poster frames on both sides, abstract collage only.
 for x in [-1.132,1.132]:
  box('Poster frame',(x,.04,1.63),(.024,1.11,1.27),'steel')
  box('Poster paper',(x*1.013,.04,1.63),(.009,1.02,1.18),'cream')
  box('Poster main field',(x*1.019,.13,1.79),(.009,.67,.64),'teal')
  box('Poster colour bar',(x*1.022,-.23,1.52),(.009,.32,.31),'orange')
  box('Poster lower block',(x*1.022,.11,1.2),(.009,.63,.16),'red')
 # Roof: a shallow elliptical dome, stepped eave and a restrained finial.
 roof=lathe('Green shallow domed roof',[(1,2.44),(1,2.53),(.96,2.62),(.82,2.79),(.58,2.94),(.27,3.025),(.07,3.05)],'green',24)
 roof.scale=(1.3,1,1); apply(roof)
 lathe('Finial',[(.055,3.03),(.068,3.07),(.042,3.105),(.04,3.15),(.014,3.2)],'green',12)
 # Folded metal/fabric awning, dipping just below the opening lintel.
 aw=box('Green awning',(0,-.79,2.30),(2.32,.44,.07),'green',.016); aw.rotation_euler.x=.12; apply(aw)
 box('Awning valance',(0,-.998,2.235),(2.31,.026,.13),'green',.008)
 for x in [-.94,.94]:rod('Awning brace',(x,-.76,1.99),(x,-.96,2.27),.012,'steel',6)
 for z in [1.35,1.69,2.02]:
  box('Magazine shelf',(0,.25,z),(1.89,.43,.025),'steel')
  for i in range(6):
   x=-.76+i*.305
   col=['paper','teal','red','cream','orange','paint'][(i+int(z*10))%6]
   o=box('Magazine cover',(x,.20,z+.135),(.255,.05,.27),col); o.rotation_euler.x=math.radians(-10); apply(o)
   box('Abstract cover band',(x,.163,z+.18),(.20,.005,.044),'paper')
 for i in range(4):
  x=-.83+i*.54
  box('Newspaper stack',(x,-.72,1.145),(.45,.29,.07),'paper',.005)
  box('Printed news block',(x-.05,-.73,1.183),(.28,.20,.005),'dark')
  box('Paper margin',(x-.05,-.73,1.188),(.024,.20,.003),'paper')

def fire_hydrant():
 lathe('Cast base and barrel',[(.20,0),(.225,.025),(.225,.065),(.145,.085),(.118,.14),(.118,.51),(.147,.53),(.147,.58),(.125,.61),(.125,.69),(.10,.765),(.065,.80)],'red',12)
 lathe('Bonnet nut',[(.032,.8),(.032,.85)],'steel',6)
 for sx in [-1,1]:
  o=lathe('Side outlet',[(.085,0),(.085,.063),(.102,.069),(.102,.091),(.075,.114)],'red',10)
  o.rotation_euler.y=sx*math.pi/2; o.location=(sx*.108,0,.46); apply(o)
  rod('Outlet hex nut',(sx*.215,0,.46),(sx*.225,0,.46),.037,'steel',6)
 # Front collar and cap; chain curls below it.
 o=lathe('Front hose outlet',[(.07,0),(.086,.035),(.086,.06),(.056,.075)],'red',12)
 o.rotation_euler.x=math.pi/2; o.location=(0,-.10,.29); apply(o)
 for a in [math.pi/4+i*math.pi/2 for i in range(4)]:
  lathe('Flange bolt',[(.017,.066),(.017,.085)],'steel',6,(.177*math.cos(a),.177*math.sin(a),0))
 # Low-poly interlocking oval links, real silhouette, not a painted chain.
 chain=[(-.165,-.069,.394),(-.145,-.096,.356),(-.11,-.126,.329),(-.075,-.15,.324),(-.04,-.168,.337)]
 for k,c in enumerate(chain):
  tangent=(Vector(chain[min(k+1,4)])-Vector(chain[max(k-1,0)])).normalized()
  cross=tangent.cross(Vector((0,1,0))).normalized()
  if k%2:cross=tangent.cross(cross).normalized()
  pts=[Vector(c)+tangent*(.029*math.cos(a))+cross*(.011*math.sin(a)) for a in [i*math.tau/6 for i in range(7)]]
  tube('Retaining chain',pts,.0038,'steel',3)

def mailbox():
 rectloft('Pedestal foot',[(0,.37,.32,.045),(.055,.37,.32,.045),(.085,.30,.26,.035)],'dark')
 box('Short pedestal',(0,.025,.28),(.14,.15,.43),'dark',.015)
 # Barrel-vault box: extrusion of an arched front profile.
 profile=[(-.275,.42),(.275,.42),(.275,1.0)]
 profile +=[(.275*math.cos(a),1.0+.25*math.sin(a)) for a in [i*math.pi/10 for i in range(1,11)]]
 verts=[(x,y,z) for y in [-.225,.225] for x,z in profile]; n=len(profile)
 faces=[tuple(reversed(range(n))),tuple(n+i for i in range(n))]
 for i in range(n):faces.append((i,(i+1)%n,(i+1)%n+n,i+n))
 o=mesh('Yellow vaulted housing',verts,faces,'yellow')
 for poly in o.data.polygons:
  poly.use_smooth=4<=poly.index<=13
 # Small single-step bevel softens the rolled steel seam within budget.
 active(o); mod=o.modifiers.new('Housing edge bevel','BEVEL'); mod.width=.008; mod.segments=1; apply(o)
 box('Inset service door',(0,-.229,.685),(.44,.012,.43),'yellow',.015)
 box('Posting slot',(0,-.239,1.008),(.365,.012,.047),'rubber',.01)
 box('Rain lip',(0,-.242,1.053),(.409,.035,.028),'yellow',.009)
 box('Collection panel',(0,-.241,.827),(.15,.008,.073),'cream',.003)
 box('Panel abstract stripe',(0,-.247,.827),(.105,.004,.012),'dark')
 rod('Key lock',(.13,-.241,.61),(.13,-.249,.61),.015,'steel',8)
 for z in [.56,.80]:box('Door hinge',(-.228,-.239,z),(.024,.02,.067),'steel',.004)

def trash_bin():
 # Faceted cylindrical steel shell with visible dark inner liner and orange rim.
 lathe('Graphite litter bin',[(.255,0),(.272,.045),(.272,.13),(.278,.84),(.29,.9)],'dark',16)
 for i in range(12):
  a=i*math.tau/12; r=.28
  rod('Vertical steel rib',(r*math.cos(a),r*math.sin(a),.15),(r*math.cos(a),r*math.sin(a),.82),.009,'steel',6)
 # Thick annulus, open center and rolled lip.
 lathe('Orange rolled ring',[(.29,.88),(.30,.90),(.30,.97),(.285,1.),(.205,1.),(.195,.97),(.195,.90)],'orange',16)
 lathe('Dark liner recess',[(.19,.86),(.19,.90)],'rubber',16)
 for i,(x,y,z) in enumerate([(-.09,.025,.921),(.07,.05,.928),(.01,-.09,.91)]):
  o=box('Discarded folded paper',(x,y,z),(.12,.075,.024),'paper',.012); o.rotation_euler=(.15*i,.25,-.4+i); apply(o)

def vending_machine():
 rectloft('Vending shell',[(.07,1,.8,.045),(1.85,1,.8,.045),(1.90,.96,.76,.035)],'teal')
 for x in [-.36,.36]:
  for y in [-.27,.27]:box('Leveling foot',(x,y,.04),(.12,.12,.08),'dark',.015)
 box('Front black inset',(-.115,-.405,1.16),(.68,.02,1.22),'dark',.025)
 box('Display interior',(-.115,-.419,1.17),(.58,.008,1.1),'dark',.008)
 for x in [-.406,.176]:box('Display light strip',(x,-.467,1.17),(.016,.008,1.085),'Glow')
 box('Display top light',(-.115,-.467,1.724),(.58,.008,.019),'Glow')
 M['Glass'].node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.12,.18,.19,.35)
 # Product cans stand proud of lit backing and behind the glass.
 for row in range(4):
  z=.75+row*.255
  box('Product shelf',(-.115,-.46,z-.082),(.59,.13,.022),'steel')
  for col in range(4):
   x=-.345+col*.15
   lathe('Drink can',[(.043,0),(.046,.014),(.046,.13),(.041,.145)],['red','orange','paint','green'][(col+row)%4],6,(x,-.459,z-.066))
   lathe('Can silver top',[(.038,.146),(.038,.151)],'steel',6,(x,-.459,z-.066))
 mesh('Front glazing',[(-.42,-.518,.625),(.19,-.518,.625),(.19,-.518,1.735),(-.42,-.518,1.735)],[(0,1,2,3)],'Glass')
 for x in [-.444,.215]:box('Glass frame',(x,-.524,1.18),(.027,.027,1.19),'steel',.004)
 for z in [.594,1.77]:box('Glass frame',(-.115,-.524,z),(.685,.027,.026),'steel',.004)
 # Deep side/top/bottom reveals enclose the products behind the front glazing.
 for x in [-.444,.215]:box('Display side enclosure',(x,-.461,1.18),(.027,.12,1.19),'teal')
 for z in [.594,1.77]:box('Display horizontal enclosure',(-.115,-.461,z),(.685,.12,.026),'teal')
 box('Payment strip',(.36,-.412,1.2),(.19,.025,.84),'dark',.015)
 box('Small display',(.36,-.433,1.51),(.13,.008,.08),'Glow',.004)
 box('Coin slot',(.36,-.435,1.34),(.025,.009,.081),'rubber',.003)
 for z in [1.17,1.08,.99]:box('Selection button',(.36,-.434,z),(.073,.012,.032),'steel',.007)
 box('Pickup recess',(-.07,-.412,.34),(.68,.014,.25),'rubber',.02)
 box('Pickup flap',(-.07,-.431,.39),(.60,.02,.135),'steel',.008)
 box('Upper teal fascia',(0,-.414,1.82),(.91,.032,.105),'teal',.012)
 # Side ventilation slots kept as shallow dark inset strips.
 for z in [.22,.27,.32,.37]:box('Vent',( .501,.13,z),(.005,.32,.012),'dark')

def parking_meter():
 rectloft('Meter foot',[(0,.26,.25,.025),(.045,.26,.25,.025),(.065,.19,.18,.022)],'dark')
 box('Meter post',(0,.025,.57),(.085,.09,1.05),'steel',.009)
 mat('slate',(.075,.16,.30),.32,.4)
 rectloft('Slate meter housing',[(.91,.31,.265,.035),(1.02,.35,.30,.04),(1.435,.35,.30,.04),(1.50,.28,.255,.033)],'slate')
 box('Control panel',(0,-.152,1.235),(.265,.012,.37),'dark',.015)
 box('LCD screen',(0,-.164,1.345),(.20,.009,.096),'Glow',.005)
 box('Screen dark data field',(0,-.17,1.34),(.12,.004,.027),'teal')
 box('Coin input',(.06,-.165,1.214),(.022,.009,.064),'rubber',.003)
 box('Confirm button',(-.068,-.165,1.214),(.048,.012,.042),'green',.01)
 box('Ticket outlet',(0,-.165,1.096),(.19,.01,.031),'rubber',.005)
 box('Ticket lip',(0,-.182,1.08),(.205,.04,.019),'steel',.004)

def flower_planter():
 mat('soil',(.055,.032,.017),0,.95,.55)
 mat('stem',(.05,.095,.015),0,.85,.3)
 # Hollow concrete tapered container and thick chamfered coping.
 rectloft('Concrete tapered planter',[(0,.96,.96,.045),(.07,.98,.98,.045),(.57,1.14,1.14,.045),(.62,1.2,1.2,.055),(.70,1.2,1.2,.055)],'concrete')
 box('Dark damp soil',(0,0,.705),(1.05,1.05,.026),'soil',.018)
 for x in [-.555,.555]:box('Concrete rim',(x,0,.713),(.09,1.17,.08),'concrete',.015)
 for y in [-.555,.555]:box('Concrete rim',(0,y,.713),(1.03,.09,.08),'concrete',.015)
 # Dense leaf rosettes: nonconvex folded leaves, not green polyhedral balls.
 for k in range(27):
  a=k*2.39996; r=.45*math.sqrt(k/27); x=r*math.cos(a); y=r*math.sin(a); z=.78+random.uniform(0,.15)
  rod('Shrub woody stem',(x*.85,y*.85,.71),(x,y,z+.04),.011,'stem',4)
  for j in range(5):
   ang=j*math.tau/5+random.random()*.4; length=random.uniform(.13,.23); dx=math.cos(ang); dy=math.sin(ang); width=.057
   verts=[(x,y,z),(x+dx*length*.5-dy*width,y+dy*length*.5+dx*width,z+.065),(x+dx*length,y+dy*length,z+.025),(x+dx*length*.5+dy*width,y+dy*length*.5-dx*width,z+.065),(x+dx*length*.5,y+dy*length*.5,z+.09)]
   mesh('Folded shrub leaf',verts,[(0,1,4),(1,2,4),(2,3,4),(3,0,4)],'leaf')
 for k in range(22):
  a=k*2.39996+.3; r=.40*math.sqrt(k/22); x=r*math.cos(a); y=r*math.sin(a); z=1.035+random.uniform(-.07,.045)
  rod('Blossom stem',(x,y,.90),(x,y,z-.015),.004,'stem',3)
  # Five-petal shallow cup; varied silhouettes are readable above the foliage.
  verts=[(x,y,z-.018)]
  for j in range(10):
   a=j*math.tau/10; rad=.061 if j%2==0 else .025
   verts.append((x+rad*math.cos(a),y+rad*math.sin(a),z+(0 if j%2==0 else -.006)))
  mesh('Pink or white blossom',verts,[(0,1+j,1+(j+1)%10) for j in range(10)],'pink' if k%3 else 'cream')
  lathe('Flower center',[(.016,z-.014),(.017,z+.002)],'yellow',6,(x,y,0))


def joined(objects,name):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0]; bpy.ops.object.join(); o=bpy.context.object; o.name=name
 apply(o); bpy.context.scene.cursor.location=(0,0,0); bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 return o

def triangulate(o):
 active(o); m=o.modifiers.new('Final triangles','TRIANGULATE'); bpy.ops.object.modifier_apply(modifier=m.name)
 bm=bmesh.new(); bm.from_mesh(o.data)
 bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
 bm.to_mesh(o.data); bm.free(); o.data.update()

def bake_body(o,slug):
 active(o); 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')
 s=bpy.context.scene; s.cycles.samples=12; s.render.bake.margin=4
 res=352 if slug=='flower-planter' else (256 if SPECS[slug][0]<1 else 384)
 mats=list(o.data.materials); images={}
 targets=[]
 for m in mats:
  node=m.node_tree.nodes.new('ShaderNodeTexImage'); m.node_tree.nodes.active=node; targets.append(node)
 def bake(kind,mode):
  im=bpy.data.images.new(slug+'_'+kind,width=res,height=res,alpha=False)
  im.colorspace_settings.name='sRGB' if kind=='basecolor' else 'Non-Color'
  for node in targets:node.image=im
  bpy.ops.object.bake(type=mode)
  images[kind]=im; return im
 s.render.bake.use_pass_direct=False; s.render.bake.use_pass_indirect=False; s.render.bake.use_pass_color=True
 color_restore=[]
 for m in mats:
  n=m.node_tree.nodes; l=m.node_tree.links; p=n.get('Principled BSDF'); out=n.get('Material Output')
  em=n.new('ShaderNodeEmission')
  if p.inputs['Base Color'].is_linked:l.new(p.inputs['Base Color'].links[0].from_socket,em.inputs[0])
  else:em.inputs[0].default_value=p.inputs['Base Color'].default_value
  l.new(em.outputs[0],out.inputs['Surface']); color_restore.append((m,p,out,em))
 bake('basecolor','EMIT')
 for m,p,out,em in color_restore:
  m.node_tree.links.new(p.outputs[0],out.inputs['Surface']); m.node_tree.nodes.remove(em)
 bake('normal','NORMAL')
 # All three packed ORM channels are Cycles emission-baked from source shaders.
 restore=[]
 for m in mats:
  n=m.node_tree.nodes; l=m.node_tree.links; out=n.get('Material Output'); p=n.get('Principled BSDF')
  restore.append((m,out,p)); comb=n.new('ShaderNodeCombineColor'); comb.mode='RGB'
  ao=n.new('ShaderNodeAmbientOcclusion'); ao.inputs['Distance'].default_value=.25
  lift=n.new('ShaderNodeMath'); lift.operation='MULTIPLY_ADD'; lift.inputs[1].default_value=.7; lift.inputs[2].default_value=.3
  l.new(ao.outputs['AO'],lift.inputs[0]); l.new(lift.outputs[0],comb.inputs['Red'])
  comb.inputs['Green'].default_value=p.inputs['Roughness'].default_value
  comb.inputs['Blue'].default_value=p.inputs['Metallic'].default_value
  em=n.new('ShaderNodeEmission'); l.new(comb.outputs[0],em.inputs[0]); l.new(em.outputs[0],out.inputs['Surface'])
 bake('orm','EMIT')
 for m,out,p in restore:m.node_tree.links.new(p.outputs[0],out.inputs['Surface'])
 for kind,im in images.items():
  im.filepath_raw=str(OUT/(slug+'_'+kind+'.png')); im.file_format='PNG'; im.save(); im.pack()
 m=bpy.data.materials.new('Body'); m.use_nodes=True
 n=m.node_tree.nodes; l=m.node_tree.links; p=n.get('Principled BSDF')
 for kind,im in images.items():
  tex=n.new('ShaderNodeTexImage'); tex.image=im
  if kind=='basecolor':l.new(tex.outputs['Color'],p.inputs['Base Color'])
  elif kind=='normal':
   nm=n.new('ShaderNodeNormalMap'); l.new(tex.outputs[0],nm.inputs['Color']); l.new(nm.outputs[0],p.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor'); sep.mode='RGB'; l.new(tex.outputs[0],sep.inputs[0]); l.new(sep.outputs['Green'],p.inputs['Roughness']); l.new(sep.outputs['Blue'],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=n.new('ShaderNodeGroup'); gn.node_tree=group; l.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 o.data.materials.clear(); o.data.materials.append(m)
 for p in o.data.polygons:p.material_index=0
 return o

def human(x,y=0):
 start=len(PARTS)
 if 'human' not in M:mat('human',(.19,.205,.22),0,.85,0)
 # neutral featureless human silhouette, soles at zero, head at 1.75 m
 for sx in [-1,1]:
  rod('Human calf',(x+sx*.09,y,.08),(x+sx*.095,y,.48),.065,'human',10,r2=.075)
  rod('Human thigh',(x+sx*.095,y,.48),(x+sx*.085,y,.91),.078,'human',10,r2=.092)
  box('Human shoe',(x+sx*.09,y-.045,.055),(.135,.245,.11),'human',.03)
  rod('Human upper arm',(x+sx*.19,y,1.38),(x+sx*.25,y,1.13),.055,'human',10,r2=.043)
  rod('Human forearm',(x+sx*.25,y,1.13),(x+sx*.27,y-.025,.94),.043,'human',10,r2=.036)
 torso=rectloft('Human torso',[(.85,.27,.20,.065),(1.02,.28,.20,.07),(1.35,.39,.23,.085),(1.43,.28,.18,.055)],'human'); torso.location=(x,y,0)
 rod('Human neck',(x,y,1.4),(x,y,1.54),.055,'human',12)
 bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(x,y,1.63)); head=bpy.context.object; head.scale=(.095,.10,.12); apply(head); add(head,'human')
 for p in head.data.polygons:p.use_smooth=True
 return PARTS[start:]

def studio():
 s=bpy.context.scene
 s.render.engine='CYCLES'; s.cycles.device='GPU'; s.cycles.use_denoising=True
 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,.72,.85,1)
 s.world.node_tree.nodes['Background'].inputs[1].default_value=.55
 mat('asphalt',(.115,.125,.135),0,.9,.18)
 box('Asphalt',(0,0,-.065),(200,200,.12),'asphalt')
 bpy.ops.object.light_add(type='AREA',location=(-3,-4,8)); key=bpy.context.object; key.data.energy=1500; key.data.shape='DISK'; key.data.size=5
 key.rotation_euler=(Vector((0,0,0))-key.location).to_track_quat('-Z','Y').to_euler()
 bpy.ops.object.light_add(type='SUN',location=(0,0,8)); sun=bpy.context.object; sun.data.energy=1.4; sun.data.angle=.22; sun.rotation_euler=(.4,-.45,-.4)
 bpy.ops.object.camera_add(); s.camera=bpy.context.object; s.camera.data.type='ORTHO'; s.camera.data.lens=70
 s.render.resolution_x=800; s.render.resolution_y=800; s.cycles.samples=32

def frame(objects,elevation=65,azimuth=32,aspect=1,pad=1.2):
 pts=[o.matrix_world@Vector(c) for o in objects for c in o.bound_box]
 lo=Vector([min(v[i] for v in pts) for i in range(3)]); hi=Vector([max(v[i] for v in pts) for i in range(3)])
 target=(lo+hi)/2; el=math.radians(elevation); az=math.radians(azimuth)
 direction=Vector((math.sin(az)*math.cos(el),-math.cos(az)*math.cos(el),math.sin(el)))
 c=bpy.context.scene.camera; c.location=target+direction*25; c.rotation_euler=(-direction).to_track_quat('-Z','Y').to_euler()
 q=c.rotation_euler.to_quaternion().inverted(); coords=[q@(v-target) for v in pts]
 width=max(v.x for v in coords)-min(v.x for v in coords); height=max(v.y for v in coords)-min(v.y for v in coords)
 c.data.ortho_scale=max(width,height*aspect)*pad
 # Center projected bounds, rather than the perspective-biased 3D box.
 shift=Vector(((max(v.x for v in coords)+min(v.x for v in coords))/2,(max(v.y for v in coords)+min(v.y for v in coords))/2,0))
 c.location+=c.rotation_euler.to_quaternion()@shift

def render(path):
 bpy.context.scene.render.filepath=str(path); bpy.ops.render.render(write_still=True)

def validate(slug):
 path=OUT/(slug+'.glb'); bpy.ops.wm.read_factory_settings(use_empty=True)
 bpy.ops.import_scene.gltf(filepath=str(path)); objs=list(bpy.context.scene.objects)
 assert len(objs)==1 and objs[0].type=='MESH',[(o.name,o.type) for o in objs]
 o=objs[0]; coords=[o.matrix_world@v.co for v in o.data.vertices]
 lo=[min(p[i] for p in coords) for i in range(3)]; hi=[max(p[i] for p in coords) for i in range(3)]
 size=[hi[i]-lo[i] for i in range(3)]; want=SPECS[slug]
 assert all(abs(size[i]/want[i]-1)<.1 for i in range(3)),(size,want)
 assert abs(lo[2])<.0001 and abs(hi[0]+lo[0])<.0001 and abs(hi[1]+lo[1])<.0001,(lo,hi)
 assert o.location.length<1e-6
 o.data.calc_loop_triangles(); tris=len(o.data.loop_triangles); limit=900 if want[0]<1 else 2000
 assert tris<=limit,(tris,limit)
 names=[m.name for m in o.data.materials]; assert set(names)<= {'Body','Glass','Glow'} and 'Body' in names,names
 assert path.stat().st_size<=450000,path.stat().st_size
 for m in o.data.materials:
  for n in m.node_tree.nodes:
   if n.type=='TEX_IMAGE':assert max(n.image.size)<=512
 data=dict(slug=slug,glb=slug+'.glb',size_m=[round(v,5) for v in size],tris=tris,materials=names,bytes=path.stat().st_size,bounds_blender=[lo,hi],origin=list(o.location))
 (OUT/(slug+'-validation.json')).write_text(json.dumps(data,indent=2)); print('VALIDATED',json.dumps(data),flush=True)
 return o,data

def build(slug):
 from datetime import datetime
 from zoneinfo import ZoneInfo
 print('TIME',datetime.now(ZoneInfo('Europe/Paris')).isoformat(),'BUILD',slug,flush=True)
 t=time.time(); reset(); palette(); BUILDERS[slug]()
 # Bake only opaque parts; transparent walls must not occlude the atlas.
 special=[o for o in PARTS if o.data.materials[0].name in ['Glass','Glow']]
 body=joined([o for o in PARTS if o not in special],slug)
 # Normalize the authored footprint bounds; origin remains ground-centered.
 allobjs=[body]+special; coords=[o.matrix_world@v.co for o in allobjs for v in o.data.vertices]
 lo=Vector([min(v[i] for v in coords) for i in range(3)]); hi=Vector([max(v[i] for v in coords) for i in range(3)])
 shift=Vector(((lo.x+hi.x)/2,(lo.y+hi.y)/2,lo.z))
 factors=Vector([SPECS[slug][i]/(hi[i]-lo[i]) for i in range(3)])
 for o in allobjs:
  inv=o.matrix_world.inverted()
  for v in o.data.vertices:
   p=o.matrix_world@v.co-shift
   v.co=inv@Vector([p[i]*factors[i] for i in range(3)])
 # Clay gate: render source silhouette with a neutral material override.
 before=set(bpy.context.scene.objects)
 humans=human(-SPECS[slug][0]/2-.6,.05); studio(); bpy.context.view_layer.update()
 clay=mat('Clay',(.3,.3,.3),0,.7,0)
 bpy.context.view_layer.material_override=clay
 frame(allobjs+humans); render(OUT/(slug+'-clay.png'))
 bpy.context.view_layer.material_override=None
 for ob in set(bpy.context.scene.objects)-before:bpy.data.objects.remove(ob,do_unlink=True)
 active(body)
 body=bake_body(body,slug); asset=joined([body]+special,slug); triangulate(asset)
 active(asset)
 bpy.ops.export_scene.gltf(filepath=str(OUT/(slug+'.glb')),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_animations=False,export_cameras=False,export_lights=False,export_image_format='AUTO')
 asset,data=validate(slug)
 # Render the delivered round-tripped GLB, not the procedural source.
 PARTS.clear(); M.clear(); humans=human(-SPECS[slug][0]/2-.6,.05); studio(); bpy.context.view_layer.update()
 frame([asset]+humans); render(OUT/(slug+'.png'))
 for h in humans:h.location+=Vector((SPECS[slug][0]/2+.6,-SPECS[slug][1]/2-.65,0))
 bpy.context.view_layer.update()
 frame([asset]+humans,elevation=8,azimuth=90); render(OUT/(slug+'-side.png'))
 print('DELIVERED',slug,'seconds',round(time.time()-t),flush=True)
 manifest(); return data

def manifest():
 rows=[]
 for slug in SPECS:
  p=OUT/(slug+'-validation.json')
  if p.exists():
   d=json.loads(p.read_text()); rows.append({k:d[k] for k in ['slug','glb','size_m','tris','materials']})
 (OUT/'props.json').write_text(json.dumps(rows,indent=2)+'\n')

def lineup():
 reset(); assets=[]; x=0
 for slug in SPECS:
  if not (OUT/(slug+'.glb')).exists():continue
  bpy.ops.import_scene.gltf(filepath=str(OUT/(slug+'.glb')))
  o=bpy.context.selected_objects[0]; w=SPECS[slug][0]; o.location.x=x+w/2; x+=w+.55; assets.append(o)
 people=human(-.65); studio(); s=bpy.context.scene; s.render.resolution_x=1600; s.render.resolution_y=800
 bpy.context.view_layer.update(); frame(assets+people,65,32,2,1.13); render(OUT/'lineup.png')

BUILDERS={slug:globals()[slug.replace('-', '_')] for slug in SPECS}
if __name__=='__main__':
 args=sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else ['all']
 for arg in args:
  if arg=='lineup':lineup()
  elif arg=='all':
   for slug in SPECS:build(slug)
   lineup()
  else:build(arg)
