# build_orrery.py — models, materials, rigs and animates a brass orrery in
# Blender, then saves the .blend, exports a GLB (for the three.js viewer on
# demo.grn.dk/orrery/) and renders a 1080p hero still with Cycles.
#
#   blender -b -P build_orrery.py -- --out /path/to/outdir [--still] [--samples N]
#
# The turntable frames are rendered separately from the saved .blend
# (blender -b orrery.blend -a) so the long job can run in the background.
import bpy, bmesh, math, sys, os
from mathutils import Vector

argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
OUT = os.path.abspath(argv[argv.index('--out') + 1]) if '--out' in argv else os.getcwd()
STILL = '--still' in argv
SAMPLES = int(argv[argv.index('--samples') + 1]) if '--samples' in argv else 64
os.makedirs(OUT, exist_ok=True)

FPS, FRAMES = 24, 192          # 8 s seamless loop
TAU = math.tau

# ---------------------------------------------------------------- scene reset
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.fps = FPS
scene.frame_start, scene.frame_end = 1, FRAMES
bpy.context.preferences.edit.keyframe_new_interpolation_type = 'LINEAR'

MODEL = bpy.data.collections.new('Orrery')      # exported
STAGE = bpy.data.collections.new('Stage')       # render only
scene.collection.children.link(MODEL)
scene.collection.children.link(STAGE)

# ---------------------------------------------------------------- materials
def principled(name, color, metallic=0.0, rough=0.5, emission=None, strength=0.0, coat=0.0):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    b = m.node_tree.nodes['Principled BSDF']
    b.inputs['Base Color'].default_value = (*color, 1)
    b.inputs['Metallic'].default_value = metallic
    b.inputs['Roughness'].default_value = rough
    b.inputs['Coat Weight'].default_value = coat
    if emission:
        b.inputs['Emission Color'].default_value = (*emission, 1)
        b.inputs['Emission Strength'].default_value = strength
    return m

BRASS   = principled('Brass polished', (0.83, 0.58, 0.22), 1.0, 0.22)
BRONZE  = principled('Bronze gears',   (0.55, 0.36, 0.16), 1.0, 0.38)
STEEL   = principled('Blued steel',    (0.16, 0.18, 0.24), 1.0, 0.30)
WALNUT  = principled('Walnut',         (0.075, 0.032, 0.016), 0.0, 0.30, coat=0.7)
ENAMEL  = principled('Black enamel',   (0.02, 0.02, 0.025), 0.0, 0.18, coat=1.0)
SUN     = principled('Sun glass',      (1.0, 0.80, 0.45), 0.0, 0.15, emission=(1.0, 0.72, 0.30), strength=9.0)
PLANETS = {
    'Mercury': principled('Mercury enamel', (0.50, 0.48, 0.45), 0.0, 0.35, coat=1.0),
    'Venus':   principled('Venus enamel',   (0.93, 0.78, 0.50), 0.0, 0.30, coat=1.0),
    'Earth':   principled('Earth enamel',   (0.10, 0.32, 0.72), 0.0, 0.25, coat=1.0),
    'Mars':    principled('Mars enamel',    (0.72, 0.22, 0.10), 0.0, 0.30, coat=1.0),
    'Moon':    principled('Moon ivory',     (0.90, 0.88, 0.80), 0.0, 0.40, coat=0.5),
}

# ---------------------------------------------------------------- helpers
def link(obj, coll=MODEL):
    for c in obj.users_collection:
        c.objects.unlink(obj)
    coll.objects.link(obj)
    return obj

def smooth(obj, on=True):
    obj.data.polygons.foreach_set('use_smooth', [on] * len(obj.data.polygons))
    obj.data.update()

def bevel(obj, width=0.004, segments=3, harden=True, angle=math.radians(30)):
    m = obj.modifiers.new('Bevel', 'BEVEL')
    m.width, m.segments, m.limit_method, m.angle_limit = width, segments, 'ANGLE', angle
    m.harden_normals = harden
    return m

def cylinder(name, r, h, loc, mat, verts=64, bev=0.004, coll=MODEL):
    bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=r, depth=h, location=loc)
    o = bpy.context.object; o.name = name; o.data.materials.append(mat)
    smooth(o); 
    if bev: bevel(o, min(bev, h * 0.45, r * 0.45))
    return link(o, coll)

def sphere(name, r, loc, mat, seg=48, coll=MODEL):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=seg, ring_count=seg // 2, radius=r, location=loc)
    o = bpy.context.object; o.name = name; o.data.materials.append(mat); smooth(o)
    return link(o, coll)

def torus(name, R, r, loc, mat, coll=MODEL):
    bpy.ops.mesh.primitive_torus_add(major_radius=R, minor_radius=r, major_segments=64, minor_segments=16, location=loc)
    o = bpy.context.object; o.name = name; o.data.materials.append(mat); smooth(o)
    return link(o, coll)

