# -*- coding: utf-8 -*-
"""
Cyberpunk rainy-night street — fully procedural Blender scene generator.
Run from an empty/any scene:  blender -b -P build_scene.py
Everything (geometry, materials, world sky, rain, animation) is generated here.
No external models / HDRIs / textures.  Only a system font (Noto Sans CJK) is used for 3D text.
Blender 4.5 LTS, Cycles.
"""
import bpy, bmesh, math, random, os, sys
from mathutils import Vector, Matrix, Euler, Quaternion

SEED = 20260930
rng = random.Random(SEED)
OUT = '/workspace/blender-cyberpunk'
BLEND_PATH = os.path.join(OUT, 'cyberpunk_street.blend')
FONT_PATH = '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
FPS, F_START, F_END = 24, 1, 240

# layout constants (street runs along +Y; X across)
ROAD_HALF = 6.0          # carriageway x in [-6, 6]
WALK_OUT = 11.8          # sidewalk slab outer edge |x|
STREET_Y0, STREET_Y1 = -36.0, 36.0   # detailed street block (~72 m of buildings, 60 m core)
DECK_Z0, DECK_Z1 = 6.3, 6.9          # station platform slab
GUIDE_Z0, GUIDE_Z1 = 5.9, 6.5        # maglev guideway beam
STATION_Y = 14.0                     # platform y in [-14, 14]

# ----------------------------------------------------------------------------- reset
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.name = 'CyberpunkStreet'

COLL = {}
def make_coll(name, parent=None):
    c = bpy.data.collections.new(name)
    (parent or scene.collection).children.link(c)
    COLL[name] = c
    return c
for cname in ['Buildings', 'Signs', 'Station', 'Train', 'Street', 'Props', 'Rain', 'People',
              'Vehicles', 'Lights', 'Cameras', 'Atmosphere', 'Background']:
    make_coll(cname)

# ----------------------------------------------------------------------------- node helpers
def sock(sockets, name, stype=None):
    for s in sockets:
        if s.name == name and (stype is None or s.type == stype) and s.enabled:
            return s
    for s in sockets:
        if s.name == name and (stype is None or s.type == stype):
            return s
    raise KeyError(name)

def nd(nt, ntype, loc=None, **kw):
    n = nt.nodes.new(ntype)
    for k, v in kw.items():
        if hasattr(n, k) and not k[0].isupper():
            setattr(n, k, v)
        else:
            n.inputs[k].default_value = v
    return n

def L(nt, a, b):
    nt.links.new(a, b)

def math_n(nt, op, a, b=None, clamp=False):
    n = nt.nodes.new('ShaderNodeMath'); n.operation = op; n.use_clamp = clamp
    for i, v in enumerate([a, b]):
        if v is None: continue
        if isinstance(v, (int, float)): n.inputs[i].default_value = v
        else: L(nt, v, n.inputs[i])
    return n.outputs[0]

def mix_rgb(nt, fac, a, b, blend='MIX'):
    n = nt.nodes.new('ShaderNodeMix'); n.data_type = 'RGBA'; n.blend_type = blend
    fs = sock(n.inputs, 'Factor', 'VALUE')
    if isinstance(fac, (int, float)): fs.default_value = fac
    else: L(nt, fac, fs)
    for nm, v in (('A', a), ('B', b)):
        s = sock(n.inputs, nm, 'RGBA')
        if isinstance(v, tuple): s.default_value = v if len(v) == 4 else (*v, 1)
        else: L(nt, v, s)
    return sock(n.outputs, 'Result', 'RGBA')

def mix_f(nt, fac, a, b):
    n = nt.nodes.new('ShaderNodeMix'); n.data_type = 'FLOAT'
    for nm, v in (('Factor', fac), ('A', a), ('B', b)):
        s = sock(n.inputs, nm, 'VALUE')
        if isinstance(v, (int, float)): s.default_value = v
        else: L(nt, v, s)
    return sock(n.outputs, 'Result', 'VALUE')

def ramp(nt, fac, stops):
    n = nt.nodes.new('ShaderNodeValToRGB')
    els = n.color_ramp.elements
    while len(els) > len(stops): els.remove(els[-1])
    while len(els) < len(stops): els.new(0.5)
    for e, (p, c) in zip(els, stops):
        e.position = p; e.color = c if len(c) == 4 else (*c, 1)
    L(nt, fac, n.inputs['Fac'])
    return n

M = {}
def new_mat(name):
    m = bpy.data.materials.new(name); m.use_nodes = True
    nt = m.node_tree; nt.nodes.clear()
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    M[name] = m
    return m, nt, out

def principled(nt, base=(0.5, 0.5, 0.5), metal=0.0, rough=0.5, **kw):
    p = nt.nodes.new('ShaderNodeBsdfPrincipled')
    if isinstance(base, tuple): p.inputs['Base Color'].default_value = (*base[:3], 1)
    else: L(nt, base, p.inputs['Base Color'])
    p.inputs['Metallic'].default_value = metal
    if isinstance(rough, (int, float)): p.inputs['Roughness'].default_value = rough
    else: L(nt, rough, p.inputs['Roughness'])
    for k, v in kw.items():
        if isinstance(v, (int, float)): p.inputs[k].default_value = v
        elif isinstance(v, tuple): p.inputs[k].default_value = (*v[:3], 1) if len(p.inputs[k].default_value) == 4 else v
        else: L(nt, v, p.inputs[k])
    return p

def simple_mat(name, base, metal=0.0, rough=0.5, **kw):
    m, nt, out = new_mat(name)
    p = principled(nt, base, metal, rough, **kw)
    L(nt, p.outputs[0], out.inputs['Surface'])
    return m

def emit_mat(name, color, strength, sampling='AUTO'):
    m, nt, out = new_mat(name)
    e = nd(nt, 'ShaderNodeEmission')
    e.inputs['Color'].default_value = (*color, 1); e.inputs['Strength'].default_value = strength
    L(nt, e.outputs[0], out.inputs['Surface'])
    m.cycles.emission_sampling = sampling
    return m

def texco(nt):
    return nd(nt, 'ShaderNodeTexCoord')

def noise(nt, vec, scale, detail=2.0, rough=0.5):
    n = nd(nt, 'ShaderNodeTexNoise')
    n.inputs['Scale'].default_value = scale; n.inputs['Detail'].default_value = detail
    n.inputs['Roughness'].default_value = rough
    if vec is not None: L(nt, vec, n.inputs['Vector'])
    return n

def facade_uv(nt):
    """(x+y, z, 0) — a planar coordinate that works for faces aligned to X or Y."""
    tc = texco(nt); sep = nd(nt, 'ShaderNodeSeparateXYZ'); L(nt, tc.outputs['Object'], sep.inputs[0])
    u = math_n(nt, 'ADD', sep.outputs[0], sep.outputs[1])
    c = nd(nt, 'ShaderNodeCombineXYZ'); L(nt, u, c.inputs[0]); L(nt, sep.outputs[2], c.inputs[1])
    return c.outputs[0], tc, sep

