"""Blender-only industrial art build, GLB exports, packed .blend and review renders.
Run: blender --background --python art/blender/build_realistic_assets.py
Blender coordinates: X lateral, Y course-forward, Z up, metres.
"""
from pathlib import Path
import math
import json
import bpy
import numpy as np
from mathutils import Vector

OUT = Path(__file__).resolve().parents[1] / 'generated'
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.render.engine = 'CYCLES'
scene.cycles.samples = 32
scene.cycles.use_denoising = True
scene.render.resolution_percentage = 100
scene.world.color = (0.16, 0.16, 0.16)
scene.view_settings.view_transform = 'AgX'
rng = np.random.default_rng(19)

def texture(name, base, rough=False):
    size = 256
    noise = rng.random((size, size))
    scratches = rng.random((size, 1)) > .97
    pixels = np.ones((size, size, 4), dtype=np.float32)
    for channel in range(3):
        pixels[:, :, channel] = np.clip(base[channel] * (.88 + .18 * noise) + scratches * .025, 0, 1)
    img = bpy.data.images.new(name, width=size, height=size)
    if rough:
        img.colorspace_settings.name = 'Non-Color'
    img.pixels.foreach_set(pixels.ravel())
    img.pack()
    return img

def material(name, color, metal=0, roughness=.45, textured=False):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    mat.diffuse_color = (*color, 1)
    shader = mat.node_tree.nodes.get('Principled BSDF')
    shader.inputs['Base Color'].default_value = (*color, 1)
    shader.inputs['Metallic'].default_value = metal
    shader.inputs['Roughness'].default_value = roughness
    if textured:
        for socket, image in [('Base Color', texture(name+'_color', color)),
                              ('Roughness', texture(name+'_roughness', (roughness,)*3, True))]:
            node = mat.node_tree.nodes.new('ShaderNodeTexImage')
            node.image = image
            mat.node_tree.links.new(node.outputs['Color'], shader.inputs[socket])
    return mat

coral = material('TeamCoral', (.48, .065, .025), .65, .32, True)
mint = material('TeamMint', (.055, .38, .28), .6, .36, True)
steel = material('BrushedTitanium', (.31, .36, .40), .85, .3, True)
dark = material('GraphiteCeramic', (.028, .04, .052), .65, .34, True)
rubber = material('JointRubber', (.012, .016, .018), .05, .78, True)
deck = material('WeatheredDeck', (.25, .29, .3), .7, .57, True)
gold = material('SafetyOchre', (.65, .34, .035), .5, .42, True)
glass = material('OpticalGlass', (.012, .052, .068), .8, .12)
white = material('MarkingIvory', (.78, .81, .75), .05, .42)
lightmat = material('StatusLED', (.09, .7, .51), .3, .2)
bsdf = lightmat.node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Emission Color'].default_value = (.1, 1, .66, 1)
bsdf.inputs['Emission Strength'].default_value = 3

def box(name, pos, size, mat, bevel=.025):
    bpy.ops.mesh.primitive_cube_add(size=1, location=pos)
    obj = bpy.context.object
    obj.name = name
    obj.scale = size
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    obj.data.materials.append(mat)
    if bevel:
        mod = obj.modifiers.new('Machined edge radius', 'BEVEL')
        mod.width = bevel
        mod.segments = 3
        mod = obj.modifiers.new('Weighted corner normals', 'WEIGHTED_NORMAL')
    return obj

def ball(name, pos, size, mat):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=12, location=pos)
    obj = bpy.context.object
    obj.name = name
    obj.scale = size
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    obj.data.materials.append(mat)
    for poly in obj.data.polygons:
        poly.use_smooth = True
    return obj

def rod(name, a, b, radius, mat, vertices=16):
    a, b = Vector(a), Vector(b)
    bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=(b-a).length, location=(a+b)/2)
    obj = bpy.context.object
    obj.name = name
    obj.rotation_mode = 'QUATERNION'
    obj.rotation_quaternion = (b-a).to_track_quat('Z', 'Y')
    obj.data.materials.append(mat)
    bevel = obj.modifiers.new('Turned edge', 'BEVEL')
    bevel.width = min(.008, radius*.2)
    bevel.segments = 2
    return obj

def label(text, pos, size, mat=white):
    bpy.ops.object.text_add(location=pos, rotation=(math.pi/2, 0, math.pi))
    obj = bpy.context.object
    obj.data.body = text
    obj.data.align_x = 'CENTER'
    obj.data.size = size
    obj.data.extrude = .001
    obj.data.materials.append(mat)
    bpy.ops.object.convert(target='MESH')
    return bpy.context.object