def box(name, size, loc, mat, bev=0.004, coll=MODEL):
    bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
    o = bpy.context.object; o.name = name; o.scale = size
    bpy.ops.object.transform_apply(scale=True)
    o.data.materials.append(mat); smooth(o); bevel(o, bev, 3)
    return link(o, coll)

def empty(name, loc, coll=MODEL):
    o = bpy.data.objects.new(name, None); o.location = loc; o.empty_display_size = 0.05
    coll.objects.link(o); return o

def parent(child, par):
    child.parent = par
    child.matrix_parent_inverse = par.matrix_world.inverted()

def spin(obj, turns, phase=0.0):
    """Keyframe a linear Z rotation of `turns` full revolutions over the loop."""
    obj.rotation_mode = 'XYZ'
    obj.rotation_euler = (0, 0, phase)
    obj.keyframe_insert('rotation_euler', index=2, frame=1)
    obj.rotation_euler = (0, 0, phase + turns * TAU)
    obj.keyframe_insert('rotation_euler', index=2, frame=FRAMES + 1)

def gear(name, teeth, module, thickness, loc, mat, hub_r=None):
    """Involute-ish spur gear as a prism from a 2-D tooth profile."""
    rp = module * teeth / 2            # pitch radius
    ra, rr = rp + module, rp - 1.25 * module
    step = TAU / teeth
    prof = []
    for k in range(teeth):
        a = k * step
        for f, r in ((0.00, rr), (0.22, rr), (0.32, ra), (0.68, ra), (0.78, rr)):
            prof.append((r * math.cos(a + f * step), r * math.sin(a + f * step)))
    bm = bmesh.new()
    top = [bm.verts.new((x, y, thickness / 2)) for x, y in prof]
    bot = [bm.verts.new((x, y, -thickness / 2)) for x, y in prof]
    bm.faces.new(top); bm.faces.new(reversed(bot))
    n = len(prof)
    for i in range(n):
        bm.faces.new((bot[i], bot[(i + 1) % n], top[(i + 1) % n], top[i]))
    me = bpy.data.meshes.new(name); bm.to_mesh(me); bm.free()
    o = bpy.data.objects.new(name, me); o.location = loc
    me.materials.append(mat)
    bevel(o, min(0.0025, thickness * 0.3), 2, harden=False)
    link(o)
    # lightening cutouts: a ring of holes through the web, plus a hub
    if teeth >= 24:
        hub = cylinder(name + ' hub', rp * 0.28, thickness * 1.6, loc, BRASS, verts=48, bev=0.002)
        parent(hub, o)
        for j in range(6):
            a = j * TAU / 6
            hole_r = rp * 0.17
            ring = torus(name + f' spoke {j}', hole_r, thickness * 0.5,
                         (loc[0] + rp * 0.6 * math.cos(a), loc[1] + rp * 0.6 * math.sin(a), loc[2]), STEEL)
            parent(ring, o)
    else:
        hub = cylinder(name + ' hub', rp * 0.5, thickness * 1.8, loc, BRASS, verts=48, bev=0.002)
        parent(hub, o)
    return o, rp

# ---------------------------------------------------------------- base + cage
base = cylinder('Base plinth', 0.56, 0.16, (0, 0, 0.08), WALNUT, verts=96, bev=0.02)
step = cylinder('Base step', 0.50, 0.06, (0, 0, 0.19), WALNUT, verts=96, bev=0.015)
ring = torus('Base ring', 0.50, 0.012, (0, 0, 0.225), BRASS)
floor_plate = cylinder('Cage floor', 0.46, 0.02, (0, 0, 0.245), ENAMEL, verts=96, bev=0.005)
plate = cylinder('Top plate', 0.50, 0.04, (0, 0, 0.87), BRASS, verts=96, bev=0.008)
plate_rim = torus('Top plate rim', 0.50, 0.014, (0, 0, 0.89), BRASS)
dial = cylinder('Dial inlay', 0.42, 0.006, (0, 0, 0.893), ENAMEL, verts=96, bev=0.002)
for i in range(4):
    a = math.radians(45 + 90 * i)
    x, y = 0.43 * math.cos(a), 0.43 * math.sin(a)
    cylinder(f'Pillar {i}', 0.022, 0.60, (x, y, 0.555), BRASS, verts=32, bev=0.005)
    torus(f'Pillar base {i}', 0.032, 0.010, (x, y, 0.262), BRASS)
    torus(f'Pillar capital {i}', 0.032, 0.010, (x, y, 0.845), BRASS)
for i in range(24):
    a = i * TAU / 24
    sphere(f'Rivet {i}', 0.009, (0.47 * math.cos(a), 0.47 * math.sin(a), 0.893), BRASS, seg=16)