# ----------------------------------------------------------------------------- MATERIALS
def build_materials():
    # 1 wet asphalt with puddle roughness mask
    m, nt, out = new_mat('M_WetAsphalt')
    tc = texco(nt)
    n1 = noise(nt, tc.outputs['Object'], 0.075, 3.0, 0.55)
    n2 = noise(nt, tc.outputs['Object'], 0.55, 2.0, 0.5)
    pr = mix_f(nt, 0.3, n1.outputs['Fac'], n2.outputs['Fac'])
    pud = ramp(nt, pr, [(0.515, (0, 0, 0)), (0.565, (1, 1, 1))])
    grain = noise(nt, tc.outputs['Object'], 38.0, 10.0, 0.65)
    gcol = ramp(nt, grain.outputs['Fac'], [(0.3, (0.018, 0.018, 0.02)), (0.7, (0.055, 0.053, 0.056))])
    base = mix_rgb(nt, pud.outputs['Color'], gcol.outputs['Color'], (0.006, 0.006, 0.008, 1))
    rd = nd(nt, 'ShaderNodeMapRange'); L(nt, grain.outputs['Fac'], rd.inputs['Value'])
    rd.inputs['To Min'].default_value = 0.32; rd.inputs['To Max'].default_value = 0.72
    rough = mix_f(nt, pud.outputs['Color'], rd.outputs[0], 0.015)
    inv = math_n(nt, 'SUBTRACT', 1.0, pud.outputs['Color'])
    bs = math_n(nt, 'MULTIPLY', inv, 0.35)
    bump = nd(nt, 'ShaderNodeBump'); bump.inputs['Distance'].default_value = 0.02
    L(nt, bs, bump.inputs['Strength']); L(nt, grain.outputs['Fac'], bump.inputs['Height'])
    p = principled(nt, base, 0.0, rough, **{'Specular IOR Level': 0.6})
    L(nt, bump.outputs[0], p.inputs['Normal']); L(nt, p.outputs[0], out.inputs['Surface'])

    # 2 sidewalk tiles (wet concrete pavers)
    m, nt, out = new_mat('M_SidewalkTiles')
    tc = texco(nt)
    br = nd(nt, 'ShaderNodeTexBrick'); L(nt, tc.outputs['Object'], br.inputs['Vector'])
    br.inputs['Scale'].default_value = 1.6; br.inputs['Mortar Size'].default_value = 0.012
    br.inputs['Color1'].default_value = (0.075, 0.075, 0.08, 1); br.inputs['Color2'].default_value = (0.11, 0.105, 0.105, 1)
    br.inputs['Mortar'].default_value = (0.02, 0.02, 0.02, 1); br.offset = 0.5
    n1 = noise(nt, tc.outputs['Object'], 0.35, 3.0, 0.55)
    pud = ramp(nt, n1.outputs['Fac'], [(0.56, (0, 0, 0)), (0.62, (1, 1, 1))])
    base = mix_rgb(nt, pud.outputs['Color'], br.outputs['Color'], (0.03, 0.03, 0.035, 1))
    r0 = mix_f(nt, br.outputs['Fac'], 0.4, 0.8)
    rough = mix_f(nt, pud.outputs['Color'], r0, 0.03)
    bump = nd(nt, 'ShaderNodeBump'); bump.inputs['Strength'].default_value = 0.4; bump.inputs['Distance'].default_value = 0.01
    inv = math_n(nt, 'SUBTRACT', 1.0, br.outputs['Fac']); L(nt, inv, bump.inputs['Height'])
    p = principled(nt, base, 0.0, rough); L(nt, bump.outputs[0], p.inputs['Normal'])
    L(nt, p.outputs[0], out.inputs['Surface'])

    # 3 curb concrete
    m, nt, out = new_mat('M_CurbConcrete')
    tc = texco(nt); n = noise(nt, tc.outputs['Object'], 6.0, 6.0, 0.6)
    c = ramp(nt, n.outputs['Fac'], [(0.3, (0.13, 0.13, 0.13)), (0.7, (0.24, 0.235, 0.23))])
    p = principled(nt, c.outputs['Color'], 0.0, 0.28); L(nt, p.outputs[0], out.inputs['Surface'])

    # 4 road paint (wet)
    m, nt, out = new_mat('M_RoadPaint')
    tc = texco(nt); n = noise(nt, tc.outputs['Object'], 3.0, 5.0, 0.6)
    wear = ramp(nt, n.outputs['Fac'], [(0.35, (0.05, 0.05, 0.05)), (0.55, (0.75, 0.75, 0.72))])
    p = principled(nt, wear.outputs['Color'], 0.0, 0.18); L(nt, p.outputs[0], out.inputs['Surface'])
    simple_mat('M_RoadPaintYellow', (0.6, 0.38, 0.02), 0.0, 0.2)

    # 5 building concrete (object color tinted, vertical rain streaks)
    for name, rough0 in (('M_BldgConcrete', 0.55), ('M_BldgPlaster', 0.4)):
        m, nt, out = new_mat(name)
        tc = texco(nt); oi = nd(nt, 'ShaderNodeObjectInfo')
        mp = nd(nt, 'ShaderNodeMapping'); mp.inputs['Scale'].default_value = (2.5, 2.5, 0.12)
        L(nt, tc.outputs['Object'], mp.inputs['Vector'])
        st = noise(nt, mp.outputs[0], 3.0, 6.0, 0.6)
        streak = ramp(nt, st.outputs['Fac'], [(0.35, (0.35, 0.35, 0.35)), (0.7, (1, 1, 1))])
        g = noise(nt, tc.outputs['Object'], 12.0, 8.0, 0.6)
        base = mix_rgb(nt, 1.0, oi.outputs['Color'], streak.outputs['Color'], 'MULTIPLY')
        r = mix_f(nt, st.outputs['Fac'], 0.25, rough0 + 0.25)
        bump = nd(nt, 'ShaderNodeBump'); bump.inputs['Strength'].default_value = 0.25
        L(nt, g.outputs['Fac'], bump.inputs['Height'])
        p = principled(nt, base, 0.0, r); L(nt, bump.outputs[0], p.inputs['Normal'])
        L(nt, p.outputs[0], out.inputs['Surface'])

    # 6 metal cladding panels
    m, nt, out = new_mat('M_BldgMetalPanel')
    uv, tc, sep = facade_uv(nt); oi = nd(nt, 'ShaderNodeObjectInfo')
    br = nd(nt, 'ShaderNodeTexBrick'); L(nt, uv, br.inputs['Vector'])
    br.inputs['Scale'].default_value = 0.55; br.inputs['Mortar Size'].default_value = 0.015
    br.inputs['Color1'].default_value = (1, 1, 1, 1); br.inputs['Color2'].default_value = (0.7, 0.7, 0.75, 1)
    br.inputs['Mortar'].default_value = (0.1, 0.1, 0.1, 1); br.offset = 0.0; br.squash = 1.0
    br.inputs['Brick Width'].default_value = 1.2; br.inputs['Row Height'].default_value = 0.6
    base = mix_rgb(nt, 1.0, oi.outputs['Color'], br.outputs['Color'], 'MULTIPLY')
    r = mix_f(nt, br.outputs['Fac'], 0.3, 0.7)
    p = principled(nt, base, 0.75, r); L(nt, p.outputs[0], out.inputs['Surface'])

    # 7 ceramic tile facade
    m, nt, out = new_mat('M_BldgTile')
    uv, tc, sep = facade_uv(nt); oi = nd(nt, 'ShaderNodeObjectInfo')
    br = nd(nt, 'ShaderNodeTexBrick'); L(nt, uv, br.inputs['Vector'])
    br.inputs['Scale'].default_value = 6.0; br.inputs['Mortar Size'].default_value = 0.02
    br.inputs['Color1'].default_value = (1, 1, 1, 1); br.inputs['Color2'].default_value = (0.85, 0.85, 0.85, 1)
    br.inputs['Mortar'].default_value = (0.25, 0.25, 0.25, 1)
    base = mix_rgb(nt, 1.0, oi.outputs['Color'], br.outputs['Color'], 'MULTIPLY')
    r = mix_f(nt, br.outputs['Fac'], 0.12, 0.6)
    p = principled(nt, base, 0.0, r); L(nt, p.outputs[0], out.inputs['Surface'])

    # 8 dark curtain-wall glass (facade of glass towers)
    simple_mat('M_BldgGlassDark', (0.01, 0.015, 0.02), 0.6, 0.08)
    # 9 windows (face attributes lit/tint/bright generated by script)
    m, nt, out = new_mat('M_Window')
    a_lit = nd(nt, 'ShaderNodeAttribute', attribute_name='lit')
    a_tint = nd(nt, 'ShaderNodeAttribute', attribute_name='tint')
    a_br = nd(nt, 'ShaderNodeAttribute', attribute_name='bright')
    col = ramp(nt, a_tint.outputs['Fac'], [(0.0, (1.0, 0.3, 0.06)), (0.3, (1.0, 0.55, 0.22)),
                                           (0.5, (1.0, 0.78, 0.5)), (0.66, (0.45, 0.7, 1.0)),
                                           (0.8, (0.3, 0.5, 1.0)), (0.9, (1.0, 0.15, 0.65)), (1.0, (0.05, 0.85, 1.0))])
    tc = texco(nt); nn = noise(nt, tc.outputs['Object'], 1.3, 3.0, 0.5)
    var = ramp(nt, nn.outputs['Fac'], [(0.3, (0.25, 0.25, 0.25)), (0.7, (1, 1, 1))])
    s1 = math_n(nt, 'MULTIPLY', a_lit.outputs['Fac'], a_br.outputs['Fac'])
    s2 = math_n(nt, 'MULTIPLY', s1, var.outputs['Color'])
    p = principled(nt, (0.01, 0.012, 0.018), 0.0, 0.06, **{'Specular IOR Level': 0.8})
    L(nt, col.outputs['Color'], p.inputs['Emission Color']); L(nt, s2, p.inputs['Emission Strength'])
    L(nt, p.outputs[0], out.inputs['Surface'])
    m.cycles.emission_sampling = 'NONE'   # thousands of small emitters: rely on BSDF hits, cheaper

    # 10 background skyline (procedural window grid in shader)
    m, nt, out = new_mat('M_BgSkyline')
    uv, tc, sep = facade_uv(nt)
    su = nd(nt, 'ShaderNodeSeparateXYZ'); L(nt, uv, su.inputs[0])
    cu = math_n(nt, 'DIVIDE', su.outputs[0], 1.7); cv = math_n(nt, 'DIVIDE', su.outputs[1], 3.3)
    fu = math_n(nt, 'FRACT', cu); fv = math_n(nt, 'FRACT', cv)
    mu = math_n(nt, 'MULTIPLY', math_n(nt, 'GREATER_THAN', fu, 0.28), math_n(nt, 'LESS_THAN', fu, 0.72))
    mv = math_n(nt, 'MULTIPLY', math_n(nt, 'GREATER_THAN', fv, 0.35), math_n(nt, 'LESS_THAN', fv, 0.75))
    cc = nd(nt, 'ShaderNodeCombineXYZ')
    L(nt, math_n(nt, 'FLOOR', cu), cc.inputs[0]); L(nt, math_n(nt, 'FLOOR', cv), cc.inputs[1])
    wn = nd(nt, 'ShaderNodeTexWhiteNoise'); wn.noise_dimensions = '3D'; L(nt, cc.outputs[0], wn.inputs['Vector'])
    big = noise(nt, tc.outputs['Object'], 0.03, 1.0, 0.5)
    thr = math_n(nt, 'ADD', math_n(nt, 'MULTIPLY', big.outputs['Fac'], 0.5), 0.5)
    lit = math_n(nt, 'GREATER_THAN', wn.outputs['Value'], thr)
    geo = nd(nt, 'ShaderNodeNewGeometry'); gs = nd(nt, 'ShaderNodeSeparateXYZ'); L(nt, geo.outputs['Normal'], gs.inputs[0])
    side = math_n(nt, 'LESS_THAN', math_n(nt, 'ABSOLUTE', gs.outputs[2]), 0.5)
    msk = math_n(nt, 'MULTIPLY', math_n(nt, 'MULTIPLY', mu, mv), math_n(nt, 'MULTIPLY', lit, side))
    tint = ramp(nt, sock(wn.outputs, 'Color'), [(0.0, (1.0, 0.35, 0.08)), (0.45, (1.0, 0.65, 0.35)), (0.75, (0.45, 0.7, 1.0)), (1.0, (1.0, 0.2, 0.7))])
    p = principled(nt, (0.015, 0.016, 0.02), 0.2, 0.35)
    L(nt, tint.outputs['Color'], p.inputs['Emission Color']); L(nt, math_n(nt, 'MULTIPLY', msk, math_n(nt, 'MULTIPLY', wn.outputs['Value'], 2.2)), p.inputs['Emission Strength'])
    L(nt, p.outputs[0], out.inputs['Surface'])
    m.cycles.emission_sampling = 'NONE'

    # metals / glass / misc
    simple_mat('M_DarkSteel', (0.03, 0.032, 0.035), 1.0, 0.32)
    simple_mat('M_Chrome', (0.8, 0.8, 0.82), 1.0, 0.07)
    simple_mat('M_RustyMetal', (0.12, 0.06, 0.035), 0.7, 0.55)
    simple_mat('M_RoofConcrete', (0.05, 0.05, 0.052), 0.0, 0.75)
    simple_mat('M_Shutter', (0.12, 0.12, 0.13), 0.8, 0.45)
    simple_mat('M_Glass', (0.85, 0.92, 1.0), 0.0, 0.02, **{'Transmission Weight': 1.0, 'IOR': 1.45})
    simple_mat('M_GlassFrosted', (0.7, 0.8, 0.9), 0.0, 0.12, **{'Transmission Weight': 1.0, 'IOR': 1.45})
    simple_mat('M_BlackRubber', (0.01, 0.01, 0.01), 0.0, 0.6)
    simple_mat('M_SignBacking', (0.02, 0.02, 0.025), 0.3, 0.35)
    simple_mat('M_Awning', (0.25, 0.03, 0.05), 0.0, 0.3)
    simple_mat('M_TactileYellow', (0.7, 0.45, 0.02), 0.0, 0.3)
    simple_mat('M_PlatformFloor', (0.12, 0.125, 0.13), 0.2, 0.15)
    simple_mat('M_StationConcrete', (0.18, 0.18, 0.185), 0.0, 0.5)
    # manhole cover: metal with radial rings (wave texture)
    m, nt, out = new_mat('M_Manhole')
    tc = texco(nt); w = nd(nt, 'ShaderNodeTexWave'); w.wave_type = 'RINGS'
    w.inputs['Scale'].default_value = 6.0; L(nt, tc.outputs['Object'], w.inputs['Vector'])
    c = ramp(nt, w.outputs['Fac'], [(0.45, (0.02, 0.02, 0.02)), (0.55, (0.12, 0.11, 0.1))])
    bump = nd(nt, 'ShaderNodeBump'); bump.inputs['Strength'].default_value = 0.6; L(nt, w.outputs['Fac'], bump.inputs['Height'])
    p = principled(nt, c.outputs['Color'], 0.9, 0.3); L(nt, bump.outputs[0], p.inputs['Normal']); L(nt, p.outputs[0], out.inputs['Surface'])
    # hazard stripes
    m, nt, out = new_mat('M_HazardStripes')
    tc = texco(nt); w = nd(nt, 'ShaderNodeTexWave'); w.wave_profile = 'SAW'
    w.inputs['Scale'].default_value = 2.0; w.inputs['Distortion'].default_value = 0.0
    w.bands_direction = 'DIAGONAL'; L(nt, tc.outputs['Object'], w.inputs['Vector'])
    c = ramp(nt, w.outputs['Fac'], [(0.5, (0.8, 0.45, 0.0)), (0.505, (0.02, 0.02, 0.02))])
    c.color_ramp.interpolation = 'CONSTANT'
    p = principled(nt, c.outputs['Color'], 0.0, 0.3); L(nt, p.outputs[0], out.inputs['Surface'])
    # object-colored plastics (props vary via object color)
    for name, rough in (('M_PaintedPlastic', 0.35), ('M_PaintedMetal', 0.25)):
        m, nt, out = new_mat(name)
        oi = nd(nt, 'ShaderNodeObjectInfo')
        p = principled(nt, oi.outputs['Color'], 0.5 if 'Metal' in name else 0.0, rough, **{'Coat Weight': 0.5})
        L(nt, p.outputs[0], out.inputs['Surface'])
    # lamp glow = object colour * strength
    m, nt, out = new_mat('M_LampGlow')
    oi = nd(nt, 'ShaderNodeObjectInfo'); e = nd(nt, 'ShaderNodeEmission'); e.inputs['Strength'].default_value = 25.0
    L(nt, oi.outputs['Color'], e.inputs['Color']); L(nt, e.outputs[0], out.inputs['Surface'])
    # vending machine front: product grid, tinted by object colour
    m, nt, out = new_mat('M_VendingFront')
    tc = texco(nt); oi = nd(nt, 'ShaderNodeObjectInfo')
    br = nd(nt, 'ShaderNodeTexBrick'); L(nt, tc.outputs['Object'], br.inputs['Vector'])
    br.inputs['Scale'].default_value = 7.0; br.inputs['Mortar Size'].default_value = 0.05; br.offset = 0.0
    br.inputs['Color1'].default_value = (1, 1, 1, 1); br.inputs['Color2'].default_value = (0.5, 0.6, 0.9, 1)
    br.inputs['Mortar'].default_value = (0.05, 0.05, 0.05, 1)
    col = mix_rgb(nt, 1.0, oi.outputs['Color'], br.outputs['Color'], 'MULTIPLY')
    e = nd(nt, 'ShaderNodeEmission'); e.inputs['Strength'].default_value = 6.0
    L(nt, col, e.inputs['Color']); L(nt, e.outputs[0], out.inputs['Surface'])

    # neon palette (>= 4 colours)
    NEON = {'Magenta': (1.0, 0.03, 0.55), 'Cyan': (0.0, 0.85, 1.0), 'Amber': (1.0, 0.45, 0.02),
            'Green': (0.1, 1.0, 0.25), 'Red': (1.0, 0.02, 0.03), 'Violet': (0.45, 0.08, 1.0),
            'Blue': (0.05, 0.25, 1.0), 'White': (0.9, 0.95, 1.0)}
    for k, c in NEON.items():
        emit_mat('M_Neon_' + k, c, 14.0)
    emit_mat('M_Neon_Red_Flicker', NEON['Red'], 14.0)
    emit_mat('M_BeaconRed', (1.0, 0.05, 0.02), 30.0)
    emit_mat('M_UnderDeckLight', (0.75, 0.9, 1.0), 12.0)
    emit_mat('M_CanopyLED', (0.8, 0.92, 1.0), 10.0)
    emit_mat('M_GuidewayGlow', (0.0, 0.8, 1.0), 10.0)
    # train
    simple_mat('M_TrainBody', (0.5, 0.52, 0.56), 0.5, 0.2, **{'Coat Weight': 1.0, 'Coat Roughness': 0.03})
    simple_mat('M_TrainUnder', (0.03, 0.03, 0.035), 0.8, 0.4)
    simple_mat('M_TrainSeam', (0.004, 0.004, 0.005), 0.0, 0.7)
    m, nt, out = new_mat('M_TrainWindow')
    p = principled(nt, (0.01, 0.01, 0.015), 0.0, 0.04, **{'Emission Color': (1.0, 0.85, 0.65), 'Emission Strength': 2.5})
    L(nt, p.outputs[0], out.inputs['Surface'])
    simple_mat('M_TrainWindshield', (0.005, 0.01, 0.02), 0.0, 0.03, **{'Specular IOR Level': 1.0})
    emit_mat('M_TrainStripe', (0.0, 0.8, 1.0), 6.0)
    emit_mat('M_Thruster', (0.15, 0.55, 1.0), 30.0)
    emit_mat('M_Headlight', (1.0, 0.97, 0.9), 40.0)
    emit_mat('M_TailLight', (1.0, 0.02, 0.02), 30.0)
    # flying car paint
    m, nt, out = new_mat('M_CarPaint')
    oi = nd(nt, 'ShaderNodeObjectInfo')
    p = principled(nt, oi.outputs['Color'], 0.6, 0.22, **{'Coat Weight': 1.0, 'Coat Roughness': 0.02})
    L(nt, p.outputs[0], out.inputs['Surface'])
    # people
    simple_mat('M_PersonCloth', (0.02, 0.02, 0.025), 0.0, 0.55, **{'Sheen Weight': 0.5})
    simple_mat('M_PersonSkin', (0.05, 0.035, 0.03), 0.0, 0.5)
    simple_mat('M_UmbrellaFabric', (0.015, 0.015, 0.02), 0.0, 0.25)
    m, nt, out = new_mat('M_UmbrellaClear')
    p = principled(nt, (0.8, 0.9, 1.0), 0.0, 0.15, **{'Transmission Weight': 0.95, 'Alpha': 0.6})
    L(nt, p.outputs[0], out.inputs['Surface'])
    # rain streaks
    m, nt, out = new_mat('M_Rain')
    e = nd(nt, 'ShaderNodeEmission'); e.inputs['Color'].default_value = (0.75, 0.85, 1.0, 1); e.inputs['Strength'].default_value = 0.55
    t = nd(nt, 'ShaderNodeBsdfTransparent'); ms = nd(nt, 'ShaderNodeMixShader')
    cd = nd(nt, 'ShaderNodeCameraData'); fade = nd(nt, 'ShaderNodeMapRange'); L(nt, cd.outputs['View Distance'], fade.inputs['Value'])
    fade.inputs['From Min'].default_value = 2.0; fade.inputs['From Max'].default_value = 10.0
    fade.inputs['To Min'].default_value = 0.0; fade.inputs['To Max'].default_value = 0.5
    far = nd(nt, 'ShaderNodeMapRange'); L(nt, cd.outputs['View Distance'], far.inputs['Value'])
    far.inputs['From Min'].default_value = 25.0; far.inputs['From Max'].default_value = 60.0
    far.inputs['To Min'].default_value = 1.0; far.inputs['To Max'].default_value = 0.08
    L(nt, math_n(nt, 'MULTIPLY', fade.outputs[0], far.outputs[0]), ms.inputs[0])
    L(nt, t.outputs[0], ms.inputs[1]); L(nt, e.outputs[0], ms.inputs[2]); L(nt, ms.outputs[0], out.inputs['Surface'])
    m.cycles.emission_sampling = 'NONE'
    # volumetric haze
    m, nt, out = new_mat('M_FogVolume')
    v = nd(nt, 'ShaderNodeVolumePrincipled')
    v.inputs['Color'].default_value = (0.7, 0.75, 0.95, 1); v.inputs['Density'].default_value = 0.0055
    v.inputs['Anisotropy'].default_value = 0.35
    L(nt, v.outputs[0], out.inputs['Volume'])
    m.cycles.homogeneous_volume = True
    # animated ad screens
    for name, stops in (('M_Screen_A', [(0.0, (1.0, 0.0, 0.5)), (0.5, (0.0, 0.1, 0.8)), (1.0, (0.0, 1.0, 0.9))]),
                        ('M_Screen_B', [(0.0, (1.0, 0.5, 0.0)), (0.45, (0.6, 0.0, 0.9)), (1.0, (1.0, 0.1, 0.3))])):
        m, nt, out = new_mat(name)
        tc = texco(nt); mp = nd(nt, 'ShaderNodeMapping'); L(nt, tc.outputs['Object'], mp.inputs['Vector'])
        mp.name = 'ScreenMapping'
        w = nd(nt, 'ShaderNodeTexWave'); w.inputs['Scale'].default_value = 0.6; w.inputs['Distortion'].default_value = 6.0
        w.inputs['Detail'].default_value = 3.0; L(nt, mp.outputs[0], w.inputs['Vector'])
        c = ramp(nt, w.outputs['Fac'], stops)
        # scanlines
        uv, _, sep = facade_uv(nt)
        sl = math_n(nt, 'FRACT', math_n(nt, 'MULTIPLY', sep.outputs[2], 12.0))
        sl2 = math_n(nt, 'ADD', math_n(nt, 'MULTIPLY', math_n(nt, 'GREATER_THAN', sl, 0.25), 0.6), 0.4)
        e = nd(nt, 'ShaderNodeEmission'); L(nt, c.outputs['Color'], e.inputs['Color'])
        L(nt, math_n(nt, 'MULTIPLY', sl2, 7.0), e.inputs['Strength'])
        L(nt, e.outputs[0], out.inputs['Surface'])
    return NEON

# ----------------------------------------------------------------------------- mesh builder
class MB:
    """Accumulate many primitives in one bmesh -> one object (keeps object count low)."""
    def __init__(self, attrs=False):
        self.bm = bmesh.new(); self.mats = []
        self.attrs = attrs
        if attrs:
            self.l_lit = self.bm.faces.layers.float.new('lit')
            self.l_tint = self.bm.faces.layers.float.new('tint')
            self.l_bright = self.bm.faces.layers.float.new('bright')
    def mi(self, mat):
        if mat not in self.mats: self.mats.append(mat)
        return self.mats.index(mat)
    def _assign(self, verts, mat, smooth=False):
        idx = self.mi(mat); faces = set()
        for v in verts: faces.update(v.link_faces)
        for f in faces:
            f.material_index = idx; f.smooth = smooth
        return faces
    def box(self, center, size, mat, rot=None):
        R = rot.to_matrix().to_4x4() if rot is not None else Matrix()
        m = Matrix.Translation(Vector(center)) @ R @ Matrix.Diagonal((size[0], size[1], size[2], 1.0))
        r = bmesh.ops.create_cube(self.bm, size=1.0, matrix=m)
        return self._assign(r['verts'], mat)
    def box_minmax(self, lo, hi, mat):
        c = [(a + b) / 2 for a, b in zip(lo, hi)]; s = [abs(b - a) for a, b in zip(lo, hi)]
        return self.box(c, s, mat)
    def cyl(self, center, r1, depth, mat, seg=12, r2=None, rot=None, smooth=True, caps=True):
        R = rot.to_matrix().to_4x4() if rot is not None else Matrix()
        m = Matrix.Translation(Vector(center)) @ R
        r = bmesh.ops.create_cone(self.bm, cap_ends=caps, cap_tris=False, segments=seg, radius1=r1,
                                  radius2=r1 if r2 is None else r2, depth=depth, matrix=m)
        return self._assign(r['verts'], mat, smooth)
    def limb(self, p0, p1, r0, r1, mat, seg=8, smooth=True):
        p0 = Vector(p0); p1 = Vector(p1); d = p1 - p0
        if d.length < 1e-5: return
        q = Vector((0, 0, 1)).rotation_difference(d.normalized())
        return self.cyl((p0 + p1) / 2, r0, d.length, mat, seg, r1, q, smooth)
    def sphere(self, center, radii, mat, u=12, v=8, smooth=True):
        m = Matrix.Translation(Vector(center)) @ Matrix.Diagonal((radii[0], radii[1], radii[2], 1.0))
        r = bmesh.ops.create_uvsphere(self.bm, u_segments=u, v_segments=v, radius=1.0, matrix=m)
        return self._assign(r['verts'], mat, smooth)
    def quad(self, pts, mat, lit=None, tint=0.0, bright=0.0):
        vs = [self.bm.verts.new(p) for p in pts]
        f = self.bm.faces.new(vs); f.material_index = self.mi(mat)
        if self.attrs and lit is not None:
            f[self.l_lit] = lit; f[self.l_tint] = tint; f[self.l_bright] = bright
        return f
    def to_obj(self, name, coll, parent=None):
        me = bpy.data.meshes.new(name)
        self.bm.normal_update()
        self.bm.to_mesh(me); self.bm.free()
        for m in self.mats: me.materials.append(m)
        ob = bpy.data.objects.new(name, me); coll.objects.link(ob)
        if parent: ob.parent = parent
        return ob