def group_since(before, name):
    objects = [o for o in scene.objects if o not in before and o.type == 'MESH']
    collection = bpy.data.collections.new(name)
    scene.collection.children.link(collection)
    for obj in objects:
        for old in list(obj.users_collection):
            old.objects.unlink(obj)
        collection.objects.link(obj)
    return objects

def export(name, objects):
    bpy.ops.object.select_all(action='DESELECT')
    for obj in objects:
        obj.select_set(True)
    bpy.context.view_layer.objects.active = objects[0]
    bpy.ops.export_scene.gltf(filepath=str(OUT/name), export_format='GLB', use_selection=True,
                             export_apply=True, export_animations=False, export_extras=True)

body_start = set(scene.objects)
roles = [('EyesHead',(0,0,3)), ('LeftArm',(-1,0,2)), ('RightArm',(1,0,2)),
         ('TorsoBack',(0,0,2)), ('LeftLeg',(-.45,0,.35)), ('RightLeg',(.45,0,.35))]
for role, center in roles:
    before = set(scene.objects)
    x,y,z = center
    if role == 'EyesHead':
        ball('Helmet shell', center, (.4,.32,.42), coral)
        box('Visor surround', (0,.27,3.02), (.66,.14,.23), dark, .07)
        for side in [-1,1]:
            rod('Optical housing',(side*.18,.32,3.02),(side*.18,.40,3.02),.096,steel,32)
            rod('Optical lens',(side*.18,.401,3.02),(side*.18,.413,3.02),.074,glass,32)
            ball('Sensor LED',(side*.27,.35,2.98),(.017,.015,.017),lightmat)
        box('Brow plate',(0,.24,3.27),(.49,.18,.065),steel)
    elif role == 'TorsoBack':
        box('Pressure hull',center,(.82,.54,.94),dark,.13)
        box('Chest armour',(0,.29,2.08),(.71,.14,.68),coral,.075)
        label('S / 06',(0,.37,2.07),.13)
        for i in range(5):
            box('Intake louvre',(-.21+i*.105,.375,1.86),(.057,.035,.10),rubber,.008)
        for side in [-1,1]:
            rod('Back power canister',(side*.25,-.3,1.68),(side*.25,-.3,2.35),.105,steel)
            rod('Shoulder coupler',(side*.38,0,2.2),(side*.75,0,2.1),.105,rubber)
            rod('Hip actuator',(side*.22,0,1.62),(side*.42,0,.67),.09,steel)
        rod('Neck',(0,0,2.43),(0,0,2.72),.13,rubber)
    elif 'Arm' in role:
        box('Forearm housing',center,(.29,.33,.54),coral,.075)
        ball('Universal wrist',(x,0,1.68),(.15,.16,.13),rubber)
        box('Palm',(x,.05,1.49),(.25,.19,.23),dark,.045)
        for finger in [-1,0,1]:
            box('Gripper finger',(x+finger*.085,.12,1.30),(.065,.15,.19),steel,.025)
        for i in range(4):
            box('Cooling rib',(x,0,2.17+i*.034),(.30,.34,.013),rubber,.004)
    else:
        box('Ankle armour',(x,0,.5),(.29,.34,.37),coral,.05)
        box('Boot',(x,.13,.22),(.38,.64,.27),dark,.065)
        box('Toe guard',(x,.37,.26),(.35,.15,.16),steel,.03)
        for i in range(6):
            box('Sole tread',(x,-.12+i*.09,.075),(.39,.048,.055),rubber,.008)
    for side in [-1,1]:
        rod('Hex fastener',(x+side*.10,.18,z+.12),(x+side*.10,.20,z+.12),.027,steel,6)
    objects = [o for o in scene.objects if o not in before]
    # Each exported role is one multi-material mesh with its pivot at the solver node.
    bpy.ops.object.select_all(action='DESELECT')
    for obj in objects:
        obj.select_set(True)
        bpy.context.view_layer.objects.active=obj
        bpy.ops.object.convert(target='MESH')
    bpy.context.view_layer.objects.active=objects[0]
    bpy.ops.object.join()
    obj=bpy.context.object
    obj.name=role
    scene.cursor.location=center
    bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    obj['singularity_role']=role
body = group_since(body_start,'SingularityBody')
export('singularity_body.glb',body)