column = cylinder('Column', 0.034, 1.30, (0, 0, 0.90), BRASS, verts=48, bev=0.006)
torus('Column collar', 0.05, 0.012, (0, 0, 0.895), BRASS)

# ---------------------------------------------------------------- gear train
MOD, TH = 0.009, 0.024
spindle, r_s = gear('Spindle gear', 12, MOD, TH, (0, 0, 0.56), BRONZE)
spin(spindle, 8)
sats = ((24, 90, 0.56), (32, 210, 0.56), (48, 330, 0.56))
for teeth, ang, z in sats:
    rp = MOD * teeth / 2
    a = math.radians(ang)
    d = r_s + rp
    loc = (d * math.cos(a), d * math.sin(a), z)
    g, _ = gear(f'Satellite gear {teeth}', teeth, MOD, TH, loc, BRONZE)
    spin(g, -8 * 12 / teeth, phase=math.pi / teeth)
    # a second, smaller gear riding on the same axle at a different height
    g2, _ = gear(f'Satellite pinion {teeth}', 14, MOD, TH * 0.8, (loc[0], loc[1], z + 0.10 if teeth != 32 else z - 0.10), BRONZE)
    spin(g2, -8 * 12 / teeth, phase=math.pi / teeth)
    cylinder(f'Axle {teeth}', 0.011, 0.60, (loc[0], loc[1], 0.555), STEEL, verts=24, bev=0.002)
    torus(f'Axle collar {teeth}', 0.018, 0.006, (loc[0], loc[1], z + TH * 0.9), STEEL)

# ---------------------------------------------------------------- planets
PLANET_SPECS = [  # name, arm z, arm reach, post height, radius, turns
    ('Mars',    1.00, 0.95, 0.50, 0.045, 1),
    ('Earth',   1.12, 0.75, 0.38, 0.050, 2),
    ('Venus',   1.24, 0.55, 0.26, 0.046, 3),
    ('Mercury', 1.36, 0.36, 0.14, 0.030, 5),
]
for name, z, reach, post_h, rad, turns in PLANET_SPECS:
    pivot = empty(f'{name} pivot', (0, 0, z))
    spin(pivot, turns, phase=math.radians(hash(name) % 360))
    sleeve = cylinder(f'{name} sleeve', 0.052, 0.07, (0, 0, z), BRASS, verts=48, bev=0.006)
    parent(sleeve, pivot)
    bar = box(f'{name} arm', (reach - 0.03, 0.028, 0.014), ((reach + 0.03) / 2, 0, z), BRASS, bev=0.004)
    parent(bar, pivot)
    cap = cylinder(f'{name} arm cap', 0.03, 0.02, (reach, 0, z), BRASS, verts=32, bev=0.004)
    parent(cap, pivot)
    post = cylinder(f'{name} post', 0.009, post_h, (reach, 0, z + post_h / 2), STEEL, verts=24, bev=0.002)
    parent(post, pivot)
    pz = z + post_h + rad * 0.9
    planet = sphere(name, rad, (reach, 0, pz), PLANETS[name])
    parent(planet, pivot)
    moons = {'Earth': [(0.10, 0.014, 8)], 'Mars': [(0.085, 0.010, 5), (0.115, 0.008, -3)]}.get(name, [])
    for k, (mr, mrad, mturns) in enumerate(moons):
        mp = empty(f'{name} moon pivot {k}', (reach, 0, pz))
        parent(mp, pivot)
        spin(mp, mturns, phase=k * 2.1)
        marm = box(f'{name} moon arm {k}', (mr, 0.008, 0.005), (reach + mr / 2, 0, pz + rad + 0.01), STEEL, bev=0.001)
        parent(marm, mp)
        mpost = cylinder(f'{name} moon post {k}', 0.004, rad + 0.02, (reach, 0, pz + (rad + 0.02) / 2), STEEL, verts=16, bev=0.001)
        parent(mpost, mp)
        moon = sphere(f'{name} moon {k}', mrad, (reach + mr, 0, pz + rad + 0.01 + mrad + 0.004), PLANETS['Moon'], seg=24)
        parent(moon, mp)

# ---------------------------------------------------------------- sun
sun_pivot = empty('Sun pivot', (0, 0, 1.64))
spin(sun_pivot, 1)
sun = sphere('Sun', 0.13, (0, 0, 1.64), SUN, seg=64)
parent(sun, sun_pivot)
torus('Sun cradle', 0.135, 0.008, (0, 0, 1.585), BRASS)
cylinder('Finial', 0.012, 0.06, (0, 0, 1.80), BRASS, verts=24, bev=0.003)
sphere('Finial ball', 0.022, (0, 0, 1.845), BRASS, seg=24)
for i in range(8):     # sun rays: thin brass spikes
    a = i * TAU / 8
    r = box(f'Sun ray {i}', (0.09, 0.006, 0.006), (0.185 * math.cos(a), 0.185 * math.sin(a), 1.64), BRASS, bev=0.001)
    r.rotation_euler = (0, 0, a)
    parent(r, sun_pivot)

