# Run with:
# blender --background --python emberscope-starter-freeform-v1.blender.py
import json
import math
from pathlib import Path

import bpy
from mathutils import Matrix, Vector

HERE = Path(__file__).resolve().parent
data = json.loads((HERE / "emberscope-starter-freeform-v1.blender.json").read_text())

bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()

def mat(name, color, metallic=0.0, roughness=0.35, alpha=1.0):
    material = bpy.data.materials.new(name)
    material.use_nodes = True
    bsdf = material.node_tree.nodes.get("Principled BSDF")
    bsdf.inputs["Base Color"].default_value = color
    bsdf.inputs["Metallic"].default_value = metallic
    bsdf.inputs["Roughness"].default_value = roughness
    bsdf.inputs["Alpha"].default_value = alpha
    material.blend_method = "BLEND" if alpha < 1.0 else "OPAQUE"
    return material

mirror_mat = mat("LWIR reflective mirrors", (0.38, 0.70, 1.0, 1.0), metallic=0.85, roughness=0.18)
ray_mat = mat("schematic ray bundles", (1.0, 0.30, 0.18, 1.0), roughness=0.1)
aperture_mat = mat("entrance aperture", (0.92, 0.96, 1.0, 1.0), roughness=0.2)
sensor_mat = mat("sensor plane", (1.0, 0.80, 0.22, 0.74), metallic=0.2, roughness=0.2, alpha=0.74)
package_mat = mat("package allowances", (0.35, 0.74, 0.42, 0.35), roughness=0.45, alpha=0.35)
envelope_mat = mat("150 mm envelope reference", (1.0, 0.82, 0.24, 0.14), roughness=0.45, alpha=0.14)

def rotation_matrix(tip_deg, tilt_deg):
    tx = math.radians(tip_deg)
    ty = math.radians(tilt_deg)
    rx = Matrix(((1, 0, 0), (0, math.cos(tx), -math.sin(tx)), (0, math.sin(tx), math.cos(tx))))
    ry = Matrix(((math.cos(ty), 0, math.sin(ty)), (0, 1, 0), (-math.sin(ty), 0, math.cos(ty))))
    return ry @ rx

def poly_sag(x, y, surf):
    xn = x / surf["Rnorm"]
    yn = y / surf["Rnorm"]
    result = 0.0
    idx = 0
    for order in range(1, surf["poly_order"] + 1):
        for j in range(order + 1):
            i = order - j
            result += surf["poly_coeffs"][idx] * (xn ** i) * (yn ** j)
            idx += 1
    return result

def sag(x, y, surf):
    r2 = x * x + y * y
    R = surf["R"]
    k = surf["k"]
    denom = R * (1.0 + math.sqrt(max(0.0, 1.0 - (1.0 + k) * r2 / (R * R))))
    return r2 / denom + poly_sag(x, y, surf)

def add_mirror(surf):
    n_theta = 96
    n_rad = 24
    verts = []
    faces = []
    rot = rotation_matrix(surf["tip_deg"], surf["tilt_deg"])
    pos = Vector(surf["position"])
    for ir in range(n_rad):
        radius = (surf["diameter"] / 2.0) * ir / (n_rad - 1)
        for it in range(n_theta):
            theta = 2.0 * math.pi * it / n_theta
            x = radius * math.cos(theta)
            y = radius * math.sin(theta)
            z = sag(x, y, surf)
            verts.append(tuple(pos + rot @ Vector((x, y, z))))
    for ir in range(n_rad - 1):
        for it in range(n_theta):
            a = ir * n_theta + it
            b = ir * n_theta + ((it + 1) % n_theta)
            c = (ir + 1) * n_theta + ((it + 1) % n_theta)
            d = (ir + 1) * n_theta + it
            faces.append((a, b, c, d))
    mesh = bpy.data.meshes.new(surf["id"] + " mesh")
    mesh.from_pydata(verts, [], faces)
    mesh.update()
    obj = bpy.data.objects.new(surf["id"], mesh)
    bpy.context.collection.objects.link(obj)
    obj.data.materials.append(mirror_mat)