before=set(scene.objects)
for start,end in [(-5,7),(15,27),(27,47)]:
    box('Structural platform',(0,(start+end)/2,-.34),(9.2,end-start,.64),dark,.09)
    for yy in np.arange(start+.5,end,1):
        for xx in [-3.45,-1.15,1.15,3.45]:
            box('Deck panel',(xx,float(yy),.005),(2.25,.95,.10),deck,.018)
            for dx in [-1,1]:
                rod('Deck rivet',(xx+dx, float(yy)-.32,.056),(xx+dx,float(yy)-.32,.064),.035,steel,6)
        for xx in [-4.5,4.5]:
            box('Edge beacon',(xx,float(yy),.095),(.045,.52,.025),lightmat,.005)
    for xx in [-4.65,4.65]:
        box('Perimeter beam',(xx,(start+end)/2,-.22),(.17,end-start,.22),steel)
for yy in np.arange(7.15,15,.30):
    box('Bridge plank',(0,float(yy),0),(2.58,.27,.12),steel,.018)
for xx in [-1.15,1.15]:
    rod('Bridge cable',(xx,7,-.1),(xx,15,-.1),.045,dark)
for yy in [6.4,15.5,27.5,41.4]:
    for xx in np.arange(-4.2,4.3,.6):
        box('Hazard stripe',(float(xx),yy,.071),(.30,.35,.008),gold,.002)
box('Delivery inset',(0,26,.07),(4.8,2.6,.025),mint)
delivery_label=label('02 / CARGO',(0,24.6,.095),.32)
delivery_label.rotation_euler=(0,0,math.pi)
course=group_since(before,'CourseModules')
export('course_modules.glb',course)

before=set(scene.objects)
box('Cargo shell',(0,18,.54),(.9,.9,.9),gold,.065)
for x in [-.43,.43]:
    for y in [17.57,18.43]:
        box('Cargo bumper',(x,y,.54),(.13,.13,.98),rubber,.025)
for z in [.14,.90]:
    box('Cargo band',(0,18,z),(.94,.94,.07),steel)
label('CARGO',(0,18.456,.58),.13,dark)
for x in [-3.8,3.8]:
    box('Finish pillar',(x,42,2.8),(.32,.5,5.6),dark,.055)
    box('Finish light',(x,42.27,2.8),(.07,.035,5.2),lightmat,.012)
box('Finish crossbeam',(0,42,5.6),(8.2,.5,.6),steel,.07)
label('S I N G U L A R I T Y',(0,42.26,5.51),.26,dark)
rod('Sweeper mast',(0,34,.1),(0,34,2.8),.11,dark)
box('Sweeper arm',(0,34,1.5),(7.2,.24,.27),gold,.06)
finish=group_since(before,'CargoAndFinish')
export('cargo_and_finish.glb',finish)

def area(name,pos,power,size,color):
    bpy.ops.object.light_add(type='AREA',location=pos)
    obj=bpy.context.object
    obj.name=name
    obj.data.energy=power
    obj.data.shape='DISK'
    obj.data.size=size
    obj.data.color=color
    obj.rotation_euler=(Vector((0,0,1.6))-obj.location).to_track_quat('-Z','Y').to_euler()
area('Warm key',(3,4,8),1800,5,(1,.83,.67))
area('Cool rim',(-4,-3,6),2200,4,(.57,.75,1))
area('Soft fill',(-3,4,3),900,4,(.8,.93,1))
bpy.ops.object.light_add(type='SUN',rotation=(.4,-.5,-.5))
bpy.context.object.data.energy=2
bpy.context.object.data.angle=.15
bpy.ops.object.camera_add()
camera=bpy.context.object
scene.camera=camera
def shot(name,pos,target,lens,width,height):
    camera.location=pos
    camera.rotation_euler=(Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler()
    camera.data.lens=lens
    scene.render.resolution_x=width
    scene.render.resolution_y=height
    scene.render.filepath=str(OUT/name)
    bpy.ops.render.render(write_still=True)

manifest={'units':'metres','axes':'Blender X lateral, Y forward, Z up',
          'roles':[r for r,_ in roles], 'role_pivots':dict(roles),
          'exports':['singularity_body.glb','course_modules.glb','cargo_and_finish.glb'],
          'materials':[m.name for m in bpy.data.materials],
          'mesh_objects':len([o for o in scene.objects if o.type=='MESH'])}
(OUT/'asset-manifest.json').write_text(json.dumps(manifest,indent=2))
shot('body-preview.png',(4.5,7,3.6),(0,0,1.65),58,1100,1100)
camera.data.type='ORTHO'
camera.data.ortho_scale=64
shot('course-preview.png',(36,60,35),(0,21,0),46,1600,1000)
bpy.context.preferences.filepaths.save_version=0
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'singularity-art.blend'))
print('ASSET_BUILD_COMPLETE',OUT)