def new_obj_empty(name, coll, loc=(0, 0, 0)):
    e = bpy.data.objects.new(name, None); e.location = loc; coll.objects.link(e); return e

LIGHT_COUNT = {'n': 0}
def add_light(name, ltype, loc, color, energy, size=0.1, rot=(0, 0, 0), spot_size=None, blend=0.5, coll=None, shadow=True):
    ld = bpy.data.lights.new(name, ltype)
    ld.color = color[:3]; ld.energy = energy
    if ltype in ('POINT', 'SPOT'): ld.shadow_soft_size = size
    if ltype == 'AREA':
        ld.shape = 'RECTANGLE'
        if isinstance(size, tuple): ld.size, ld.size_y = size
        else: ld.size = size
    if ltype == 'SPOT':
        ld.spot_size = spot_size or math.radians(90); ld.spot_blend = blend
    ld.use_shadow = shadow
    ob = bpy.data.objects.new(name, ld); ob.location = loc; ob.rotation_euler = rot
    (coll or COLL['Lights']).objects.link(ob)
    LIGHT_COUNT['n'] += 1
    return ob

# ----------------------------------------------------------------------------- 3D TEXT (Text object -> mesh)
FONT = None
def make_text(name, body, fit_w, fit_h, mat, loc, rot, coll, extrude=0.03, space_line=1.0, parent=None):
    global FONT
    if FONT is None:
        FONT = bpy.data.fonts.load(FONT_PATH)
    cu = bpy.data.curves.new(name + '_curve', 'FONT')
    cu.body = body; cu.font = FONT; cu.size = 1.0; cu.extrude = extrude
    cu.bevel_depth = 0.004; cu.resolution_u = 3; cu.align_x = 'CENTER'; cu.align_y = 'CENTER'
    cu.space_line = space_line
    tmp = bpy.data.objects.new(name + '_tmp', cu); scene.collection.objects.link(tmp)
    dg = bpy.context.evaluated_depsgraph_get()
    me = bpy.data.meshes.new_from_object(tmp.evaluated_get(dg))
    bpy.data.objects.remove(tmp); bpy.data.curves.remove(cu)
    xs = [v.co.x for v in me.vertices]; ys = [v.co.y for v in me.vertices]; zs = [v.co.z for v in me.vertices]
    cx, cy, cz = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2, (min(zs) + max(zs)) / 2
    w, h = max(xs) - min(xs), max(ys) - min(ys)
    s = min(fit_w / w, fit_h / h)
    me.transform(Matrix.Diagonal((s, s, 1.0, 1.0)) @ Matrix.Translation((-cx, -cy, -cz)))
    me.materials.clear(); me.materials.append(mat)
    me.name = name
    ob = bpy.data.objects.new(name, me); ob.location = loc; ob.rotation_euler = rot
    coll.objects.link(ob)
    if parent: ob.parent = parent
    return ob

# ----------------------------------------------------------------------------- WORLD (procedural night sky)
def build_world():
    w = bpy.data.worlds.new('World_ProceduralNightSky'); scene.world = w; w.use_nodes = True
    nt = w.node_tree; nt.nodes.clear()
    out = nd(nt, 'ShaderNodeOutputWorld')
    tc = texco(nt); sep = nd(nt, 'ShaderNodeSeparateXYZ'); L(nt, tc.outputs['Generated'], sep.inputs[0])
    mr = nd(nt, 'ShaderNodeMapRange'); L(nt, sep.outputs[2], mr.inputs['Value'])
    mr.inputs['From Min'].default_value = -0.05; mr.inputs['From Max'].default_value = 0.55
    sky = ramp(nt, mr.outputs[0], [(0.0, (0.07, 0.022, 0.05)), (0.12, (0.035, 0.014, 0.04)),
                                   (0.4, (0.008, 0.007, 0.022)), (1.0, (0.0015, 0.0018, 0.005))])
    # low rain clouds lit from below by city light
    mp = nd(nt, 'ShaderNodeMapping'); mp.inputs['Scale'].default_value = (1.0, 1.0, 3.0)
    L(nt, tc.outputs['Generated'], mp.inputs['Vector'])
    cl = noise(nt, mp.outputs[0], 2.2, 8.0, 0.6)
    cm = ramp(nt, cl.outputs['Fac'], [(0.42, (0, 0, 0)), (0.75, (1, 1, 1))])
    glow = ramp(nt, mr.outputs[0], [(0.0, (0.14, 0.05, 0.1)), (0.5, (0.035, 0.02, 0.05)), (1.0, (0.006, 0.006, 0.012))])
    cm2 = math_n(nt, 'MULTIPLY', cm.outputs['Color'], 0.8)
    col = mix_rgb(nt, cm2, sky.outputs['Color'], glow.outputs['Color'])
    bg = nd(nt, 'ShaderNodeBackground'); bg.inputs['Strength'].default_value = 1.0
    L(nt, col, bg.inputs['Color']); L(nt, bg.outputs[0], out.inputs['Surface'])
    w.mist_settings.start = 6.0; w.mist_settings.depth = 150.0; w.mist_settings.falloff = 'QUADRATIC'

# ----------------------------------------------------------------------------- BUILDINGS
BUILDINGS = []
BSTATS = {'buildings': 0, 'windows': 0, 'lit': 0, 'detail': []}

def plan_buildings():
    plans = []
    for side in (-1, 1):
        y = STREET_Y0 + rng.uniform(0.0, 0.8); i = 0
        while y < STREET_Y1 - 3.5:
            w = rng.uniform(5.5, 10.5)
            if y + w > STREET_Y1: w = STREET_Y1 - y
            r = rng.random(); yc = y + w / 2
            if yc > 10:     # keep the +Y end lower so the final aerial view can see down the whole street
                H = rng.uniform(9, 15) if r < 0.4 else rng.uniform(16, 26)
            else:           # taller towers toward the -Y end (camera start)
                H = rng.uniform(10, 16) if r < 0.2 else (rng.uniform(18, 34) if r < 0.55 else rng.uniform(38, 66))
            plans.append(dict(side=side, y0=y, y1=y + w, H=H, idx=i))
            y += w + rng.choice([0.0, 0.0, 0.25, 0.8, 1.6]); i += 1
    for p in plans:
        same = [q for q in plans if q['side'] == p['side']]
        p['nb_lo'] = max([q['H'] for q in same if abs(q['y1'] - p['y0']) < 0.05] or [0])
        p['nb_hi'] = max([q['H'] for q in same if abs(q['y0'] - p['y1']) < 0.05] or [0])
    return plans

def window_grid(mb, origin, u_dir, normal, u_len, z_min, z_max, sp, ww, wh, fh, lit_ratio, first_z, sills=None):
    ncols = max(1, int((u_len - 0.6) / sp))
    u0 = (u_len - ncols * sp) / 2 + sp / 2
    flip = u_dir.cross(Vector((0, 0, 1))).dot(normal) < 0
    cnt = lit_cnt = 0; centers = []
    z = first_z
    while z + fh <= z_max + 0.01:
        zc = z + fh * 0.55
        if zc - wh / 2 >= z_min:
            fr = min(1.0, lit_ratio * rng.uniform(0.15, 1.8))
            for c in range(ncols):
                if rng.random() < 0.03: continue
                uc = u0 + c * sp
                lit = 1.0 if rng.random() < fr else 0.0
                p = origin + u_dir * uc + normal * 0.035
                d = [(-1, -1), (1, -1), (1, 1), (-1, 1)]
                pts = [p + u_dir * (a * ww / 2) + Vector((0, 0, zc + b * wh / 2)) for a, b in d]
                if flip: pts.reverse()
                mb.quad(pts, M['M_Window'], lit, rng.random(), rng.uniform(0.35, 2.0) * lit)
                if sills is not None:
                    sc = origin + u_dir * uc + normal * 0.08 + Vector((0, 0, zc - wh / 2 - 0.06))
                    size = u_dir * (ww + 0.2) + normal * 0.16
                    mb.box(sc, (abs(size.x) + 0.12 * abs(u_dir.y), abs(size.y) + 0.12 * abs(u_dir.x), 0.08), sills)
                centers.append((uc, zc)); cnt += 1; lit_cnt += int(lit)
        z += fh
    return cnt, lit_cnt, centers

STYLE_MAT = {'concrete': 'M_BldgConcrete', 'plaster': 'M_BldgPlaster', 'metal': 'M_BldgMetalPanel',
             'tile': 'M_BldgTile', 'glass': 'M_BldgGlassDark'}
STYLE_COL = {'concrete': [(0.18, 0.17, 0.16), (0.12, 0.12, 0.13), (0.22, 0.2, 0.18)],
             'plaster': [(0.25, 0.2, 0.17), (0.16, 0.19, 0.2), (0.28, 0.24, 0.2), (0.2, 0.12, 0.12)],
             'metal': [(0.08, 0.09, 0.11), (0.12, 0.1, 0.09), (0.06, 0.07, 0.07)],
             'tile': [(0.35, 0.34, 0.3), (0.2, 0.3, 0.3), (0.3, 0.24, 0.22)],
             'glass': [(0.05, 0.05, 0.06)]}

def add_fire_escape(mb, side, fr, yc, gf, fh, zmax):
    steel = M['M_DarkSteel']; w = 3.0; dpt = 1.2
    xo = side * (fr - dpt); xi = side * fr
    z = gf; k = 0
    while z + 0.5 < zmax - 1.0:
        mb.box(((xo + xi) / 2, yc, z), (dpt, w, 0.07), steel)
        mb.box((side * (fr - dpt + 0.02), yc, z + 1.0), (0.04, w, 0.04), steel)
        mb.box((side * (fr - dpt + 0.02), yc, z + 0.5), (0.03, w, 0.03), steel)
        for e in (-1, 1):
            mb.box((side * (fr - dpt / 2), yc + e * w / 2, z + 1.0), (dpt, 0.04, 0.04), steel)
        for j in range(13):
            mb.box((side * (fr - dpt + 0.02), yc - w / 2 + j * w / 12, z + 0.5), (0.02, 0.02, 1.0), steel)
        if z + fh + 0.5 < zmax - 1.0:
            dirn = 1 if k % 2 == 0 else -1
            ya = yc - dirn * 1.2; yb = yc + dirn * 1.2
            xs = side * (fr - 0.35)
            n = max(4, int(fh / 0.24))
            for e in (-0.3, 0.3):
                mb.limb((xs + side * e, ya, z), (xs + side * e, yb, z + fh), 0.03, 0.03, steel, seg=4, smooth=False)
            for j in range(1, n):
                t = j / n
                mb.box((xs, ya + (yb - ya) * t, z + fh * t), (0.6, 0.2, 0.025), steel)
        z += fh; k += 1
    # drop ladder
    xl = side * (fr - dpt + 0.25)
    for e in (-0.22, 0.22):
        mb.box((xl, yc + 1.0 + e, gf - 1.0), (0.03, 0.03, 2.0), steel)
    for j in range(7):
        mb.box((xl, yc + 1.0, gf - 2.0 + j * 0.3), (0.03, 0.44, 0.025), steel)

def add_pipes(mb, side, fr, ya, yb, ztop, rng_):
    mat = M['M_RustyMetal'] if rng_.random() < 0.5 else M['M_DarkSteel']
    ys = [ya + 0.35, yb - 0.35] + ([rng_.uniform(ya + 1, yb - 1)] if yb - ya > 4 else [])
    for y in ys:
        r = rng_.uniform(0.06, 0.12); x = side * (fr - 0.12 - r)
        mb.cyl((x, y, (0.15 + ztop) / 2), r, ztop - 0.15, mat, seg=10)
        z = 1.5
        while z < ztop:
            mb.cyl((x, y, z), r + 0.02, 0.06, M['M_DarkSteel'], seg=10); z += 2.4
        for _ in range(rng_.randint(1, 3)):
            zb = rng_.uniform(3.0, ztop - 1)
            y2 = min(max(y + rng_.uniform(-3, 3), ya + 0.3), yb - 0.3)
            mb.limb((x, y, zb), (x, y2, zb), r * 0.7, r * 0.7, mat, seg=8)
            mb.limb((x, y2, zb), (side * fr, y2, zb), r * 0.7, r * 0.7, mat, seg=8)

def add_ac_units(mb, side, fr, ya, centers, wh, rng_, prob=0.3):
    body = M['M_ACUnit']; n = 0
    for (uc, zc) in centers:
        if zc > 28 or rng_.random() > prob: continue
        y = ya + uc; z = zc - wh / 2 - 0.45
        x = side * (fr - 0.3)
        mb.box((x, y, z), (0.55, 0.85, 0.6), body)
        mb.cyl((side * (fr - 0.58), y + 0.12, z), 0.22, 0.03, M['M_DarkSteel'], seg=16,
               rot=Euler((0, math.radians(90), 0)))
        mb.box((side * (fr - 0.3), y, z - 0.33), (0.6, 0.9, 0.04), M['M_DarkSteel'])
        n += 1
    return n