def add_box(name, center, size, material):
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=tuple(center))
    obj = bpy.context.object
    obj.name = name
    obj.dimensions = tuple(size)
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    obj.data.materials.append(material)
    return obj

def add_curve(name, points, material, bevel_depth):
    curve = bpy.data.curves.new(name, "CURVE")
    curve.dimensions = "3D"
    curve.resolution_u = 2
    curve.bevel_depth = bevel_depth
    poly = curve.splines.new("POLY")
    poly.points.add(len(points) - 1)
    for p, coords in zip(poly.points, points):
        p.co = (coords[0], coords[1], coords[2], 1.0)
    obj = bpy.data.objects.new(name, curve)
    bpy.context.collection.objects.link(obj)
    obj.data.materials.append(material)

for surf in data["surfaces"]:
    add_mirror(surf)

for component in data["components"]:
    geometry = component.get("geometry", {})
    if component["type"] == "window" or component["type"] in {"electronics", "structure"}:
        add_box(component["id"], geometry["center_mm"], geometry["size_mm"], package_mat)
    elif component["type"] == "envelope_reference":
        box = add_box(component["id"], geometry["center_mm"], geometry["size_mm"], envelope_mat)
        box.display_type = "WIRE"
    elif component["type"] == "baffle":
        bpy.ops.mesh.primitive_cylinder_add(
            vertices=64,
            radius=geometry["diameter_mm"] / 2.0,
            depth=geometry["length_mm"],
            location=tuple(geometry["center_mm"]),
        )
        obj = bpy.context.object
        obj.name = component["id"]
        obj.data.materials.append(package_mat)

aperture = data["entrance_aperture"]
bpy.ops.mesh.primitive_torus_add(
    major_radius=aperture["diameter"] / 2.0,
    minor_radius=0.35,
    location=tuple(aperture["center"]),
)
bpy.context.object.name = "entrance aperture"
bpy.context.object.data.materials.append(aperture_mat)

sensor = data["image_sensor"]
c = Vector(sensor["center"])
x = Vector(sensor["x_axis"]) * (sensor["width"] / 2.0)
y = Vector(sensor["y_axis"]) * (sensor["height"] / 2.0)
verts = [tuple(c - x - y), tuple(c + x - y), tuple(c + x + y), tuple(c - x + y)]
mesh = bpy.data.meshes.new("image sensor mesh")
mesh.from_pydata(verts, [], [(0, 1, 2, 3)])
mesh.update()
sensor_obj = bpy.data.objects.new("image sensor / focal plane", mesh)
bpy.context.collection.objects.link(sensor_obj)
sensor_obj.data.materials.append(sensor_mat)

for idx, ray in enumerate(data["ray_paths"]):
    add_curve(f"source_spec_ray_{idx:02d}", ray["points"], ray_mat, 0.16)

bpy.ops.object.light_add(type="AREA", location=(90, -130, 125))
light = bpy.context.object
light.name = "large softbox"
light.data.energy = 500
light.data.size = 130

bpy.ops.object.camera_add(location=(130, -160, 105), rotation=(math.radians(62), 0, math.radians(42)))
bpy.context.scene.camera = bpy.context.object
bpy.context.scene.render.engine = "CYCLES"
bpy.context.scene.cycles.samples = 96
bpy.context.scene.view_settings.view_transform = "Filmic"
bpy.context.scene.view_settings.look = "Medium High Contrast"
bpy.context.scene.render.resolution_x = 1800
bpy.context.scene.render.resolution_y = 1200
bpy.context.scene.render.filepath = str(HERE / "emberscope-starter-freeform-v1.blender-render.png")
bpy.ops.render.render(write_still=True)