# ---------------------------------------------------------------- stage (render only)
bpy.ops.mesh.primitive_plane_add(size=14, location=(0, 0, 0))
floor = bpy.context.object; floor.name = 'Studio floor'
floor.data.materials.append(principled('Studio floor', (0.03, 0.032, 0.036), 0.0, 0.28, coat=0.8))
link(floor, STAGE)

def area(name, loc, energy, color, size, target=(0, 0, 0.9), coll=STAGE):
    ld = bpy.data.lights.new(name, 'AREA'); ld.energy, ld.color, ld.size = energy, color, size
    lo = bpy.data.objects.new(name, ld); lo.location = loc; coll.objects.link(lo)
    d = Vector(target) - Vector(loc)
    lo.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
    return lo
area('Key',  ( 2.4, -2.2, 3.2), 900, (1.0, 0.92, 0.82), 1.6)
area('Rim',  (-2.6,  2.0, 2.6), 500, (0.75, 0.85, 1.0), 1.2)
area('Fill', (-1.5, -2.8, 1.4), 180, (1.0, 1.0, 1.0), 2.5)
world = bpy.data.worlds.new('World'); scene.world = world; world.use_nodes = True
bg = world.node_tree.nodes['Background']; bg.inputs['Color'].default_value = (0.012, 0.013, 0.016, 1); bg.inputs['Strength'].default_value = 1.0

cam_pivot = empty('Camera pivot', (0, 0, 0), STAGE)
spin(cam_pivot, 1)
cam_data = bpy.data.cameras.new('Camera'); cam_data.lens = 45
cam_data.dof.use_dof = True; cam_data.dof.aperture_fstop = 4.0; cam_data.dof.focus_distance = 4.6
cam = bpy.data.objects.new('Camera', cam_data); STAGE.objects.link(cam)
cam.location = (0, -4.7, 2.15)
cam.rotation_euler = (Vector((0, 0, 0.93)) - Vector(cam.location)).to_track_quat('-Z', 'Y').to_euler()
parent(cam, cam_pivot)
scene.camera = cam

# ---------------------------------------------------------------- render settings
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = SAMPLES
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = True
scene.render.resolution_x, scene.render.resolution_y = 1280, 720
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = os.path.join(OUT, 'frames', 'orrery_')
scene.view_settings.view_transform = 'AgX'
scene.view_settings.look = 'AgX - Punchy'

# compositor bloom so the sun glows in the Cycles footage as well
scene.use_nodes = True
tree = scene.node_tree
for n in list(tree.nodes): tree.nodes.remove(n)
rl = tree.nodes.new('CompositorNodeRLayers'); comp = tree.nodes.new('CompositorNodeComposite')
glare = tree.nodes.new('CompositorNodeGlare')
for attr, val in (('glare_type', 'BLOOM'), ('quality', 'HIGH'), ('threshold', 1.2), ('mix', -0.3), ('size', 7)):
    try: setattr(glare, attr, val)
    except Exception as e: print('glare attr', attr, e)
for name, val in (('Threshold', 1.2), ('Strength', 0.35), ('Size', 0.9)):
    try: glare.inputs[name].default_value = val
    except Exception as e: print('glare input', name, e)
tree.links.new(rl.outputs['Image'], glare.inputs['Image'])
tree.links.new(glare.outputs['Image'], comp.inputs['Image'])

# ---------------------------------------------------------------- save, export, still
blend = os.path.join(OUT, 'orrery.blend')
bpy.ops.wm.save_as_mainfile(filepath=blend)

bpy.ops.object.select_all(action='DESELECT')
for o in MODEL.all_objects:
    o.select_set(True)
bpy.ops.export_scene.gltf(
    filepath=os.path.join(OUT, 'orrery.glb'), export_format='GLB',
    use_selection=True, export_apply=True, export_yup=True,
    export_animations=True, export_animation_mode='SCENE', export_force_sampling=True,
    export_cameras=False, export_lights=False, export_extras=False,
)

tris = sum(len(o.data.polygons) for o in MODEL.all_objects if o.type == 'MESH')
print(f'ORRERY: {len(list(MODEL.all_objects))} objects, {tris} polygons (before modifiers)')

if STILL:
    scene.frame_set(37)
    scene.render.resolution_x, scene.render.resolution_y = 1920, 1080
    scene.cycles.samples = max(SAMPLES, 128)
    scene.render.filepath = os.path.join(OUT, 'orrery-hero.png')
    bpy.ops.render.render(write_still=True)
    print('ORRERY: hero still written')