def build_building(p, bi, details):
    side, y0, y1, H = p['side'], p['y0'], p['y1'], p['H']
    fx = rng.uniform(10.3, 11.2); depth = rng.uniform(9, 15); back = fx + depth
    style = rng.choices(list(STYLE_MAT), weights=[3, 2, 2, 2, 1.5])[0]
    if H < 20 and style == 'glass': style = 'concrete'
    fh = rng.uniform(3.6, 4.2) if style == 'glass' else rng.uniform(3.0, 3.9)
    gf = 4.4
    lit_ratio = rng.uniform(0.15, 0.8)
    sp = rng.uniform(1.15, 2.1) if style != 'glass' else rng.uniform(1.3, 1.8)
    ww = sp * (0.88 if style == 'glass' else rng.uniform(0.42, 0.72))
    wh = fh * (0.8 if style == 'glass' else rng.uniform(0.42, 0.66))
    fac = M[STYLE_MAT[style]]
    name = 'Bldg_%s%02d' % ('L' if side < 0 else 'R', p['idx'] + 1)
    mb = MB(attrs=True)
    tiers = [(y0, y1, 0.0, H, fx)]
    if H > 26 and rng.random() < 0.6:
        zt = gf + fh * round((H * rng.uniform(0.45, 0.7) - gf) / fh)
        inset = rng.uniform(1.0, 3.0); sh = rng.uniform(0.4, 1.6)
        if (y1 - y0) - 2 * sh > 3.5:
            tiers = [(y0, y1, 0.0, zt, fx), (y0 + sh, y1 - sh, zt, H, fx + inset)]
    sills = M['M_CurbConcrete'] if style in ('concrete', 'tile', 'plaster') and rng.random() < 0.7 else None
    nwin = nlit = 0; front_centers = None
    for ti, (ya, yb, za, zb, fr) in enumerate(tiers):
        xa, xb = side * fr, side * back
        mb.box_minmax((min(xa, xb), ya, za), (max(xa, xb), yb, zb), fac)
        c, l, cen = window_grid(mb, Vector((side * fr, ya, 0)), Vector((0, 1, 0)), Vector((-side, 0, 0)),
                                yb - ya, max(za + 0.3, gf), zb - 0.4, sp, ww, wh, fh, lit_ratio, gf, sills)
        nwin += c; nlit += l
        if ti == 0: front_centers = cen
        for yy, nrm, nbH in ((ya, -1, p['nb_lo'] if ti == 0 else 0), (yb, 1, p['nb_hi'] if ti == 0 else 0)):
            zmin = max(za + 0.3, gf, nbH + 0.6)
            if zb - zmin > fh:
                c, l, _ = window_grid(mb, Vector((side * fr, yy, 0)), Vector((side, 0, 0)), Vector((0, nrm, 0)),
                                      back - fr, zmin, zb - 0.4, sp, ww, wh, fh, lit_ratio, gf)
                nwin += c; nlit += l
        # facade articulation
        if style in ('concrete', 'plaster', 'tile') and not sills:
            z = gf
            while z < zb - 0.5:
                mb.box((side * (fr - 0.09), (ya + yb) / 2, z), (0.18, yb - ya, 0.12), M['M_CurbConcrete']); z += fh
        elif style == 'metal':
            ncols = max(1, int((yb - ya - 0.6) / sp)); u0 = (yb - ya - ncols * sp) / 2
            for cidx in range(ncols + 1):
                mb.box((side * (fr - 0.15), ya + u0 + cidx * sp, (max(za, gf) + zb) / 2), (0.3, 0.1, zb - max(za, gf)), M['M_DarkSteel'])
        elif style == 'glass':
            z = gf
            while z < zb:
                mb.box((side * (fr - 0.05), (ya + yb) / 2, z), (0.1, yb - ya, 0.08), M['M_DarkSteel']); z += fh
        # parapet on top of each tier
        pz = zb + 0.45
        mb.box((side * (fr + 0.12), (ya + yb) / 2, pz), (0.25, yb - ya, 0.9), fac)
        mb.box((side * (back - 0.12), (ya + yb) / 2, pz), (0.25, yb - ya, 0.9), fac)
        for yy in (ya + 0.12, yb - 0.12):
            mb.box(((xa + xb) / 2, yy, pz), (back - fr, 0.25, 0.9), fac)
    # ground floor shops
    nseg = max(1, round((y1 - y0) / 4.5)); segw = (y1 - y0) / nseg
    for s in range(nseg):
        ya = y0 + s * segw + 0.35; yb = y0 + (s + 1) * segw - 0.35
        x = side * (fx - 0.03)
        pts = [(x, ya, 0.45), (x, yb, 0.45), (x, yb, 3.4), (x, ya, 3.4)]
        if side > 0: pts.reverse()
        if rng.random() < 0.72:
            mb.quad(pts, M['M_Window'], 1.0, rng.random(), rng.uniform(0.6, 2.0)); nwin += 1; nlit += 1
            nm = max(1, int((yb - ya) / 1.3))
            for j in range(1, nm):
                mb.box((side * (fx - 0.06), ya + j * (yb - ya) / nm, 1.9), (0.08, 0.07, 3.0), M['M_DarkSteel'])
            mb.box((side * (fx - 0.06), (ya + yb) / 2, 2.75), (0.08, yb - ya, 0.07), M['M_DarkSteel'])
        else:
            mb.quad(pts, M['M_Shutter'], 0.0, 0, 0)
        mb.box((side * (fx - 0.1), (ya + yb) / 2, 3.55), (0.2, yb - ya + 0.5, 0.3), M['M_DarkSteel'])
        if rng.random() < 0.45:
            mb.box((side * (fx - 0.75), (ya + yb) / 2, 3.75), (1.5, yb - ya, 0.1), M['M_Awning'])
    # roof clutter
    ya, yb, za, zb, fr = tiers[-1]
    rx0, rx1 = side * (fr + 1.0), side * (back - 1.0)
    for _ in range(rng.randint(1, 4)):
        mb.box((rng.uniform(min(rx0, rx1), max(rx0, rx1)), rng.uniform(ya + 1, yb - 1), zb + 0.5),
               (rng.uniform(0.8, 2.0), rng.uniform(0.8, 1.6), rng.uniform(0.6, 1.2)), M['M_ACUnit'])
    if rng.random() < 0.45:
        tx, ty = (rx0 + rx1) / 2, (ya + yb) / 2; tr = rng.uniform(0.9, 1.4)
        for a in range(4):
            ang = a * math.pi / 2 + math.pi / 4
            mb.box((tx + math.cos(ang) * tr * 0.7, ty + math.sin(ang) * tr * 0.7, zb + 0.8), (0.12, 0.12, 1.6), M['M_DarkSteel'])
        mb.cyl((tx, ty, zb + 2.6), tr, 2.0, M['M_RustyMetal'], seg=16)
        mb.cyl((tx, ty, zb + 3.8), tr, 0.4, M['M_RustyMetal'], seg=16, r2=0.1)
    if H > 30:
        ax, ay = side * (fr + rng.uniform(1.5, 3)), rng.uniform(ya + 1, yb - 1); ah = rng.uniform(4, 9)
        mb.cyl((ax, ay, zb + ah / 2), 0.06, ah, M['M_DarkSteel'], seg=6)
        mb.sphere((ax, ay, zb + ah + 0.1), (0.14, 0.14, 0.14), M['M_BeaconRed'], 8, 6)
    # corner neon strip
    if rng.random() < 0.35:
        nm = M[rng.choice(['M_Neon_Cyan', 'M_Neon_Magenta', 'M_Neon_Violet'])]
        yy = y0 + 0.12 if rng.random() < 0.5 else y1 - 0.12
        mb.box((side * (fx - 0.08), yy, (gf + tiers[0][3]) / 2), (0.1, 0.1, tiers[0][3] - gf - 0.5), nm)
    # exterior details (fire escape / AC / pipes)
    dl = details.get(bi, [])
    ztop0 = tiers[0][3]
    if 'fire_escape' in dl:
        yc = y0 + (y1 - y0) * rng.uniform(0.35, 0.65)
        yc = min(max(yc, y0 + 1.8), y1 - 1.8)
        add_fire_escape(mb, side, fx, yc, gf, fh, ztop0)
    if 'ac' in dl:
        add_ac_units(mb, side, fx, y0, front_centers, wh, rng, 0.3)
    if 'pipes' in dl:
        add_pipes(mb, side, fx, y0, y1, ztop0, rng)
    ob = mb.to_obj(name, COLL['Buildings'])
    ob.color = (*rng.choice(STYLE_COL[style]), 1)
    info = dict(name=name, side=side, y0=y0, y1=y1, H=H, fx=fx, back=back, tiers=tiers, style=style,
                windows=nwin, lit=nlit, lit_ratio=lit_ratio, floors=int((H - gf) / fh) + 1, floor_h=fh, details=dl, obj=ob)
    BUILDINGS.append(info)
    BSTATS['windows'] += nwin; BSTATS['lit'] += nlit; BSTATS['buildings'] += 1
    return info

def build_buildings():
    simple_mat('M_ACUnit', (0.32, 0.33, 0.33), 0.3, 0.4)
    plans = plan_buildings()
    # choose >=3 (here 6) buildings near the camera path for exterior details
    cands = [i for i, p in enumerate(plans) if p['y0'] < 14 and p['y1'] - p['y0'] > 5.0]
    rng.shuffle(cands)
    types = [['fire_escape', 'ac'], ['pipes', 'ac'], ['fire_escape', 'pipes'], ['ac'], ['fire_escape'], ['pipes', 'ac']]
    details = {i: t for i, t in zip(cands, types)}
    for i, p in enumerate(plans):
        build_building(p, i, details)

def find_building(side, y):
    best = None
    for b in BUILDINGS:
        if b['side'] == side and b['y0'] - 0.01 <= y <= b['y1'] + 0.01:
            best = b
    if best is None:
        best = min([b for b in BUILDINGS if b['side'] == side], key=lambda b: abs((b['y0'] + b['y1']) / 2 - y))
    return best

def build_background():
    mb = MB(); bgm = M['M_BgSkyline']
    for side in (-1, 1):
        for (ya0, ya1) in ((46.0, 175.0), (-175.0, -46.0)):
            y = ya0
            while y < ya1:
                w = rng.uniform(7, 16); fx = rng.uniform(10.5, 12.5)
                H = rng.uniform(8, 18) if abs(y) < 80 else rng.uniform(15, 55)
                mb.box_minmax((min(side * fx, side * 30), y, 0), (max(side * fx, side * 30), min(y + w, ya1), H), bgm)
                y += w + rng.choice([0, 0.5, 2.0])
        # skyline towers behind the rows
        for k in range(70):
            x = side * rng.uniform(32, 140); y = rng.uniform(-190, 190)
            sx, sy = rng.uniform(10, 24), rng.uniform(10, 24)
            if abs(y) < 50 and abs(x) < 45: continue
            H = rng.uniform(25, 70) if rng.random() < 0.6 else rng.uniform(70, 150)
            mb.box((x, y, H / 2), (sx, sy, H), bgm)
            if rng.random() < 0.5:
                mb.sphere((x, y, H + 0.3), (0.4, 0.4, 0.4), M['M_BeaconRed'], 8, 6)
            if rng.random() < 0.25:
                nm = M[rng.choice(['M_Neon_Cyan', 'M_Neon_Magenta', 'M_Neon_Amber', 'M_Neon_Violet'])]
                mb.box((x, y, H - 1.5), (sx + 0.1, sy + 0.1, 0.35), nm)
    # the cross streets' far walls
    for yy in (-41.0, 41.0):
        for side in (-1, 1):
            for k in range(6):
                x0 = side * (30 + k * 18); H = rng.uniform(20, 60)
                ya, yb = (yy - 21, yy - 6) if k % 2 == 0 else (yy + 6, yy + 21)
                mb.box_minmax((min(x0, x0 + side * 15), ya, 0), (max(x0, x0 + side * 15), yb, H), bgm)
    mb.to_obj('BG_Skyline', COLL['Background'])

# ----------------------------------------------------------------------------- STREET
def build_street():
    # ground: one big wet asphalt plane (road + cross streets)
    mb = MB()
    mb.quad([(-220, -220, 0), (220, -220, 0), (220, 220, 0), (-220, 220, 0)], M['M_WetAsphalt'])
    mb.to_obj('Street_Ground_WetAsphalt', COLL['Street'])
    # sidewalks & curbs (separated from the carriageway, raised 15 cm)
    sw = MB(); cb = MB()
    segs = [(-175.0, -46.0), (-36.0, 36.0), (46.0, 175.0)]
    for side in (-1, 1):
        for ya, yb in segs:
            sw.box_minmax((min(side * 6.15, side * 30), ya, 0), (max(side * 6.15, side * 30), yb, 0.15), M['M_SidewalkTiles'])
            cb.box_minmax((min(side * 5.9, side * 6.15), ya, 0), (max(side * 5.9, side * 6.15), yb, 0.16), M['M_CurbConcrete'])
            for yy, d in ((ya, -1), (yb, 1)):
                cb.box_minmax((min(side * 5.9, side * 30), yy + (0 if d < 0 else -0.25), 0),
                              (max(side * 5.9, side * 30), yy + (0.25 if d < 0 else 0), 0.16), M['M_CurbConcrete'])
    sw.to_obj('Street_Sidewalks', COLL['Street']); cb.to_obj('Street_Curbs', COLL['Street'])
    # lane markings, edge lines, zebra crossings
    mk = MB(); paint = M['M_RoadPaint']; yel = M['M_RoadPaintYellow']
    def in_x(y): return (-46 < y < -36) or (36 < y < 46)
    y = -175.0
    while y < 175:
        if not in_x(y) and not in_x(y + 3):
            for x in (-3.0, 3.0): mk.box((x, y + 1.5, 0.004), (0.13, 3.0, 0.008), paint)
        y += 7.5
    for ya, yb in segs:
        for x in (-5.55, 5.55): mk.box((x, (ya + yb) / 2, 0.004), (0.15, yb - ya, 0.008), paint)
        for x in (-0.14, 0.14): mk.box((x, (ya + yb) / 2, 0.004), (0.1, yb - ya, 0.008), yel)
    for yc in (-33.2, 33.2):
        for i in range(11):
            mk.box((-5.0 + i * 1.0, yc, 0.005), (0.5, 3.2, 0.01), paint)
        mk.box((0, yc - 2.4 * (1 if yc < 0 else -1), 0.005), (11.6, 0.35, 0.01), paint)
    mk.to_obj('Street_Markings_Zebra', COLL['Street'])
    # manholes and drain grates
    mh = MB()
    for (x, y) in [(3.1, -24.0), (-2.4, -9.0), (2.0, 7.5), (-3.8, 19.0), (1.6, 28.5), (-1.9, -38.0), (8.3, -4.0)]:
        mh.cyl((x, y, 0.012 if abs(x) < 6 else 0.162), 0.4, 0.024, M['M_Manhole'], seg=24)
        mh.cyl((x, y, 0.006 if abs(x) < 6 else 0.156), 0.46, 0.012, M['M_DarkSteel'], seg=24)
    y = -34.0
    while y < 35:
        for side in (-1, 1):
            mh.box((side * 5.55, y + rng.uniform(-1, 1), 0.006), (0.45, 0.9, 0.012), M['M_DarkSteel'])
        y += 9.5
    mh.to_obj('Street_Manholes_Drains', COLL['Street'])

# ----------------------------------------------------------------------------- STATION (elevated maglev platform)
COLS_Y = [-12.0, -4.0, 4.0, 12.0]
DECK_Z0 = 5.9
ARC_R = (6.8 ** 2 + 1.4 ** 2) / (2 * 1.4); ARC_ZC = 12.3 - ARC_R; ARC_PHI = math.asin(6.8 / ARC_R)
def arc_z(x): return ARC_ZC + math.sqrt(ARC_R ** 2 - x ** 2)
STAIR = dict(x0=6.9, x1=8.7, ytop=-14.0, ybot=-25.0)

def build_station():
    conc = M['M_StationConcrete']; steel = M['M_DarkSteel']
    st = MB()
    # guideway (runs the whole street and beyond) + glowing maglev rails
    st.box((0, 0, (GUIDE_Z0 + GUIDE_Z1) / 2), (1.4, 360, GUIDE_Z1 - GUIDE_Z0), conc)
    for x in (-0.64, 0.64):
        st.box((x, 0, GUIDE_Z1 + 0.02), (0.07, 360, 0.04), M['M_GuidewayGlow'])
    for y in [22.0, -22.0, 52, -52, 70, -70, 88, -88, 106, -106, 124, -124, 142, -142, 160, -160]:
        st.box((0, y, GUIDE_Z0 / 2), (0.9, 1.2, GUIDE_Z0), conc)
        st.box((0, y, GUIDE_Z0 - 0.2), (1.8, 1.8, 0.4), conc)
    # two side platforms
    for side in (-1, 1):
        x0, x1 = side * 1.5, side * 6.8
        st.box_minmax((min(x0, x1), -STATION_Y, DECK_Z0), (max(x0, x1), STATION_Y, DECK_Z1), conc)
        st.box_minmax((min(side * 1.5, side * 6.65), -STATION_Y + 0.05, DECK_Z1), (max(side * 1.5, side * 6.65), STATION_Y - 0.05, DECK_Z1 + 0.02), M['M_PlatformFloor'])
        st.box((side * 1.85, 0, DECK_Z1 + 0.025), (0.35, 2 * STATION_Y - 0.4, 0.01), M['M_TactileYellow'])
        st.box((side * 1.49, 0, DECK_Z1 - 0.1), (0.02, 2 * STATION_Y, 0.05), M['M_Neon_Cyan'])
        st.box((side * 6.81, 0, DECK_Z1 - 0.15), (0.02, 2 * STATION_Y, 0.06), M['M_Neon_Magenta'])
        for xx in (3.0, 5.3):
            st.box((side * xx, 0, DECK_Z0 - 0.02), (0.16, 2 * STATION_Y - 2, 0.03), M['M_UnderDeckLight'])
        for y in COLS_Y:
            st.box((side * 6.5, y, (0.15 + DECK_Z0) / 2), (0.6, 0.6, DECK_Z0 - 0.15), conc)
            st.box((side * 6.5, y, 0.4), (0.8, 0.8, 0.5), steel)
            st.box((side * 6.5, y, DECK_Z0 - 0.3), (0.9, 0.9, 0.6), conc)
    for y in COLS_Y:
        st.box((0, y, (5.3 + GUIDE_Z0) / 2), (13.6, 0.7, GUIDE_Z0 - 5.3), conc)
    # fascia panels (station name signs live on them)
    for yy in (-STATION_Y - 0.06, STATION_Y + 0.06):
        for side in (-1, 1):
            st.box((side * 4.1, yy, 6.4), (5.0, 0.1, 1.0), M['M_SignBacking'])
    # stairs down to the right sidewalk + landing
    s = STAIR; nsteps = 36; rise = (DECK_Z1 - 0.15) / nsteps; run = (s['ytop'] - s['ybot']) / nsteps
    xc = (s['x0'] + s['x1']) / 2; wd = s['x1'] - s['x0']
    st.box_minmax((6.8, s['ytop'], DECK_Z0 + 0.4), (s['x1'], -11.6, DECK_Z1), conc)
    for i in range(nsteps):
        z = DECK_Z1 - (i + 1) * rise
        y = s['ytop'] - (i + 0.5) * run
        st.box((xc, y, z), (wd, run + 0.02, 0.06), M['M_PlatformFloor'])
    for x in (s['x0'] + 0.05, s['x1'] - 0.05):
        st.limb((x, s['ytop'], DECK_Z1 - 0.2), (x, s['ybot'], 0.0), 0.06, 0.06, steel, seg=4, smooth=False)
        st.limb((x, s['ytop'], DECK_Z1 + 0.95), (x, s['ybot'], 1.1), 0.025, 0.025, M['M_Chrome'], seg=6)
        for i in range(0, nsteps + 1, 4):
            y = s['ytop'] - i * run; z = DECK_Z1 - i * rise
            st.box((x, y, z + 0.5), (0.04, 0.04, 1.0), M['M_Chrome'])
    st.box((xc, -19.5, (DECK_Z1 - 18 * rise) / 2), (0.35, 0.35, DECK_Z1 - 18 * rise), steel)
    st.to_obj('Station_Deck_Guideway_Stairs', COLL['Station'])

    # arched glass canopy with steel ribs
    cp = MB(); nphi = 18
    arc = [(ARC_R * math.sin(-ARC_PHI + 2 * ARC_PHI * i / nphi), 0) for i in range(nphi + 1)]
    arc = [(x, arc_z(x)) for x, _ in arc]
    for i in range(nphi):
        (xa, za), (xb, zb) = arc[i], arc[i + 1]
        cp.quad([(xa, -STATION_Y - 0.6, za), (xb, -STATION_Y - 0.6, zb), (xb, STATION_Y + 0.6, zb), (xa, STATION_Y + 0.6, za)], M['M_GlassFrosted'])
    ribs_y = [-STATION_Y - 0.6 + k * (2 * STATION_Y + 1.2) / 10 for k in range(11)]
    for y in ribs_y:
        for i in range(nphi):
            (xa, za), (xb, zb) = arc[i], arc[i + 1]
            cp.limb((xa, y, za - 0.08), (xb, y, zb - 0.08), 0.06, 0.06, steel, seg=6)
    for i in (0, nphi // 2, nphi):
        x, z = arc[i]
        cp.box((x, 0, z - 0.1), (0.18, 2 * STATION_Y + 1.2, 0.18), steel)
        # neon outline on top of the canopy (reads from the aerial view)
        cp.box((x, 0, z + 0.06), (0.1, 2 * STATION_Y + 1.2, 0.06), M['M_Neon_Magenta'] if i == nphi // 2 else M['M_Neon_Cyan'])
    for y in (-STATION_Y - 0.6, STATION_Y + 0.6):
        for i in range(nphi):
            (xa, za), (xb, zb) = arc[i], arc[i + 1]
            cp.limb((xa, y, za + 0.05), (xb, y, zb + 0.05), 0.045, 0.045, M['M_Neon_Cyan'], seg=6)
    for side in (-1, 1):
        for y in (-12, -6, 0, 6, 12):
            cp.cyl((side * 6.4, y, (DECK_Z1 + arc_z(6.4)) / 2), 0.12, arc_z(6.4) - DECK_Z1, steel, seg=12)
        cp.box((side * 3.8, 0, arc_z(3.8) - 0.2), (0.14, 2 * STATION_Y, 0.05), M['M_CanopyLED'])
        add_light('Light_Canopy_%s' % ('L' if side < 0 else 'R'), 'AREA', (side * 3.8, 0, arc_z(3.8) - 0.35),
                  (0.8, 0.9, 1.0), 900, size=(0.5, 2 * STATION_Y - 1))
        add_light('Light_UnderDeck_%s' % ('L' if side < 0 else 'R'), 'AREA', (side * 4.2, 0, DECK_Z0 - 0.1),
                  (0.75, 0.9, 1.0), 1200, size=(1.2, 2 * STATION_Y - 2))
    cp.to_obj('Station_Canopy', COLL['Station'])

    # railings: posts, chrome top rail, glass panels
    rl = MB()
    def railing(p0, p1):
        p0 = Vector(p0); p1 = Vector(p1); d = p1 - p0; n = max(1, int(d.length / 1.45))
        for i in range(n + 1):
            p = p0 + d * (i / n)
            rl.box((p.x, p.y, p.z + 0.55), (0.06, 0.06, 1.1), steel)
        rl.limb(p0 + Vector((0, 0, 1.1)), p1 + Vector((0, 0, 1.1)), 0.03, 0.03, M['M_Chrome'], seg=8)
        mid = (p0 + p1) / 2
        sx = abs(d.x) if abs(d.x) > 0.01 else 0.015; sy = abs(d.y) if abs(d.y) > 0.01 else 0.015
        rl.box((mid.x, mid.y, mid.z + 0.55), (sx, sy, 0.9), M['M_Glass'])
    z = DECK_Z1 + 0.02
    railing((-6.7, -STATION_Y + 0.1, z), (-6.7, STATION_Y - 0.1, z))
    railing((6.7, -11.6, z), (6.7, STATION_Y - 0.1, z))
    for yy in (-STATION_Y + 0.1, STATION_Y - 0.1):
        railing((-6.7, yy, z), (-1.7, yy, z)); railing((1.7, yy, z), (6.7 if yy > 0 else 6.7, yy, z))
    railing((8.65, -STATION_Y, z), (8.65, -11.6, z)); railing((6.7, -11.6, z), (8.65, -11.6, z))
    rl.to_obj('Station_Railings', COLL['Station'])
    # platform furniture: benches, info pylons (emissive)
    fu = MB()
    for side in (-1, 1):
        for y in (-8.5, 1.0, 9.5):
            fu.box((side * 5.6, y, DECK_Z1 + 0.45), (0.5, 1.8, 0.08), M['M_Chrome'])
            fu.box((side * 5.6, y, DECK_Z1 + 0.22), (0.08, 1.6, 0.45), steel)
        fu.box((side * 4.6, -2.5, DECK_Z1 + 1.1), (0.25, 0.9, 2.2), M['M_SignBacking'])
        fu.box((side * 4.46 if side > 0 else side * 4.46, -2.5, DECK_Z1 + 1.4), (0.02, 0.75, 1.2), M['M_Screen_A'])
    fu.to_obj('Station_Furniture', COLL['Station'])

# ----------------------------------------------------------------------------- LOFT (streamlined bodies)
def superellipse_ts(n, s_levels, NP=40):
    ts = [-math.pi / 2 + 2 * math.pi * i / NP for i in range(NP)]
    for s in s_levels:
        t = math.copysign(math.asin(min(1.0, abs(s)) ** (n / 2)), s)
        ts += [t, math.pi - t]
    ts = sorted(((t + math.pi / 2) % (2 * math.pi)) - math.pi / 2 for t in ts)
    out = []
    for t in ts:
        if not out or t - out[-1] > 1e-3: out.append(t)
    if out[-1] - out[0] > 2 * math.pi - 1e-3: out.pop()
    return out

def loft(mb, sections, ts, n, face_fn, cap_mat):
    rings = []
    for (y, a, b, zc, info) in sections:
        ring = []
        for t in ts:
            c, s = math.cos(t), math.sin(t)
            ring.append(mb.bm.verts.new((a * math.copysign(abs(c) ** (2 / n), c), y, zc + b * math.copysign(abs(s) ** (2 / n), s))))
        rings.append(ring)
    N = len(ts); faces = []
    for j in range(len(rings) - 1):
        ym = (sections[j][0] + sections[j + 1][0]) / 2
        for i in range(N):
            i2 = (i + 1) % N
            t2 = ts[i2] if i2 > 0 else ts[0] + 2 * math.pi
            tm = (ts[i] + t2) / 2
            srel = math.copysign(abs(math.sin(tm)) ** (2 / n), math.sin(tm))
            f = mb.bm.faces.new((rings[j][i], rings[j + 1][i], rings[j + 1][i2], rings[j][i2]))
            f.material_index = mb.mi(face_fn(ym, srel, math.cos(tm), sections[j][4], sections[j + 1][4]))
            f.smooth = True; faces.append(f)
    for ring in (rings[0], rings[-1]):
        f = mb.bm.faces.new(ring); f.material_index = mb.mi(cap_mat); f.smooth = False; faces.append(f)
        for e in f.edges: e.smooth = False
    bmesh.ops.recalc_face_normals(mb.bm, faces=faces)

# ----------------------------------------------------------------------------- TRAIN (3 cars)
T_ZC, T_A, T_B, T_N = 8.5, 1.4, 1.5, 3.2
CAR_L, CAR_GAP = 8.8, 0.45
def s_of(z): return (z - T_ZC) / T_B

def build_car(name, kind, parent, y_off):
    half = CAR_L / 2; mb = MB()
    nose = kind in ('lead', 'tail')
    by0 = -half; by1 = half - 4.0 if nose else half
    doors = [-2.4] if nose else [-2.2, 2.2]
    wins = []; y = by0 + 0.55
    while y + 1.0 <= by1 - 0.35:
        if all(abs(y + 0.5 - c) > 1.25 for c in doors):
            wins.append((y, y + 1.0)); y += 1.3
        else:
            y += 0.1
    bps = {by0 + 0.18, by1}
    for a, b in wins: bps.update([a, b])
    for c in doors: bps.update([c - 0.67, c - 0.63, c - 0.015, c + 0.015, c + 0.63, c + 0.67])
    k = by0 + 0.5
    while k < by1: bps.add(round(k, 3)); k += 0.5
    ys = []
    for v in sorted(bps):
        if by0 + 0.17 <= v <= by1 + 1e-6 and (not ys or v - ys[-1] > 0.006): ys.append(v)
    secs = [(by0, T_A * 0.9, T_B * 0.93, T_ZC, ('end', 0))]
    secs += [(y, T_A, T_B, T_ZC, ('body', 0)) for y in ys]
    if nose:
        for i in range(1, 17):
            u = (i / 16) ** 0.85; h = 3.0 * (1 - 0.9 * u ** 1.7); zb = 7.0 + 0.3 * u * u
            secs.append((by1 + u * 4.0, T_A * (1 - 0.86 * u ** 2.4), h / 2, zb + h / 2, ('nose', u)))
    else:
        secs.append((half, T_A * 0.9, T_B * 0.93, T_ZC, ('end', 0)))
    lamp = M['M_Headlight'] if kind == 'lead' else M['M_TailLight']
    def face(ym, s, xs, i0, i1):
        if i1[0] == 'nose':
            u = (i0[1] + i1[1]) / 2
            if s > 0.2 and 0.06 < u < 0.62: return M['M_TrainWindshield']
            if 0.6 < u < 0.93 and -0.62 < s < -0.22 and abs(xs) > 0.25: return lamp
            if s < -0.8: return M['M_TrainUnder']
            if s_of(8.05) < s < s_of(8.2): return M['M_TrainStripe']
            return M['M_TrainBody']
        if s < -0.85: return M['M_TrainUnder']
        if s > 0.9 and abs(xs) < 0.12: return M['M_Neon_Magenta']   # roof light strip (reads from above)
        for c in doors:
            if c - 0.67 < ym < c + 0.67 and s < s_of(9.55):
                if ym < c - 0.63 or ym > c + 0.63 or abs(ym - c) < 0.015 or s > s_of(9.51):
                    return M['M_TrainSeam']
        if s_of(8.5) < s < s_of(9.4) and any(a < ym < b for a, b in wins): return M['M_TrainWindow']
        if s_of(8.05) < s < s_of(8.2): return M['M_TrainStripe']
        return M['M_TrainBody']
    ts = superellipse_ts(T_N, [s_of(z) for z in (7.25, 8.05, 8.2, 8.5, 9.4, 9.51, 9.55)], 44)
    loft(mb, secs, ts, T_N, face, M['M_TrainSeam'])
    # underbody maglev thrusters (glowing pods) + skirts
    for yy in ([-3.0, 0.0, 2.2] if nose else [-3.0, 0.0, 3.0]):
        for x in (-0.6, 0.6):
            mb.sphere((x, yy, 6.84), (0.32, 0.95, 0.09), M['M_Thruster'], 12, 6)
            mb.box((x, yy, 6.97), (0.5, 1.4, 0.08), M['M_TrainUnder'])
    ob = mb.to_obj(name, COLL['Train'], parent)
    ob.location = (0, y_off, 0)
    if kind == 'tail': ob.rotation_euler = (0, 0, math.pi)
    return ob

def build_train():
    root = new_obj_empty('Train_Root', COLL['Train'])
    step = CAR_L + CAR_GAP
    build_car('Train_Car1_Lead', 'lead', root, step)
    build_car('Train_Car2_Middle', 'mid', root, 0.0)
    build_car('Train_Car3_Tail', 'tail', root, -step)
    gw = MB()
    for yy in (step / 2, -step / 2):
        gw.box((0, yy, 8.45), (2.0, CAR_GAP + 0.2, 2.4), M['M_TrainSeam'])
    gw.to_obj('Train_Gangways', COLL['Train'], root)
    # train runs toward -Y (root rotated 180 deg): arrives decelerating (f1-110), dwells, departs accelerating (f140-240)
    root.rotation_euler = (0, 0, math.pi)
    D = 100.0; f_stop, f_go, D_out = 110, 140, 38.0
    for f in range(F_START, F_END + 1, 2):
        if f <= f_stop:
            t = (f - F_START) / (f_stop - F_START); y = D * (1 - t) ** 2
        elif f <= f_go:
            y = 0.0
        else:
            t = (f - f_go) / (F_END - f_go); y = -D_out * t * t
        root.location = (0, y, 0)
        root.keyframe_insert('location', index=1, frame=f)
    for fc in root.animation_data.action.fcurves:
        for kp in fc.keyframe_points: kp.interpolation = 'LINEAR'
    return root

def fcurves_of(idblock):
    ad = idblock.animation_data
    if ad is None or ad.action is None: return []
    act = ad.action
    try:
        return list(act.fcurves)
    except Exception:
        out = []
        for layer in act.layers:
            for strip in layer.strips:
                for cb in strip.channelbags: out += list(cb.fcurves)
        return out

def set_interp(idblock, mode):
    for fc in fcurves_of(idblock):
        for kp in fc.keyframe_points: kp.interpolation = mode

# ----------------------------------------------------------------------------- SIGNS
SIGN_LIST = []
def neon_rect_xz(mb, xc, y, zc, w, h, mat, r=0.035):
    c = [(xc - w / 2, y, zc - h / 2), (xc + w / 2, y, zc - h / 2), (xc + w / 2, y, zc + h / 2), (xc - w / 2, y, zc + h / 2)]
    for i in range(4):
        mb.limb(c[i], c[(i + 1) % 4], r, r, mat, seg=6)
        mb.sphere(c[i], (r, r, r), mat, 6, 4)

def blade_sign(name, side, y, z0, hgt, wid, text, col, NEON, spill=250, mat=None):
    b = find_building(side, y)
    y = min(max(y, b['y0'] + 0.7), b['y1'] - 0.7); fx = b['fx']
    xc = side * (fx - 0.35 - wid / 2)
    mat = mat or M['M_Neon_' + col]
    mb = MB()
    mb.box((xc, y, z0 + hgt / 2), (wid, 0.24, hgt), M['M_SignBacking'])
    for fs in (-1, 1):
        neon_rect_xz(mb, xc, y + fs * 0.14, z0 + hgt / 2, wid - 0.12, hgt - 0.12, mat)
    for zz in (z0 + 0.3, z0 + hgt - 0.3):
        mb.box((side * (fx - 0.2), y, zz), (0.42, 0.08, 0.08), M['M_DarkSteel'])
    ob = mb.to_obj(name, COLL['Signs'])
    for fs in (-1, 1):
        make_text(name + ('_TextFront' if fs < 0 else '_TextBack'), text, (wid - 0.3) * 0.9, (hgt - 0.3) * 0.88, mat,
                  (xc, y + fs * 0.155, z0 + hgt / 2), (math.radians(90), 0, 0 if fs < 0 else math.pi),
                  COLL['Signs'], extrude=0.03, space_line=0.82)
    lt = None
    if spill:
        lt = add_light(name + '_Spill', 'POINT', (xc + side * 0.2, y - 1.0, z0 + hgt / 2), NEON[col], spill, size=0.5)
    SIGN_LIST.append((name, text.replace('\n', ''), col, True))
    return ob, lt

def build_signs(NEON):
    blade_sign('Sign01_Blade_NiHong', -1, -16.5, 3.9, 5.2, 1.6, '霓\n虹', 'Magenta', NEON, 350)
    blade_sign('Sign02_Blade_LaMian', 1, -27.5, 3.6, 4.0, 1.35, '拉\n面', 'Amber', NEON, 300)
    blade_sign('Sign03_Blade_JiuBa', -1, -10.0, 4.4, 4.4, 1.3, '酒\n吧', 'Green', NEON, 250)
    blade_sign('Sign04_Blade_DianWan', 1, 20.0, 5.0, 5.0, 1.4, '电\n玩', 'Cyan', NEON, 300)
    _, hotel_light = blade_sign('Sign05_Blade_HOTEL_Flicker', 1, 7.5, 7.2, 7.4, 1.25, 'H\nO\nT\nE\nL', 'Red', NEON, 350,
                                mat=M['M_Neon_Red_Flicker'])
    blade_sign('Sign06_Hanging_NEON', -1, -22.5, 3.9, 1.0, 2.6, 'NEON', 'Cyan', NEON, 200)
    blade_sign('Sign07_Small_24H', 1, -20.5, 3.8, 0.9, 1.1, '24H', 'Violet', NEON, 150)
    blade_sign('Sign08_Blade_ShouSi', -1, 12.0, 4.2, 4.2, 1.3, '寿\n司', 'Blue', NEON, 250)
    blade_sign('Sign09_Blade_Cyber', -1, 28.0, 6.5, 6.5, 1.5, 'C\nY\nB\nE\nR', 'Violet', NEON, 250)
    # street-spanning gantry banner
    mb = MB(); zc = 11.8; y = 34.6
    mb.box((0, y, zc), (13.0, 0.3, 2.6), M['M_SignBacking'])
    for fs in (-1, 1): neon_rect_xz(mb, 0, y + fs * 0.17, zc, 12.8, 2.4, M['M_Neon_Magenta'], 0.05)
    for side in (-1, 1):
        mb.box((side * 8.6, y, zc + 0.8), (4.0, 0.2, 0.2), M['M_DarkSteel'])
        mb.box((side * 8.6, y, zc - 0.8), (4.0, 0.2, 0.2), M['M_DarkSteel'])
    mb.to_obj('Sign10_Gantry_NeonCity', COLL['Signs'])
    for fs in (-1, 1):
        make_text('Sign10_Gantry_Text' + ('Front' if fs < 0 else 'Back'), '新九龙  NEON CITY', 12.0, 1.7, M['M_Neon_Cyan'],
                  (0, y + fs * 0.19, zc), (math.radians(90), 0, 0 if fs < 0 else math.pi), COLL['Signs'], extrude=0.04)
    add_light('Sign10_Gantry_Spill', 'AREA', (0, y - 0.8, zc - 1.5), NEON['Cyan'], 1200, size=(12, 1.0),
              rot=(math.radians(30), 0, 0))
    SIGN_LIST.append(('Sign10_Gantry_NeonCity', '新九龙 NEON CITY', 'Cyan+Magenta', True))
    # station name on fascia (both ends)
    for yy, rz, fs in ((-STATION_Y - 0.12, 0, -1), (STATION_Y + 0.12, math.pi, 1)):
        make_text('Sign11_Station_ZhongYang' + ('_S' if fs < 0 else '_N'), '中央站', 4.2, 0.75, M['M_Neon_Amber'],
                  (-4.1 * (1 if fs < 0 else -1), yy, 6.4), (math.radians(90), 0, rz), COLL['Signs'], extrude=0.03)
        make_text('Sign11_Station_CENTRAL' + ('_S' if fs < 0 else '_N'), 'CENTRAL', 4.2, 0.7, M['M_Neon_White'],
                  (4.1 * (1 if fs < 0 else -1), yy, 6.4), (math.radians(90), 0, rz), COLL['Signs'], extrude=0.03)
    SIGN_LIST.append(('Sign11_Station', '中央站 / CENTRAL', 'Amber+White', True))
    # pharmacy cross (non-text neon), ring, arrow
    b = find_building(1, 26.0); x = 1 * (b['fx'] - 0.12)
    mb = MB()
    mb.box((x, 26.0, 6.0), (0.12, 1.8, 0.6), M['M_Neon_Green']); mb.box((x, 26.0, 6.0), (0.12, 0.6, 1.8), M['M_Neon_Green'])
    mb.to_obj('Sign12_PharmacyCross', COLL['Signs'])
    add_light('Sign12_Spill', 'POINT', (x - 1.0, 26.0, 6.0), NEON['Green'], 150, size=0.4)
    b = find_building(-1, -3.0); x = -(b['fx'] - 0.15)
    mb = MB()
    ring_n = 24
    for i in range(ring_n):
        a0 = 2 * math.pi * i / ring_n; a1 = 2 * math.pi * (i + 1) / ring_n
        mb.limb((x, -3 + 1.6 * math.cos(a0), 14 + 1.6 * math.sin(a0)), (x, -3 + 1.6 * math.cos(a1), 14 + 1.6 * math.sin(a1)), 0.07, 0.07, M['M_Neon_Magenta'], 6)
        mb.limb((x, -3 + 1.1 * math.cos(a0), 14 + 1.1 * math.sin(a0)), (x, -3 + 1.1 * math.cos(a1), 14 + 1.1 * math.sin(a1)), 0.05, 0.05, M['M_Neon_Cyan'], 6)
    mb.to_obj('Sign13_NeonRings', COLL['Signs'])
    b = find_building(1, -12.0); x = b['fx'] - 0.12
    mb = MB(); am = M['M_Neon_Amber']
    pts = [(-12.8, 5.6), (-11.2, 5.6), (-11.2, 4.9), (-10.6, 4.9), (-12.0, 4.0), (-13.4, 4.9), (-12.8, 4.9), (-12.8, 5.6)]
    for i in range(len(pts) - 1):
        mb.limb((x, pts[i][0], pts[i][1]), (x, pts[i + 1][0], pts[i + 1][1]), 0.04, 0.04, am, 6)
    mb.to_obj('Sign14_NeonArrow', COLL['Signs'])
    # animated ad screens
    for name, side, y, z0, w, h, mat in (('Screen01_Ad', -1, -4.0, 8.5, 5.0, 9.0, 'M_Screen_A'), ('Screen02_Ad', 1, -24.0, 11.0, 4.0, 7.0, 'M_Screen_B')):
        b = find_building(side, y); x = side * (b['fx'] - 0.06)
        y = min(max(y, b['y0'] + w / 2 + 0.2), b['y1'] - w / 2 - 0.2)
        mb = MB()
        mb.box((side * (b['fx'] - 0.03), y, z0 + h / 2), (0.06, w + 0.3, h + 0.3), M['M_SignBacking'])
        pts = [(x, y - w / 2, z0), (x, y + w / 2, z0), (x, y + w / 2, z0 + h), (x, y - w / 2, z0 + h)]
        if side > 0: pts.reverse()
        mb.quad(pts, M[mat])
        mb.to_obj(name, COLL['Signs'])
        add_light(name + '_Spill', 'AREA', (side * (b['fx'] - 1.2), y, z0 + h / 2), (1.0, 0.3, 0.8) if mat == 'M_Screen_A' else (1.0, 0.5, 0.2),
                  500, size=(w, h), rot=(0, math.radians(90 * side), 0))
    SIGN_LIST.append(('Screen01/02', 'animated ad screens', 'multi', False))
    return hotel_light

def animate_signs(hotel_light):
    # flicker: irregular on/off bursts (CONSTANT interpolation) on the HOTEL sign material + its spill light
    m = M['M_Neon_Red_Flicker']; em = m.node_tree.nodes['Emission']
    pattern = [(1, 1), (22, 0), (24, 1), (26, 0), (29, 1), (70, 0.15), (73, 1), (118, 0), (121, 0.4), (123, 0), (127, 1),
               (160, 0), (162, 1), (164, 0), (170, 1), (205, 0.2), (207, 1), (226, 0), (229, 1)]
    for f, v in pattern:
        em.inputs['Strength'].default_value = 14.0 * v
        em.inputs['Strength'].keyframe_insert('default_value', frame=f)
        hotel_light.data.energy = 350 * v
        hotel_light.data.keyframe_insert('energy', frame=f)
    set_interp(m.node_tree, 'CONSTANT'); set_interp(hotel_light.data, 'CONSTANT')
    # ad screens scroll
    for mn, sp in (('M_Screen_A', 1.0), ('M_Screen_B', -1.4)):
        mp = M[mn].node_tree.nodes['ScreenMapping']
        mp.inputs['Location'].default_value = (0, 0, 0); mp.inputs['Location'].keyframe_insert('default_value', frame=F_START)
        mp.inputs['Location'].default_value = (0, 3.0 * sp, 6.0 * sp); mp.inputs['Location'].keyframe_insert('default_value', frame=F_END)
        set_interp(M[mn].node_tree, 'LINEAR')

# ----------------------------------------------------------------------------- PROPS (script-randomised scatter)
OCC = []
PROP_COUNT = {}
def blocked(x, y):
    if STAIR['x0'] - 0.4 < x < STAIR['x1'] + 0.4 and STAIR['ybot'] - 1.0 < y < -11.0: return True
    for cy in COLS_Y:
        if abs(abs(x) - 6.5) < 0.9 and abs(y - cy) < 0.9: return True
    if abs(abs(y) - 33.2) < 2.2 and abs(x) < 8: return True
    return False
def free(x, y, r):
    return not blocked(x, y) and all((x - a) ** 2 + (y - b) ** 2 > (r + c) ** 2 for a, b, c in OCC)
def claim(x, y, r): OCC.append((x, y, r))
def scatter_one(xranges, yr, r, tries=400):
    for _ in range(tries):
        x = rng.uniform(*rng.choice(xranges)); y = rng.uniform(*yr)
        if free(x, y, r):
            claim(x, y, r); return x, y
    return None
def count(kind): PROP_COUNT[kind] = PROP_COUNT.get(kind, 0) + 1

def street_lamp(i, x, y, side):
    h = rng.uniform(6.2, 7.8); style = rng.choice(['A', 'B', 'C'])
    col = rng.choice([(1.0, 0.62, 0.3), (1.0, 0.7, 0.42), (0.65, 0.85, 1.0), (1.0, 0.45, 0.75)])
    mb = MB(); st = M['M_DarkSteel']
    mb.cyl((x, y, 0.15 + 0.25), 0.17, 0.5, st, seg=10)
    mb.cyl((x, y, 0.15 + h / 2), 0.09, h, st, seg=10, r2=0.06)
    arm = rng.uniform(1.4, 2.2); heads = []
    if style in ('A', 'C'):
        hx = x - side * arm
        mb.limb((x, y, h), (hx, y, h + 0.3), 0.045, 0.04, st, 6)
        heads.append((hx, y, h + 0.25))
    if style == 'B':
        for d in (-1, 1):
            hx = x + d * arm * 0.8
            mb.limb((x, y, h), (hx, y, h + 0.2), 0.04, 0.035, st, 6); heads.append((hx, y, h + 0.15))
    if style == 'C':
        mb.box((x - side * 0.12, y, h - 2.2), (0.08, 0.5, 1.6), M['M_Neon_Cyan'] if rng.random() < 0.5 else M['M_Neon_Magenta'])
    for (hx, hy, hz) in heads:
        mb.box((hx, hy, hz), (0.75, 0.32, 0.14), st)
        mb.box((hx, hy, hz - 0.075), (0.62, 0.24, 0.02), M['M_LampGlow'])
    ob = mb.to_obj('Prop_StreetLamp_%02d' % i, COLL['Props']); ob.color = (*col, 1)
    for k, (hx, hy, hz) in enumerate(heads):
        add_light('Light_StreetLamp_%02d_%d' % (i, k), 'SPOT', (hx, hy, hz - 0.12), col, rng.uniform(1300, 2000),
                  size=0.15, spot_size=math.radians(rng.uniform(95, 115)), blend=0.6)
    claim(x, y, 0.5); count('street_lamp')

def build_props():
    # street lamps (rows with jitter, three variants, random height/colour)
    i = 0
    for side, ys in ((-1, [-30.0, -18.5, 18.0, 29.5, 55, 75, 95, 120, -55, -75, -95]),
                     (1, [-33.0, 20.5, 31.0, 50, 70, 90, 115, -50, -68, -88])):
        for y in ys:
            y += rng.uniform(-1.2, 1.2)
            if blocked(side * 6.7, y): continue
            street_lamp(i, side * 6.7, y, side); i += 1
    walk = [(-9.6, -6.8), (6.8, 9.6)]
    # utility poles + tangled wires
    poles = []
    for side, ys in ((-1, [-31.0, -13.0, 7.0, 26.0]), (1, [-29.5, -8.0, 12.5, 29.8])):
        for y in ys:
            y += rng.uniform(-0.8, 0.8); x = side * 9.3; h = rng.uniform(9.5, 11.5)
            mb = MB()
            mb.cyl((x, y, 0.15 + h / 2), 0.15, h, M['M_StationConcrete'], seg=10, r2=0.1)
            for zz, wdt in ((h - 0.2, 2.2), (h - 1.0, 1.6)):
                mb.box((x, y, zz), (wdt, 0.12, 0.12), M['M_DarkSteel'])
                for dx in (-wdt / 2 + 0.1, -0.3, 0.3, wdt / 2 - 0.1):
                    mb.cyl((x + dx, y, zz + 0.12), 0.04, 0.14, M['M_Chrome'], seg=6)
            if rng.random() < 0.5:
                mb.cyl((x + side * 0.35, y, h - 2.3), 0.28, 0.9, M['M_ACUnit'], seg=12)
            mb.to_obj('Prop_UtilityPole_%s_%02d' % ('L' if side < 0 else 'R', len(poles) + 1), COLL['Props'])
            poles.append((x, y, h)); claim(x, y, 0.4); count('utility_pole')
    wires = []
    def wire(p0, p1, sag): wires.append((Vector(p0), Vector(p1), sag))
    for side in (-1, 1):
        ps = sorted([p for p in poles if p[0] * side > 0], key=lambda p: p[1])
        for a, b in zip(ps, ps[1:]):
            for dx in (-1.0, -0.3, 0.3, 1.0):
                wire((a[0] + dx, a[1], a[2] - 0.08), (b[0] + dx, b[1], b[2] - 0.08), rng.uniform(0.5, 1.0))
        for p in ps:
            for _ in range(2):
                bld = find_building(side, p[1] + rng.uniform(-3, 3))
                wire((p[0], p[1], p[2] - 1.0), (side * bld['fx'], p[1] + rng.uniform(-3, 3), rng.uniform(5.5, 9)), rng.uniform(0.3, 0.8))
    L_ = [p for p in poles if p[0] < 0]; R_ = [p for p in poles if p[0] > 0]
    for a in L_:
        b = min(R_, key=lambda q: abs(q[1] - a[1]) + rng.uniform(0, 8))
        for dz in (-0.1, -0.9):
            wire((a[0] + 1.0, a[1], a[2] + dz), (b[0] - 1.0, b[1], b[2] + dz), rng.uniform(1.2, 2.2))
    lantern_wires = []
    for k in range(9):  # high building-to-building cables across the street
        y0 = rng.uniform(-34, 34)
        if abs(y0) < STATION_Y + 1: y0 = rng.choice([-1, 1]) * rng.uniform(16, 30)
        y1 = y0 + rng.uniform(-5, 5)
        bl = find_building(-1, y0); br = find_building(1, y1)
        z0 = rng.uniform(12, min(22, bl['H'] - 1)); z1 = rng.uniform(12, min(22, br['H'] - 1))
        if z0 < 12.5 or z1 < 12.5: continue
        sag = rng.uniform(1.0, 2.5)
        wire((-bl['fx'], y0, z0), (br['fx'], y1, z1), sag)
        if len(lantern_wires) < 2: lantern_wires.append(((-bl['fx'], y0, z0), (br['fx'], y1, z1), sag))
    cu = bpy.data.curves.new('Wires_All', 'CURVE'); cu.dimensions = '3D'; cu.bevel_depth = 0.014; cu.bevel_resolution = 1
    for p0, p1, sag in wires:
        sp = cu.splines.new('POLY'); npt = 16; sp.points.add(npt - 1)
        for j in range(npt):
            t = j / (npt - 1); p = p0.lerp(p1, t); p.z -= sag * 4 * t * (1 - t)
            sp.points[j].co = (p.x, p.y, p.z, 1)
    cu.materials.append(M['M_BlackRubber'])
    wo = bpy.data.objects.new('Wires_All', cu); COLL['Props'].objects.link(wo)
    PROP_COUNT['wires'] = len(wires)
    # hanging lantern strings on two of the cables
    mb = MB()
    for (p0, p1, sag) in lantern_wires:
        p0 = Vector(p0); p1 = Vector(p1)
        for j in range(1, 14):
            t = j / 14; p = p0.lerp(p1, t); p.z -= sag * 4 * t * (1 - t)
            mb.limb(p, p - Vector((0, 0, 0.35)), 0.006, 0.006, M['M_BlackRubber'], 4)
            mb.sphere(p - Vector((0, 0, 0.5)), (0.14, 0.14, 0.18), M['M_Neon_Amber'] if j % 3 else M['M_Neon_Red'], 8, 6)
    mb.to_obj('Prop_LanternStrings', COLL['Props'])
    # trash bins (3 shapes, random size / colour / rotation, one knocked over)
    bin_cols = [(0.03, 0.15, 0.06), (0.12, 0.12, 0.13), (0.03, 0.06, 0.18), (0.25, 0.12, 0.01), (0.2, 0.02, 0.02)]
    for k in range(9):
        pos = scatter_one(walk, (-35, 35), 0.6)
        if not pos: continue
        x, y = pos; kind = rng.choice(['round', 'round', 'square', 'dumpster'] if k > 1 else ['round'])
        mb = MB(); pm = M['M_PaintedMetal']
        if kind == 'round':
            r = rng.uniform(0.26, 0.36); h = rng.uniform(0.75, 1.0)
            mb.cyl((0, 0, h / 2), r, h, pm, seg=16, r2=r * 1.06)
            mb.cyl((0, 0, h + 0.03), r * 1.12, 0.06, M['M_DarkSteel'], seg=16)
        elif kind == 'square':
            s = rng.uniform(0.5, 0.65); h = rng.uniform(0.9, 1.1)
            mb.box((0, 0, h / 2), (s, s, h), pm); mb.sphere((0, 0, h), (s / 2, s / 2, 0.15), pm, 12, 6)
        else:
            x = math.copysign(find_building(1 if x > 0 else -1, y)['fx'] - 0.7, x)
            mb.box((0, 0, 0.62), (1.0, 1.8, 1.1), pm); mb.box((0, 0, 1.2), (1.05, 1.85, 0.06), M['M_BlackRubber'])
            for dx in (-0.35, 0.35):
                for dy in (-0.7, 0.7): mb.cyl((dx, dy, 0.06), 0.06, 0.08, M['M_BlackRubber'], seg=8, rot=Euler((0, math.pi / 2, 0)))
            for _ in range(rng.randint(2, 4)):
                mb.sphere((rng.uniform(-0.9, 0.9) * (1 if x < 0 else -1) * 0 + (0.9 if x < 0 else -0.9), rng.uniform(-1.2, 1.2), 0.25), (0.35, 0.3, 0.28), M['M_BlackRubber'], 10, 6)
        ob = mb.to_obj('Prop_TrashBin_%s_%02d' % (kind, k + 1), COLL['Props'])
        ob.location = (x, y, 0.15); ob.rotation_euler = (0, 0, 0 if kind == 'dumpster' else rng.uniform(0, 6.28)); ob.color = (*rng.choice(bin_cols), 1)
        if k == 3 and kind != 'dumpster':
            ob.rotation_euler = (math.radians(90), 0, rng.uniform(0, 6.28)); ob.location.z = 0.45
        count('trash_bin')
    # vending machines against facades
    vcols = [(1.0, 0.1, 0.12), (0.1, 0.35, 1.0), (0.9, 0.95, 1.0), (0.1, 1.0, 0.4), (1.0, 0.55, 0.05), (0.9, 0.1, 0.9)]
    for k in range(6):
        for _ in range(200):
            side = rng.choice([-1, 1]); y = rng.uniform(-34, 34)
            b = find_building(side, y); x = side * (b['fx'] - 0.5)
            if free(x, y, 0.8): claim(x, y, 0.8); break
        mb = MB()
        mb.box((0, 0, 1.0), (0.85, 1.05, 1.95), M['M_PaintedPlastic'])
        mb.box((-0.43, 0.0, 1.15), (0.02, 0.85, 1.2), M['M_VendingFront'])
        mb.box((-0.43, 0.2, 0.35), (0.04, 0.5, 0.2), M['M_BlackRubber'])
        mb.box((-0.43, -0.35, 0.95), (0.03, 0.12, 0.3), M['M_Neon_White'])
        ob = mb.to_obj('Prop_VendingMachine_%02d' % (k + 1), COLL['Props'])
        ob.location = (x, y, 0.15); ob.rotation_euler = (0, 0, 0 if side > 0 else math.pi)
        ob.color = (*vcols[k % len(vcols)], 1); count('vending_machine')
    # roadwork zone: A-frame barriers + cones (random pos/rot, varying sizes)
    for k in range(4):
        x = rng.uniform(-5.4, -3.6); y = rng.uniform(-23, -15)
        mb = MB()
        for d in (-1, 1):
            for e in (-0.55, 0.55):
                mb.limb((e, d * 0.25, 0.0), (e, 0.0, 1.0), 0.03, 0.03, M['M_PaintedMetal'], 4, False)
        mb.box((0, -0.14, 0.8), (1.3, 0.03, 0.25), M['M_HazardStripes'])
        mb.box((0, -0.18, 0.45), (1.3, 0.03, 0.2), M['M_HazardStripes'])
        mb.sphere((0.55, 0, 1.08), (0.07, 0.07, 0.07), M['M_Neon_Amber'], 8, 6)
        ob = mb.to_obj('Prop_Barrier_%02d' % (k + 1), COLL['Props'])
        ob.location = (x, y, 0.0); ob.rotation_euler = (0, 0, rng.uniform(-0.6, 0.6) + (math.pi if rng.random() < 0.5 else 0))
        ob.color = (0.8, 0.8, 0.8, 1); count('barrier')
    for k in range(6):
        x = rng.uniform(-5.6, -3.2); y = rng.uniform(-25, -13); s = rng.uniform(0.8, 1.15)
        mb = MB()
        mb.box((0, 0, 0.02), (0.42, 0.42, 0.04), M['M_BlackRubber'])
        mb.cyl((0, 0, 0.36), 0.17, 0.66, M['M_PaintedPlastic'], seg=14, r2=0.03)
        mb.cyl((0, 0, 0.42), 0.12, 0.1, M['M_RoadPaint'], seg=14, r2=0.1)
        ob = mb.to_obj('Prop_TrafficCone_%02d' % (k + 1), COLL['Props'])
        ob.location = (x, y, 0); ob.scale = (s, s, s); ob.color = (1.0, 0.25 + rng.uniform(-0.05, 0.1), 0.02, 1)
        if k == 5: ob.rotation_euler = (math.radians(90), 0, rng.uniform(0, 6.28)); ob.location.z = 0.15
        count('traffic_cone')
    # bollards with glowing caps near the crossings
    k = 0
    for yc in (-33.2, 33.2):
        for side in (-1, 1):
            for dy in (-2.0, 2.0):
                x = side * 6.45; y = yc + dy
                mb = MB()
                h = rng.uniform(0.8, 1.0)
                mb.cyl((0, 0, h / 2), 0.1, h, M['M_DarkSteel'], seg=12)
                mb.cyl((0, 0, h - 0.08), 0.105, 0.04, M['M_Neon_Cyan'] if rng.random() < 0.6 else M['M_Neon_Amber'], seg=12)
                ob = mb.to_obj('Prop_Bollard_%02d' % (k + 1), COLL['Props']); ob.location = (x, y, 0.15); k += 1
                claim(x, y, 0.2); count('bollard')
    # fire hydrants, benches, crate stacks (scattered)
    for k in range(3):
        pos = scatter_one([(-7.3, -6.6), (6.6, 7.3)], (-34, 34), 0.4)
        if not pos: continue
        mb = MB(); pm = M['M_PaintedMetal']
        mb.cyl((0, 0, 0.35), 0.13, 0.6, pm, seg=12); mb.sphere((0, 0, 0.66), (0.13, 0.13, 0.1), pm, 12, 6)
        mb.limb((-0.2, 0, 0.45), (0.2, 0, 0.45), 0.05, 0.05, pm, 8)
        ob = mb.to_obj('Prop_Hydrant_%02d' % (k + 1), COLL['Props']); ob.location = (*pos, 0.15)
        ob.rotation_euler = (0, 0, rng.uniform(0, 6.28)); ob.color = (rng.choice([(0.5, 0.02, 0.02), (0.6, 0.35, 0.0)]) + (1,)); count('hydrant')
    for k in range(3):
        pos = scatter_one(walk, (-34, 34), 1.2)
        if not pos: continue
        mb = MB(); L_len = rng.uniform(1.4, 2.0)
        mb.box((0, 0, 0.45), (0.45, L_len, 0.06), M['M_PaintedMetal']); mb.box((0.2, 0, 0.75), (0.05, L_len, 0.4), M['M_PaintedMetal'])
        for dy in (-L_len / 2 + 0.1, L_len / 2 - 0.1): mb.box((0, dy, 0.22), (0.4, 0.06, 0.45), M['M_DarkSteel'])
        ob = mb.to_obj('Prop_Bench_%02d' % (k + 1), COLL['Props']); ob.location = (*pos, 0.15)
        ob.rotation_euler = (0, 0, 0 if pos[0] < 0 else math.pi); ob.color = (0.1, 0.05, 0.02, 1); count('bench')
    for k in range(4):
        for _ in range(200):
            side = rng.choice([-1, 1]); y = rng.uniform(-34, 34); b = find_building(side, y)
            x = side * (b['fx'] - 0.6)
            if free(x, y, 0.8): claim(x, y, 0.8); break
        mb = MB(); z = 0.0
        for j in range(rng.randint(2, 5)):
            sx, sy, sz = rng.uniform(0.4, 0.8), rng.uniform(0.4, 0.8), rng.uniform(0.3, 0.6)
            mb.box((rng.uniform(-0.1, 0.1), rng.uniform(-0.1, 0.1), z + sz / 2), (sx, sy, sz), M['M_PaintedPlastic'],
                   rot=Euler((0, 0, rng.uniform(-0.4, 0.4)))); z += sz
        ob = mb.to_obj('Prop_CrateStack_%02d' % (k + 1), COLL['Props']); ob.location = (x, y, 0.15)
        ob.color = (0.22, 0.13, 0.06, 1); count('crate_stack')

# ----------------------------------------------------------------------------- PEOPLE (simplified silhouettes)
PEOPLE = []
def build_person(name, loc, rot_z, h=1.75, walk=False, umbrella=None, coat=False, hood=False, accent=None):
    k = h / 1.75; mb = MB(); cloth = M['M_PersonCloth']; skin = M['M_PersonSkin']
    P = lambda x, y, z: Vector((x * k, y * k, z * k))
    stride = 0.32 if walk else 0.05
    for sg in (-1, 1):
        hip = P(sg * 0.09, 0, 0.93); fw = stride * sg
        knee = P(sg * 0.095, fw * 0.55 + 0.03, 0.5); ank = P(sg * 0.095, fw, 0.09)
        mb.limb(hip, knee, 0.075 * k, 0.058 * k, cloth); mb.limb(knee, ank, 0.056 * k, 0.045 * k, cloth)
        mb.box(ank + P(0, 0.06, -0.045), (0.1 * k, 0.26 * k, 0.08 * k), M['M_BlackRubber'])
    mb.sphere(P(0, 0, 0.97), (0.17 * k, 0.11 * k, 0.13 * k), cloth)
    mb.limb(P(0, 0, 0.97), P(0, 0, 1.40), 0.15 * k, 0.185 * k, cloth, 12)
    mb.sphere(P(0, 0, 1.40), (0.215 * k, 0.12 * k, 0.09 * k), cloth)
    mb.limb(P(0, 0, 1.45), P(0, 0, 1.54), 0.045 * k, 0.045 * k, skin)
    mb.sphere(P(0, 0.01, 1.64), (0.09 * k, 0.1 * k, 0.115 * k), skin)
    if hood: mb.sphere(P(0, -0.015, 1.66), (0.11 * k, 0.12 * k, 0.13 * k), cloth)
    if coat: mb.limb(P(0, 0, 1.36), P(0, 0, 0.48), 0.2 * k, 0.29 * k, cloth, 14)
    hand_r = None
    for sg in (-1, 1):
        sh = P(sg * 0.21, 0, 1.41)
        if umbrella and sg == 1:
            el = P(0.25, 0.16, 1.17); wr = P(0.1, 0.28, 1.3); hand_r = wr
        else:
            sw = -stride * sg * 0.8
            el = P(sg * 0.25, sw * 0.5, 1.12); wr = P(sg * 0.26, sw, 0.86)
        mb.limb(sh, el, 0.055 * k, 0.045 * k, cloth); mb.limb(el, wr, 0.045 * k, 0.035 * k, cloth)
        mb.sphere(wr, (0.045 * k, 0.045 * k, 0.05 * k), skin, 8, 6)
    if umbrella:
        top = P(0.08, 0.2, 2.1)
        mb.limb(hand_r, top, 0.012, 0.012, M['M_DarkSteel'], 6)
        cm = M['M_UmbrellaClear'] if umbrella == 'clear' else M['M_UmbrellaFabric']
        mb.cyl(top - Vector((0, 0, 0.12)), 0.58 * k, 0.26 * k, cm, seg=10, r2=0.03, smooth=False, caps=False)
        if umbrella == 'clear':
            n = 10
            for i in range(n):
                a0 = 2 * math.pi * i / n; a1 = 2 * math.pi * (i + 1) / n; zc = top.z - 0.25 * k
                mb.limb(Vector((top.x + 0.58 * k * math.cos(a0), top.y + 0.58 * k * math.sin(a0), zc)),
                        Vector((top.x + 0.58 * k * math.cos(a1), top.y + 0.58 * k * math.sin(a1), zc)), 0.012, 0.012, M['M_Neon_Cyan'], 5)
    if accent:
        mb.cyl(P(0, 0, 1.18), 0.17 * k, 0.03, M[accent], seg=14)
        mb.limb(P(-0.2, 0.05, 1.38), P(-0.25, 0.08, 1.14), 0.05 * k, 0.05 * k, M[accent], 6)
    ob = mb.to_obj(name, COLL['People']); ob.location = loc; ob.rotation_euler = (0, 0, rot_z)
    PEOPLE.append((name, bool(umbrella), walk))
    return ob

def walk_anim(ob, p0, p1, f0=F_START, f1=F_END):
    p0 = Vector(p0); p1 = Vector(p1)
    for f in range(f0, f1 + 1, 6):
        t = (f - f0) / (f1 - f0); p = p0.lerp(p1, t)
        p.z += 0.025 * abs(math.sin(math.pi * (f - f0) / 12))
        ob.location = p; ob.keyframe_insert('location', frame=f)

def build_people():
    zs = 0.15; zp = DECK_Z1 + 0.02
    a = build_person('Person01_Umbrella_Walker', (9.45, -20, zs), math.pi, 1.78, walk=True, umbrella='black', coat=True)
    walk_anim(a, (9.45, -18.0, zs), (9.45, -30.0, zs))
    build_person('Person02_LEDUmbrella', (-8.1, -22.5, zs), -math.pi / 2 + 0.3, 1.66, umbrella='clear', coat=True)
    b = build_person('Person03_Hooded_Walker', (-7.6, -2, zs), 0.0, 1.82, walk=True, hood=True, accent='M_Neon_Magenta')
    walk_anim(b, (-7.6, -4.0, zs), (-7.6, 8.0, zs))
    build_person('Person04_Platform', (3.6, -6.0, zp), math.pi / 2, 1.74, coat=True)
    build_person('Person05_Platform', (-3.9, 3.5, zp), -math.pi / 2, 1.62, hood=True, accent='M_Neon_Cyan')
    c = build_person('Person06_Crossing_Umbrella', (3.5, 33.6, 0.01), math.pi / 2, 1.7, walk=True, umbrella='clear')
    walk_anim(c, (3.5, 33.6, 0.01), (-4.0, 33.4, 0.01))
    build_person('Person07_Leaning', (-9.7, -8.2, zs), -math.pi / 2, 1.8, hood=True, accent='M_Neon_Green')
    build_person('Person08_Umbrella', (7.4, 21.5, zs), math.pi + 0.4, 1.69, umbrella='black')
    build_person('Person09_Platform', (4.6, 5.0, zp), math.pi / 2 + 0.2, 1.77, coat=True)

# ----------------------------------------------------------------------------- FLYING CARS
FLYERS = []
def build_flyer(name, color, path_pts, f0=F_START, f1=F_END):
    mb = MB(); A, B, N = 0.95, 0.42, 2.6
    secs = [(-2.25, A * 0.55, B * 0.55, 0.05, ('tail', 1.0)), (-2.05, A * 0.82, B * 0.85, 0.02, ('tail', 0.6)),
            (-1.75, A * 0.97, B * 0.98, 0.0, ('tail', 0.2)), (-1.2, A, B, 0.0, ('body', 0)), (-0.6, A, B, 0, ('body', 0)),
            (0.0, A, B, 0, ('body', 0)), (0.5, A, B, 0, ('body', 0))]
    for i in range(1, 11):
        u = i / 10; hgt = 2 * B * (1 - 0.62 * u ** 1.4); zb = -B + 0.08 * u * u
        secs.append((0.5 + u * 1.8, A * (1 - 0.72 * u ** 2), hgt / 2, zb + hgt / 2, ('nose', u)))
    def face(ym, s, xs, i0, i1):
        r = i1[0]; u = (i0[1] + i1[1]) / 2
        if r == 'tail' and i0[0] == 'tail' and abs(s) < 0.45: return M['M_TailLight']
        if s > 0.3 and -1.3 < ym < 1.3: return M['M_TrainWindshield']
        if r == 'nose' and u > 0.72 and -0.55 < s < -0.1 and abs(xs) > 0.3: return M['M_Headlight']
        if s < -0.8: return M['M_TrainUnder']
        return M['M_CarPaint']
    loft(mb, secs, superellipse_ts(N, [0.3, -0.1, -0.55], 32), N, face, M['M_TailLight'])
    for sg in (-1, 1):
        mb.cyl((sg * 1.1, -0.6, -0.05), 0.24, 1.5, M['M_CarPaint'], seg=16, rot=Euler((math.pi / 2, 0, 0)))
        mb.cyl((sg * 1.1, -1.37, -0.05), 0.19, 0.04, M['M_Thruster'], seg=16, rot=Euler((math.pi / 2, 0, 0)))
        mb.box((sg * 0.6, -1.9, 0.42), (0.08, 0.4, 0.3), M['M_CarPaint'])
    mb.box((0, -2.28, 0.1), (1.3, 0.04, 0.07), M['M_TailLight'])
    for sg in (-1, 1): mb.box((sg * 0.55, 0.0, -0.44), (0.04, 2.4, 0.03), M['M_Neon_Magenta'])
    ob = mb.to_obj(name, COLL['Vehicles']); ob.color = (*color, 1)
    # path + follow-path constraint
    cu = bpy.data.curves.new(name + '_Path', 'CURVE'); cu.dimensions = '3D'; cu.use_path = True
    sp = cu.splines.new('BEZIER'); sp.bezier_points.add(len(path_pts) - 1)
    for bp, p in zip(sp.bezier_points, path_pts):
        bp.co = p; bp.handle_left_type = bp.handle_right_type = 'AUTO'
    po = bpy.data.objects.new(name + '_Path', cu); COLL['Vehicles'].objects.link(po)
    c = ob.constraints.new('FOLLOW_PATH'); c.target = po; c.use_curve_follow = True; c.use_fixed_location = True
    c.forward_axis = 'FORWARD_Y'; c.up_axis = 'UP_Z'
    c.offset_factor = 0.0; c.keyframe_insert('offset_factor', frame=f0)
    c.offset_factor = 1.0; c.keyframe_insert('offset_factor', frame=f1)
    set_interp(ob, 'LINEAR')
    tl = add_light(name + '_TailLight', 'POINT', (0, -2.5, 0.1), (1.0, 0.03, 0.02), 60, size=0.15)
    tl.parent = ob
    hl = add_light(name + '_HeadLight', 'SPOT', (0, 2.35, -0.2), (0.9, 0.95, 1.0), 400, size=0.1,
                   rot=(math.radians(80), 0, 0), spot_size=math.radians(40), blend=0.4)
    hl.parent = ob
    FLYERS.append(name)
    return ob

def build_vehicles():
    build_flyer('FlyingCar01_Taxi', (0.8, 0.5, 0.02), [(-110, 24, 21), (-30, 27, 20), (20, 31, 19.5), (70, 36, 22)])
    build_flyer('FlyingCar02_Black', (0.02, 0.02, 0.025), [(-2.5, 70, 27), (-1.0, 10, 25), (1.5, -60, 23), (3, -170, 22)])
    build_flyer('FlyingCar03_White', (0.7, 0.72, 0.75), [(70, -90, 40), (25, -25, 37), (-10, 20, 34), (-70, 70, 33)])
    build_flyer('FlyingCar04_Red', (0.5, 0.02, 0.03), [(-60, 120, 30), (-18, 60, 28), (5, 10, 31), (60, -60, 34)])
    build_flyer('FlyingCar05_Cyan', (0.02, 0.3, 0.35), [(-30, -21, 9.5), (10, -20, 8.8), (60, -17, 11), (130, -12, 15)])

# ----------------------------------------------------------------------------- RAIN (geometry nodes) + FOG
def build_rain(count=75000):
    ng = bpy.data.node_groups.new('GN_RainStreaks', 'GeometryNodeTree')
    ng.interface.new_socket('Geometry', in_out='INPUT', socket_type='NodeSocketGeometry')
    ng.interface.new_socket('Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry')
    N = ng.nodes; Lk = ng.links.new
    gi = N.new('NodeGroupInput'); go = N.new('NodeGroupOutput')
    X0, X1, Y0, Y1, H, SPEED, SLANT = -13.0, 13.0, -50.0, 58.0, 30.0, 0.45, 0.12
    pts = N.new('GeometryNodePoints'); pts.inputs['Count'].default_value = count
    rv = N.new('FunctionNodeRandomValue'); rv.data_type = 'FLOAT_VECTOR'
    sock(rv.inputs, 'Min', 'VECTOR').default_value = (X0, Y0, 0.0); sock(rv.inputs, 'Max', 'VECTOR').default_value = (X1, Y1, H)
    sock(rv.inputs, 'Seed', 'INT').default_value = 11
    Lk(sock(rv.outputs, 'Value', 'VECTOR'), pts.inputs['Position'])
    pos = N.new('GeometryNodeInputPosition'); sep = N.new('ShaderNodeSeparateXYZ'); Lk(pos.outputs[0], sep.inputs[0])
    tm = N.new('GeometryNodeInputSceneTime')
    ma = N.new('ShaderNodeMath'); ma.operation = 'MULTIPLY_ADD'
    Lk(tm.outputs['Frame'], ma.inputs[0]); ma.inputs[1].default_value = -SPEED; Lk(sep.outputs[2], ma.inputs[2])
    md = N.new('ShaderNodeMath'); md.operation = 'FLOORED_MODULO'; Lk(ma.outputs[0], md.inputs[0]); md.inputs[1].default_value = H
    sl = N.new('ShaderNodeMath'); sl.operation = 'MULTIPLY_ADD'; Lk(md.outputs[0], sl.inputs[0]); sl.inputs[1].default_value = SLANT
    Lk(sep.outputs[1], sl.inputs[2])
    cb = N.new('ShaderNodeCombineXYZ'); Lk(sep.outputs[0], cb.inputs[0]); Lk(sl.outputs[0], cb.inputs[1]); Lk(md.outputs[0], cb.inputs[2])
    spn0 = N.new('GeometryNodeSetPosition'); Lk(pts.outputs[0], spn0.inputs['Geometry']); Lk(cb.outputs[0], spn0.inputs['Position'])
    # no rain under the station canopy: delete drops inside the canopy box
    pos2 = N.new('GeometryNodeInputPosition'); sep2 = N.new('ShaderNodeSeparateXYZ'); Lk(pos2.outputs[0], sep2.inputs[0])
    def cmp_abs(src, lim):
        ab = N.new('ShaderNodeMath'); ab.operation = 'ABSOLUTE'; Lk(src, ab.inputs[0])
        lt = N.new('ShaderNodeMath'); lt.operation = 'LESS_THAN'; Lk(ab.outputs[0], lt.inputs[0]); lt.inputs[1].default_value = lim
        return lt.outputs[0]
    m1 = N.new('ShaderNodeMath'); m1.operation = 'MULTIPLY'; Lk(cmp_abs(sep2.outputs[0], 7.0), m1.inputs[0]); Lk(cmp_abs(sep2.outputs[1], STATION_Y + 0.8), m1.inputs[1])
    zl = N.new('ShaderNodeMath'); zl.operation = 'LESS_THAN'; Lk(sep2.outputs[2], zl.inputs[0]); zl.inputs[1].default_value = 12.4
    m2 = N.new('ShaderNodeMath'); m2.operation = 'MULTIPLY'; Lk(m1.outputs[0], m2.inputs[0]); Lk(zl.outputs[0], m2.inputs[1])
    gt = N.new('FunctionNodeCompare'); gt.data_type = 'FLOAT'; gt.operation = 'GREATER_THAN'
    Lk(m2.outputs[0], gt.inputs[0]); gt.inputs[1].default_value = 0.5
    spn = N.new('GeometryNodeDeleteGeometry'); spn.domain = 'POINT'
    Lk(spn0.outputs[0], spn.inputs['Geometry']); Lk(gt.outputs[0], spn.inputs['Selection'])
    cyl = N.new('GeometryNodeMeshCylinder'); cyl.fill_type = 'NONE'
    cyl.inputs['Vertices'].default_value = 3; cyl.inputs['Radius'].default_value = 0.005; cyl.inputs['Depth'].default_value = 1.0
    smat = N.new('GeometryNodeSetMaterial'); smat.inputs['Material'].default_value = M['M_Rain']
    Lk(cyl.outputs['Mesh'], smat.inputs['Geometry'])
    rs = N.new('FunctionNodeRandomValue'); rs.data_type = 'FLOAT'
    sock(rs.inputs, 'Min', 'VALUE').default_value = 0.35; sock(rs.inputs, 'Max', 'VALUE').default_value = 0.85
    sock(rs.inputs, 'Seed', 'INT').default_value = 5
    sc = N.new('ShaderNodeCombineXYZ'); sc.inputs[0].default_value = 1.0; sc.inputs[1].default_value = 1.0
    Lk(sock(rs.outputs, 'Value', 'VALUE'), sc.inputs[2])
    iop = N.new('GeometryNodeInstanceOnPoints')
    Lk(spn.outputs[0], iop.inputs['Points']); Lk(smat.outputs[0], iop.inputs['Instance'])
    iop.inputs['Rotation'].default_value = (-math.atan(SLANT), 0, 0)
    Lk(sc.outputs[0], iop.inputs['Scale'])
    Lk(iop.outputs[0], go.inputs[0])
    me = bpy.data.meshes.new('Rain_Emitter'); ob = bpy.data.objects.new('Rain_GeoNodes_Streaks', me)
    COLL['Rain'].objects.link(ob)
    mod = ob.modifiers.new('RainStreaks', 'NODES'); mod.node_group = ng
    ob.visible_shadow = False; ob.visible_diffuse = False; ob.visible_volume_scatter = False; ob.visible_transmission = False
    return ob

def build_fog():
    mb = MB(); mb.box((0, 4, 24), (80, 150, 50), M['M_FogVolume'])
    ob = mb.to_obj('Fog_Volume_Haze', COLL['Atmosphere'])
    ob.visible_shadow = False
    return ob

# ----------------------------------------------------------------------------- CAMERAS
def look_rot(pos, tgt, prev=None):
    q = (Vector(tgt) - Vector(pos)).to_track_quat('-Z', 'Y')
    return q.to_euler('XYZ', prev) if prev is not None else q.to_euler('XYZ')

def catmull(pts, times, f):
    if f <= times[0]: return Vector(pts[0])
    if f >= times[-1]: return Vector(pts[-1])
    i = max(j for j in range(len(times) - 1) if times[j] <= f)
    t = (f - times[i]) / (times[i + 1] - times[i])
    t = t * t * (3 - 2 * t) * 0.35 + t * 0.65   # mild ease between keys
    p0 = Vector(pts[max(i - 1, 0)]); p1 = Vector(pts[i]); p2 = Vector(pts[i + 1]); p3 = Vector(pts[min(i + 2, len(pts) - 1)])
    return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t ** 3)

CAM_KEYS = [  # frame, camera position, look-at target
    (1,   (3.3, -41.0, 1.25), (2.6, 0.0, 3.6)),
    (45,  (3.3, -28.0, 1.55), (2.5, 5.0, 4.4)),
    (90,  (3.1, -13.5, 2.3),  (2.0, 16.0, 5.0)),
    (125, (2.8, 0.5, 2.9),    (1.2, 30.0, 6.0)),
    (160, (2.4, 17.0, 4.6),   (0.0, 45.0, 10.5)),
    (186, (1.6, 33.0, 8.2),   (-30.0, 46.0, 12.0)),
    (213, (-0.5, 48.5, 22.0), (-25.0, 20.0, 5.0)),
    (240, (-3.0, 62.0, 34.0), (0.6, -5.0, 2.0)),
]

def build_cameras():
    def cam(name, lens):
        cd = bpy.data.cameras.new(name); cd.lens = lens; cd.clip_start = 0.05; cd.clip_end = 1500
        cd.sensor_width = 36
        ob = bpy.data.objects.new(name, cd); COLL['Cameras'].objects.link(ob); return ob
    anim = cam('Cam_Anim_Flythrough', 22)
    times = [k[0] for k in CAM_KEYS]; P = [k[1] for k in CAM_KEYS]; T = [k[2] for k in CAM_KEYS]
    prev = None
    for f in range(F_START, F_END + 1):
        p = catmull(P, times, f); t = catmull(T, times, f)
        if 160 <= f <= 208:   # keep clear of the guideway beam while rising: smooth sideways/forward-look correction
            w = math.sin(math.pi * (f - 160) / 48.0) ** 2
            p = p + Vector((1.8 * w, 0, 0)); t = t + Vector((14.0 * w, 0, 2.0 * w))
        e = look_rot(p, t, prev); prev = e
        anim.location = p; anim.rotation_euler = e
        anim.keyframe_insert('location', frame=f); anim.keyframe_insert('rotation_euler', frame=f)
    set_interp(anim, 'LINEAR')
    # focal length: slightly wider at the end for the panorama
    anim.data.lens = 22; anim.data.keyframe_insert('lens', frame=160)
    anim.data.lens = 24; anim.data.keyframe_insert('lens', frame=240)
    s1 = cam('Cam_Still1_StreetLow', 20)
    s1.location = (3.6, -36.3, 0.5); s1.rotation_euler = look_rot(s1.location, (0.3, 0.0, 5.3))
    s2 = cam('Cam_Still2_StationClose', 24)
    s2.location = (-5.0, 17.2, 8.9); s2.rotation_euler = look_rot(s2.location, (0.3, 2.0, 8.5))
    s3 = cam('Cam_Still3_AerialPanorama', 27)
    s3.location = (-6.0, 80.0, 50.0); s3.rotation_euler = look_rot(s3.location, (1.0, -4.0, 3.0))
    scene.camera = anim
    return anim

# ----------------------------------------------------------------------------- RENDER + COMPOSITOR
def setup_render():
    r = scene.render; cy = scene.cycles
    r.engine = 'CYCLES'; cy.device = 'CPU'
    r.resolution_x, r.resolution_y, r.resolution_percentage = 1920, 1080, 100
    r.fps = FPS; scene.frame_start, scene.frame_end = F_START, F_END
    cy.samples = 96; cy.use_adaptive_sampling = True; cy.adaptive_threshold = 0.03
    cy.use_denoising = True; cy.denoiser = 'OPENIMAGEDENOISE'; cy.denoising_input_passes = 'RGB_ALBEDO_NORMAL'
    cy.max_bounces = 6; cy.diffuse_bounces = 2; cy.glossy_bounces = 3; cy.transmission_bounces = 4
    cy.volume_bounces = 0; cy.transparent_max_bounces = 8
    cy.caustics_reflective = False; cy.caustics_refractive = False
    cy.blur_glossy = 1.0; cy.sample_clamp_indirect = 6.0; cy.sample_clamp_direct = 0.0
    cy.use_light_tree = True
    r.use_persistent_data = True
    r.film_transparent = False
    scene.view_settings.view_transform = 'AgX'; scene.view_settings.look = 'AgX - Medium High Contrast'
    scene.view_settings.exposure = 0.0
    scene.view_layers[0].use_pass_mist = True
    r.image_settings.file_format = 'PNG'; r.image_settings.color_depth = '8'
    # compositor: mist depth-haze tint + bloom glare + slight chromatic dispersion
    scene.use_nodes = True; nt = scene.node_tree; nt.nodes.clear()
    rl = nt.nodes.new('CompositorNodeRLayers')
    mm = nt.nodes.new('CompositorNodeMath'); mm.operation = 'MULTIPLY'; mm.inputs[1].default_value = 0.2
    nt.links.new(rl.outputs['Mist'], mm.inputs[0])
    mix = nt.nodes.new('CompositorNodeMixRGB'); mix.blend_type = 'MIX'
    nt.links.new(mm.outputs[0], mix.inputs[0]); nt.links.new(rl.outputs['Image'], mix.inputs[1])
    mix.inputs[2].default_value = (0.035, 0.03, 0.065, 1)
    gl = nt.nodes.new('CompositorNodeGlare'); gl.glare_type = 'BLOOM'; gl.quality = 'HIGH'
    for k, v in (('Threshold', 1.2), ('Strength', 0.55), ('Size', 0.6), ('Saturation', 1.0)):
        if k in gl.inputs:
            try: gl.inputs[k].default_value = v
            except Exception: pass
    nt.links.new(mix.outputs[0], gl.inputs['Image'])
    ld = nt.nodes.new('CompositorNodeLensdist'); ld.inputs['Dispersion'].default_value = 0.012
    nt.links.new(gl.outputs[0], ld.inputs['Image'])
    comp = nt.nodes.new('CompositorNodeComposite'); nt.links.new(ld.outputs[0], comp.inputs['Image'])

# ----------------------------------------------------------------------------- MAIN
def main():
    NEON = build_materials()
    build_world()
    build_buildings()
    build_background()
    build_street()
    build_station()
    build_train()
    hotel = build_signs(NEON)
    animate_signs(hotel)
    build_props()
    build_people()
    build_vehicles()
    build_rain()
    build_fog()
    build_cameras()
    setup_render()
    scene.frame_set(1)
    info = {'buildings': BSTATS['buildings'], 'windows': BSTATS['windows'], 'lit_windows': BSTATS['lit'],
            'props': PROP_COUNT, 'people': len(PEOPLE), 'umbrellas': sum(1 for p in PEOPLE if p[1]),
            'flyers': len(FLYERS), 'signs': len(SIGN_LIST), 'lights': LIGHT_COUNT['n'],
            'building_details': [(b['name'], b['style'], b['details'], b['floors'], round(b['floor_h'], 2), b['windows'], b['lit'], round(b['lit_ratio'], 2), round(b['H'], 1), round(b['y1'] - b['y0'], 1), len(b['tiers'])) for b in BUILDINGS]}
    scene['build_info'] = str(info)
    print('BUILD_INFO', info)
    if '--nosave' not in sys.argv:
        bpy.ops.wm.save_as_mainfile(filepath=BLEND_PATH, compress=True)
        print('SAVED', BLEND_PATH)

main()
