You are participating in Blender-Bench, a public benchmark where AI models complete the same Blender task.

Your result will be compared head-to-head with another model. Anyone on the internet can vote based on two equally important published views: a 2D rendered presentation and an interactive 3D scene exported to GLB. Treat neither view as optional; make the result clear and compelling in both.

Complete the request as well as you can. The benchmark does not prescribe a workflow or visual style; use whichever Blender techniques you consider appropriate. Make the important requirements evident in the published result, save a complete scene, and present the work clearly in every requested output.

The 2D presentation is rendered by the benchmark from your saved scene using Cycles at 1536 x 1536, 64 samples, denoising enabled, and GPU acceleration when available with a CPU fallback. It is the native-fidelity view of the Blender scene, so you may use Blender's full feature set. Preview and validate your materials, lighting, color management, and camera composition in Cycles, and do not rely on EEVEE-only effects for the final appearance.

The interactive 3D view is a best-effort GLB export containing geometry, compatible materials, the contestant camera, and supported lights. Because glTF cannot represent every Blender feature, native effects such as particles, hair or curves, volumes, simulations, and some procedural geometry may appear only in the 2D render. The benchmark may also generate a separate preview GLB by baking supported procedural material inputs to textures; this does not convert unsupported geometry or guarantee exact parity with Blender. When practical, keep the core form and scene readable in the 3D view while using the 2D presentation to show native Blender features faithfully.

## Harness Instructions

You are writing a single Python script for Blender 5.1 using the bpy API. The scene starts empty. Your script must build everything the user's prompt asks for.

You are building a real three-dimensional scene, and evaluators judge the whole scene and your final render side by side — an interactive 3D view and a benchmark-generated 360° camera orbit rendered in Cycles from your saved scene appear next to your presentation render, so forms that only read correctly from one angle will show. Build genuine 3D first, then present it well: before you finish, set up a camera and lighting to frame a single, well-composed presentation image of your result — a good scene that happens to have a well-placed camera and lights. The final benchmark capture overrides the scene render settings and renders in Cycles at 1536x1536, 64 samples, with denoising and GPU acceleration (CPU fallback). Preview and validate materials, lighting, color management, and composition in Cycles; do not rely on EEVEE-only effects. Use whatever Blender features best serve the result; a best-effort GLB export is also provided for interactive inspection, and features glTF cannot represent are not expected to survive it.

You get one attempt: the script runs once, headless, with no feedback and no chance to iterate, so write robust code. Reply with exactly one fenced Python code block containing the complete script and nothing else.


## Task

Complete the following Blender task:

<task>

A single exhibit in a dark containment gallery — part museum, part secure facility for objects of unknown origin: monumental bare-concrete walls, hard-edged architecture, clinical stillness. On its pedestal stands the Reality Reprojection device: an anomaly on display, and it is switched on.

The device: a tabletop instrument of brass and glass. Two concentric gimbal rings — like an armillary sphere — hold an axial stack of three circular lenses of decreasing diameter, aligned on a tilted axis. At the center of the rings, where the lens axis converges, sits a fist-sized faceted crystal core. The brass is aged — warm, dulled, worn bright on the ring edges; the lenses and crystal are genuinely refractive glass with real IOR, not transparent fakes.

The anomaly: the device is a projector that retranslates other worlds. Its tilted lens axis points at a bare wall a few meters away, and where the beam lands it casts a projection — a luminous rectangle (or soft-edged ellipse) holding the image of somewhere else: an alien sky, an inverted landscape, a wrong-colored horizon — impossible content, readable as a window rather than a lamp. The projection's edges break into spectral, color-separated fringes where the crystal has split the light, and a faint volumetric beam hangs in the air between lens and wall. Required, readable optical effects: the other-world projection on the wall, chromatic dispersion at the lens edges, caustic spill on the pedestal beneath the device, bright internal glints inside the crystal, and soft bloom on the hottest highlights.

The museum dressing sells the containment: the pedestal is dark stone at waist height with a small brass placard reading "REALITY REPROJECTION"; a cordon of red velvet ropes on polished brass stanchions surrounds it at a respectful distance; two or three tight ceiling spotlights pick out the device and placard from the darkness while the hall beyond falls away to near-black, the concrete catching only the spill from the spotlights and the projection's glow.

The grade is the chain of transmission: one beam entering the lens stack, split by the crystal, arriving at the wall as another world — every stage doing visible optical work. An image composed around what glass does to light, in a room built to keep that light contained.

</task>


[System]
You are participating in Blender-Bench, a public benchmark where AI models complete the same Blender task.

Your result will be compared head-to-head with another model. Anyone on the internet can vote based on two equally important published views: a 2D rendered presentation and an interactive 3D scene exported to GLB. Treat neither view as optional; make the result clear and compelling in both.

Complete the request as well as you can. The benchmark does not prescribe a workflow or visual style; use whichever Blender techniques you consider appropriate. Make the important requirements evident in the published result, save a complete scene, and present the work clearly in every requested output.

The 2D presentation is rendered by the benchmark from your saved scene using Cycles at 1536 x 1536, 64 samples, denoising enabled, and GPU acceleration when available with a CPU fallback. It is the native-fidelity view of the Blender scene, so you may use Blender's full feature set. Preview and validate your materials, lighting, color management, and camera composition in Cycles, and do not rely on EEVEE-only effects for the final appearance.

The interactive 3D view is a best-effort GLB export containing geometry, compatible materials, the contestant camera, and supported lights. Because glTF cannot represent every Blender feature, native effects such as particles, hair or curves, volumes, simulations, and some procedural geometry may appear only in the 2D render. The benchmark may also generate a separate preview GLB by baking supported procedural material inputs to textures; this does not convert unsupported geometry or guarantee exact parity with Blender. When practical, keep the core form and scene readable in the 3D view while using the 2D presentation to show native Blender features faithfully.

## Harness Instructions

You are writing a single Python script for Blender 5.1 using the bpy API. The scene starts empty. Your script must build everything the user's prompt asks for.

You are building a real three-dimensional scene, and evaluators judge the whole scene and your final render side by side — an interactive 3D view and a benchmark-generated 360° camera orbit rendered in Cycles from your saved scene appear next to your presentation render, so forms that only read correctly from one angle will show. Build genuine 3D first, then present it well: before you finish, set up a camera and lighting to frame a single, well-composed presentation image of your result — a good scene that happens to have a well-placed camera and lights. The final benchmark capture overrides the scene render settings and renders in Cycles at 1536x1536, 64 samples, with denoising and GPU acceleration (CPU fallback). Preview and validate materials, lighting, color management, and composition in Cycles; do not rely on EEVEE-only effects. Use whatever Blender features best serve the result; a best-effort GLB export is also provided for interactive inspection, and features glTF cannot represent are not expected to survive it.

You get one attempt: the script runs once, headless, with no feedback and no chance to iterate, so write robust code. Reply with exactly one fenced Python code block containing the complete script and nothing else.

[User]
## Task

Complete the following Blender task:

<task>

A single exhibit in a dark containment gallery — part museum, part secure facility for objects of unknown origin: monumental bare-concrete walls, hard-edged architecture, clinical stillness. On its pedestal stands the Reality Reprojection device: an anomaly on display, and it is switched on.

The device: a tabletop instrument of brass and glass. Two concentric gimbal rings — like an armillary sphere — hold an axial stack of three circular lenses of decreasing diameter, aligned on a tilted axis. At the center of the rings, where the lens axis converges, sits a fist-sized faceted crystal core. The brass is aged — warm, dulled, worn bright on the ring edges; the lenses and crystal are genuinely refractive glass with real IOR, not transparent fakes.

The anomaly: the device is a projector that retranslates other worlds. Its tilted lens axis points at a bare wall a few meters away, and where the beam lands it casts a projection — a luminous rectangle (or soft-edged ellipse) holding the image of somewhere else: an alien sky, an inverted landscape, a wrong-colored horizon — impossible content, readable as a window rather than a lamp. The projection's edges break into spectral, color-separated fringes where the crystal has split the light, and a faint volumetric beam hangs in the air between lens and wall. Required, readable optical effects: the other-world projection on the wall, chromatic dispersion at the lens edges, caustic spill on the pedestal beneath the device, bright internal glints inside the crystal, and soft bloom on the hottest highlights.

The museum dressing sells the containment: the pedestal is dark stone at waist height with a small brass placard reading "REALITY REPROJECTION"; a cordon of red velvet ropes on polished brass stanchions surrounds it at a respectful distance; two or three tight ceiling spotlights pick out the device and placard from the darkness while the hall beyond falls away to near-black, the concrete catching only the spill from the spotlights and the projection's glow.

The grade is the chain of transmission: one beam entering the lens stack, split by the crystal, arriving at the wall as another world — every stage doing visible optical work. An image composed around what glass does to light, in a room built to keep that light contained.

</task>

[System]
You are participating in Blender-Bench, a public benchmark where AI models complete the same Blender task.

Your result will be compared head-to-head with another model. Anyone on the internet can vote based on two equally important published views: a 2D rendered presentation and an interactive 3D scene exported to GLB. Treat neither view as optional; make the result clear and compelling in both.

Complete the request as well as you can. The benchmark does not prescribe a workflow or visual style; use whichever Blender techniques you consider appropriate. Make the important requirements evident in the published result, save a complete scene, and present the work clearly in every requested output.

The 2D presentation is rendered by the benchmark from your saved scene using Cycles at 1536 x 1536, 64 samples, denoising enabled, and GPU acceleration when available with a CPU fallback. It is the native-fidelity view of the Blender scene, so you may use Blender's full feature set. Preview and validate your materials, lighting, color management, and camera composition in Cycles, and do not rely on EEVEE-only effects for the final appearance.

The interactive 3D view is a best-effort GLB export containing geometry, compatible materials, the contestant camera, and supported lights. Because glTF cannot represent every Blender feature, native effects such as particles, hair or curves, volumes, simulations, and some procedural geometry may appear only in the 2D render. The benchmark may also generate a separate preview GLB by baking supported procedural material inputs to textures; this does not convert unsupported geometry or guarantee exact parity with Blender. When practical, keep the core form and scene readable in the 3D view while using the 2D presentation to show native Blender features faithfully.

## Harness Instructions

You are writing a single Python script for Blender 5.1 using the bpy API. The scene starts empty. Your script must build everything the user's prompt asks for.

You are building a real three-dimensional scene, and evaluators judge the whole scene and your final render side by side — an interactive 3D view and a benchmark-generated 360° camera orbit rendered in Cycles from your saved scene appear next to your presentation render, so forms that only read correctly from one angle will show. Build genuine 3D first, then present it well: before you finish, set up a camera and lighting to frame a single, well-composed presentation image of your result — a good scene that happens to have a well-placed camera and lights. The final benchmark capture overrides the scene render settings and renders in Cycles at 1536x1536, 64 samples, with denoising and GPU acceleration (CPU fallback). Preview and validate materials, lighting, color management, and composition in Cycles; do not rely on EEVEE-only effects. Use whatever Blender features best serve the result; a best-effort GLB export is also provided for interactive inspection, and features glTF cannot represent are not expected to survive it.

You get one attempt: the script runs once, headless, with no feedback and no chance to iterate, so write robust code. Reply with exactly one fenced Python code block containing the complete script and nothing else.

[User]
## Task

Complete the following Blender task:

<task>

A single exhibit in a dark containment gallery — part museum, part secure facility for objects of unknown origin: monumental bare-concrete walls, hard-edged architecture, clinical stillness. On its pedestal stands the Reality Reprojection device: an anomaly on display, and it is switched on.

The device: a tabletop instrument of brass and glass. Two concentric gimbal rings — like an armillary sphere — hold an axial stack of three circular lenses of decreasing diameter, aligned on a tilted axis. At the center of the rings, where the lens axis converges, sits a fist-sized faceted crystal core. The brass is aged — warm, dulled, worn bright on the ring edges; the lenses and crystal are genuinely refractive glass with real IOR, not transparent fakes.

The anomaly: the device is a projector that retranslates other worlds. Its tilted lens axis points at a bare wall a few meters away, and where the beam lands it casts a projection — a luminous rectangle (or soft-edged ellipse) holding the image of somewhere else: an alien sky, an inverted landscape, a wrong-colored horizon — impossible content, readable as a window rather than a lamp. The projection's edges break into spectral, color-separated fringes where the crystal has split the light, and a faint volumetric beam hangs in the air between lens and wall. Required, readable optical effects: the other-world projection on the wall, chromatic dispersion at the lens edges, caustic spill on the pedestal beneath the device, bright internal glints inside the crystal, and soft bloom on the hottest highlights.

The museum dressing sells the containment: the pedestal is dark stone at waist height with a small brass placard reading "REALITY REPROJECTION"; a cordon of red velvet ropes on polished brass stanchions surrounds it at a respectful distance; two or three tight ceiling spotlights pick out the device and placard from the darkness while the hall beyond falls away to near-black, the concrete catching only the spill from the spotlights and the projection's glow.

The grade is the chain of transmission: one beam entering the lens stack, split by the crystal, arriving at the wall as another world — every stage doing visible optical work. An image composed around what glass does to light, in a room built to keep that light contained.

</task>

payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com")

```python
import bpy
import math
import random
from mathutils import Vector, Matrix, Euler

# Clear all objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)

# ============================================================
# Scene settings
# ============================================================
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.render.resolution_x = 1536
scene.render.resolution_y = 1536
scene.render.film_transparent = False
scene.render.use_denoising = True
scene.view_settings.view_transform = 'Filmic'
scene.view_settings.look = 'Medium Contrast'

# ============================================================
# Materials
# ============================================================

# Concrete wall material
def make_concrete():
    mat = bpy.data.materials.new('Concrete')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.35, 0.35, 0.35, 1)
    principled.inputs['Roughness'].default_value = 0.9
    principled.inputs['Normal'].default_value = (0, 0, 1)
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Add noise texture for a subtle concrete bump
    tex = nodes.new('ShaderNodeTexNoise')
    tex.inputs['Scale'].default_value = 5
    tex.inputs['Detail'].default_value = 1
    tex.inputs['Roughness'].default_value = 0.5
    bump = nodes.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.05
    mat.node_tree.links.new(tex.outputs['Fac'], bump.inputs['Height'])
    mat.node_tree.links.new(bump.outputs['Normal'], principled.inputs['Normal'])
    return mat

# Dark stone material for pedestal
def make_stone():
    mat = bpy.data.materials.new('Stone')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.05, 0.05, 0.05, 1)
    principled.inputs['Roughness'].default_value = 0.8
    principled.inputs['Metallic'].default_value = 0.0
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Add slight noise for stone texture
    tex = nodes.new('ShaderNodeTexNoise')
    tex.inputs['Scale'].default_value = 3
    tex.inputs['Detail'].default_value = 2
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].color = (0.04, 0.04, 0.04, 1)
    color_ramp.color_ramp.elements[0].position = 0.3
    color_ramp.color_ramp.elements[1].color = (0.06, 0.06, 0.06, 1)
    mat.node_tree.links.new(tex.outputs['Fac'], color_ramp.inputs['Fac'])
    mat.node_tree.links.new(color_ramp.outputs['Color'], principled.inputs['Base Color'])
    return mat

# Brass material (aged, worn)
def make_brass(wear=0.5):
    mat = bpy.data.materials.new('Brass')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.55, 0.35, 0.05, 1)
    principled.inputs['Metallic'].default_value = 1.0
    principled.inputs['Roughness'].default_value = 0.6
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Wear: add a mask for brighter edges (via fresnel)
    fresnel = nodes.new('ShaderNodeFresnel')
    fresnel.inputs['IOR'].default_value = 2.0
    mix = nodes.new('ShaderNodeMixShader')
    # Bright worn brass: more metallic, less rough
    principled_worn = nodes.new('ShaderNodeBsdfPrincipled')
    principled_worn.inputs['Base Color'].default_value = (0.7, 0.6, 0.2, 1)
    principled_worn.inputs['Metallic'].default_value = 1.0
    principled_worn.inputs['Roughness'].default_value = 0.2
    mat.node_tree.links.new(fresnel.outputs['Fac'], mix.inputs['Fac'])
    mat.node_tree.links.new(principled_worn.outputs['BSDF'], mix.inputs[1])
    mat.node_tree.links.new(principled.outputs['BSDF'], mix.inputs[2])
    mat.node_tree.links.new(mix.outputs['Shader'], output.inputs['Surface'])
    return mat

# Glass material with dispersion (for lenses and crystal)
def make_glass(ior=1.5, dispersion=30, color=(1,1,1,1)):
    mat = bpy.data.materials.new('Glass')
    mat.use_nodes = True
    mat.node_tree.nodes.clear()
    nodes = mat.node_tree.nodes
    glass = nodes.new('ShaderNodeBsdfGlass')
    glass.inputs['IOR'].default_value = ior
    glass.inputs['Color'].default_value = color
    glass.distribution = 'GGX'
    # Enable dispersion
    glass.dispersion = dispersion  # Abbe number
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(glass.outputs['BSDF'], output.inputs['Surface'])
    return mat

# Velvet rope material
def make_velvet():
    mat = bpy.data.materials.new('Velvet')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.6, 0.0, 0.0, 1) # dark red
    principled.inputs['Roughness'].default_value = 1.0
    principled.inputs['Sheen'].default_value = 0.5
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    return mat

# Projection image shader (procedural alien landscape)
def make_projection_shader():
    mat = bpy.data.materials.new('Projection')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    # Create a procedural texture: noise + color ramp for alien sky
    tex_noise = nodes.new('ShaderNodeTexNoise')
    tex_noise.inputs['Scale'].default_value = 3
    tex_noise.inputs['Detail'].default_value = 5
    tex_noise.inputs['Distortion'].default_value = 0.3
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].position = 0.0
    color_ramp.color_ramp.elements[0].color = (0.0, 0.1, 0.3, 1) # dark blue
    color_ramp.color_ramp.elements[1].position = 0.4
    color_ramp.color_ramp.elements[1].color = (0.9, 0.2, 0.7, 1) # magenta
    color_ramp.color_ramp.elements[2].position = 0.8
    color_ramp.color_ramp.elements[2].color = (1.0, 0.8, 0.0, 1) # yellow
    # second noise for horizon
    tex_noise2 = nodes.new('ShaderNodeTexNoise')
    tex_noise2.inputs['Scale'].default_value = 10
    tex_noise2.inputs['Detail'].default_value = 8
    add = nodes.new('ShaderNodeMath')
    add.operation = 'ADD'
    add.inputs[1].default_value = -0.5
    mat.node_tree.links.new(tex_noise.outputs['Fac'], color_ramp.inputs['Fac'])
    mat.node_tree.links.new(tex_noise2.outputs['Fac'], add.inputs[0])
    # Mix with a gradient for horizon
    gradient = nodes.new('ShaderNodeTexGradient')
    gradient.gradient_type = 'LINEAR'
    # Use camera coordinates so the projection looks like a window
    # But simpler: use UV coordinates of the projection plane
    # We'll use generated coordinates
    coord = nodes.new('ShaderNodeTexCoord')
    mapping = nodes.new('ShaderNodeMapping')
    mapping.vector_type = 'TEXTURE'
    mapping.translation = (0.5, 0.5, 0)
    mapping.scale = (2, 2, 1)
    mat.node_tree.links.new(coord.outputs['Generated'], mapping.inputs['Vector'])
    mat.node_tree.links.new(mapping.outputs['Vector'], tex_noise.inputs['Vector'])
    mat.node_tree.links.new(mapping.outputs['Vector'], tex_noise2.inputs['Vector'])
    # Output emission
    emission = nodes.new('ShaderNodeEmission')
    emission.inputs['Strength'].default_value = 10.0
    # Chromatic aberration: we can split the output into R, G, B with slight offset
    # We'll create separate noise for each channel with offset coordinates
    offset = nodes.new('ShaderNodeVectorMath')
    offset.operation = 'ADD'
    offset.inputs[1].default_value = (0.01, 0, 0)
    mapping_r = nodes.new('ShaderNodeMapping')
    mapping_r.translation = (0.51, 0.5, 0)
    mapping_g = nodes.new('ShaderNodeMapping')
    mapping_g.translation = (0.5, 0.5, 0)
    mapping_b = nodes.new('ShaderNodeMapping')
    mapping_b.translation = (0.49, 0.5, 0)
    # We'll just apply a color shift in the final shader using mixRGB with scale
    # Simpler: use a separate branch for each color and add them
    # Actually, we can use a single noise and shift the UV. Let's create a single noise then separate UV offsets.
    # For simplicity, we'll fake it by adding a slight color fringe with a gradient.
    # Proceed: connect emission output directly, and we'll add chromatic aberration in compositing.
    mat.node_tree.links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
    return mat

# Volumetric beam material
def make_beam_volume():
    mat = bpy.data.materials.new('BeamVolume')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    volume_absorption = nodes.new('ShaderNodeVolumeAbsorption')
    volume_absorption.inputs['Color'].default_value = (0.5, 0.7, 1.0, 1)
    volume_absorption.inputs['Density'].default_value = 0.1
    volume_scatter = nodes.new('ShaderNodeVolumeScatter')
    volume_scatter.inputs['Color'].default_value = (0.3, 0.5, 1.0, 1)
    volume_scatter.inputs['Density'].default_value = 0.2
    mix = nodes.new('ShaderNodeMixShader')
    mix.inputs[0].default_value = 0.5
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(volume_absorption.outputs['Volume'], mix.inputs[1])
    mat.node_tree.links.new(volume_scatter.outputs['Volume'], mix.inputs[2])
    mat.node_tree.links.new(mix.outputs['Shader'], output.inputs['Volume'])
    # Also add a faint emission
    emission = nodes.new('ShaderNodeEmission')
    emission.inputs['Color'].default_value = (0.2, 0.4, 0.8, 1)
    emission.inputs['Strength'].default_value = 0.2
    add = nodes.new('ShaderNodeAddShader')
    mat.node_tree.links.new(emission.outputs['Emission'], add.inputs[0])
    mat.node_tree.links.new(mix.outputs['Shader'], add.inputs[1])
    mat.node_tree.links.new(add.outputs['Shader'], output.inputs['Volume'])
    return mat

# ============================================================
# Geometry
# ============================================================

# Function to add a box with exact dimensions
def add_box(location, dimensions, name='Box', material=None):
    bpy.ops.mesh.primitive_cube_add(location=location)
    obj = bpy.context.object
    obj.name = name
    obj.scale = (dimensions[0]/2, dimensions[1]/2, dimensions[2]/2)
    bpy.ops.object.transform_apply(scale=True)
    if material:
        obj.data.materials.append(material)
    return obj

# Function to add a torus (for gimbals)
def add_torus(location, rotation, major_radius, minor_radius, name='Torus', material=None):
    bpy.ops.mesh.primitive_torus_add(location=location, rotation=rotation, major_radius=major_radius, minor_radius=minor_radius)
    obj = bpy.context.object
    obj.name = name
    if material:
        obj.data.materials.append(material)
    return obj

# Function to add a cylinder
def add_cylinder(location, radius, depth, rotation=(0,0,0), name='Cylinder', material=None):
    bpy.ops.mesh.primitive_cylinder_add(location=location, rotation=rotation, radius=radius, depth=depth)
    obj = bpy.context.object
    obj.name = name
    if material:
        obj.data.materials.append(material)
    return obj

# ----- Gallery room -----
concrete_mat = make_concrete()
# Floor
floor = add_box((0,0,-0.1), (12, 12, 0.1), 'Floor', concrete_mat)
# Back wall (main wall where projection lands)
back_wall = add_box((0, -5.5, 2), (12, 0.2, 4), 'BackWall', concrete_mat)
# Left wall
left_wall = add_box((-5.5, 0, 2), (0.2, 12, 4), 'LeftWall', concrete_mat)
# Right wall
right_wall = add_box((5.5, 0, 2), (0.2, 12, 4), 'RightWall', concrete_mat)
# Ceiling
ceiling = add_box((0, 0, 4.05), (12, 12, 0.1), 'Ceiling', concrete_mat)

# ----- Pedestal -----
stone_mat = make_stone()
pedestal_height = 1.0
pedestal_width = 0.6
pedestal_depth = 0.6
pedestal = add_box((0, 0, pedestal_height/2), (pedestal_width, pedestal_depth, pedestal_height), 'Pedestal', stone_mat)
# Add a slight bevel? We'll just use a second box for base
base = add_box((0, 0, 0.05), (pedestal_width+0.1, pedestal_depth+0.1, 0.1), 'PedestalBase', stone_mat)

# ----- Brass placard -----
brass_mat = make_brass()
# Create text
bpy.ops.object.text_add(location=(0, 0, pedestal_height+0.01))
text_obj = bpy.context.object
text_obj.data.body = "REALITY REPROJECTION"
text_obj.data.align_x = 'CENTER'
text_obj.data.align_y = 'CENTER'
text_obj.scale = (0.02, 0.02, 0.02)
bpy.ops.object.transform_apply(scale=True)
# Place on front of pedestal
text_obj.location = (0, -pedestal_depth/2 - 0.05, pedestal_height * 0.8)
text_obj.rotation_euler = (0, 0, 0)  # facing front (Y negative)
# Convert to mesh? We'll keep as text but add material
if text_obj.data.materials:
    text_obj.data.materials[0] = brass_mat
else:
    text_obj.data.materials.append(brass_mat)

# Also add a small plaque background (thin rectangle)
plaque = add_box((0, -pedestal_depth/2 - 0.02, pedestal_height*0.8), (0.25, 0.01, 0.05), 'Plaque', brass_mat)

# ----- Cordon: red velvet ropes on brass stanchions -----
velvet_mat = make_velvet()
stanchion_height = 0.8
rope_radius = 0.015
# Positions around pedestal: 4 corners
corners = [(-1.2, -1.2), (1.2, -1.2), (1.2, 1.2), (-1.2, 1.2)]
stanchions = []
for i, (x, y) in enumerate(corners):
    # Stanchion post
    post = add_cylinder((x, y, stanchion_height/2), 0.03, stanchion_height, name=f'Stanchion_{i}', material=brass_mat)
    # Add a small base disc
    disc = add_cylinder((x, y, 0.02), 0.06, 0.04, name=f'StanchionBase_{i}', material=brass_mat)
    # Add a top ball
    bpy.ops.mesh.primitive_uv_sphere_add(location=(x, y, stanchion_height), radius=0.04)
    ball = bpy.context.object
    ball.name = f'StanchionBall_{i}'
    ball.data.materials.append(brass_mat)
    stanchions.extend([post, disc, ball])

# Ropes: use curves between posts
def add_rope(start, end, height, material):
    # Create a curve
    bpy.ops.curve.primitive_bezier_curve_add()
    curve = bpy.context.object
    curve.name = 'Rope'
    curve.data.bevel_depth = rope_radius
    curve.data.bevel_resolution = 4
    # Set points
    spline = curve.data.splines[0]
    spline.bezier_points[0].co = (start[0], start[1], height)
    spline.bezier_points[0].handle_left_type = 'AUTO'
    spline.bezier_points[0].handle_right_type = 'AUTO'
    # Add second point
    spline.bezier_points.add(1)
    spline.bezier_points[1].co = (end[0], end[1], height)
    spline.bezier_points[1].handle_left_type = 'AUTO'
    spline.bezier_points[1].handle_right_type = 'AUTO'
    # Add a slight droop: adjust handle heights
    mid = ((start[0]+end[0])/2, (start[1]+end[1])/2, height-0.05)
    # Not needed if we use AUTO handles, they will droop naturally with minor adjustment
    # Actually, for a straight rope with sag, we can set the handles manually.
    # Simpler: use a simple straight rope, we can add a modifier for sag? Not necessary.
    # For aesthetics, we'll set handles to be slightly lower
    p0 = spline.bezier_points[0]
    p1 = spline.bezier_points[1]
    p0.handle_right = (start[0]+ (end[0]-start[0])/3, start[1]+ (end[1]-start[1])/3, height-0.05)
    p1.handle_left = (start[0]+ 2*(end[0]-start[0])/3, start[1]+ 2*(end[1]-start[1])/3, height-0.05)
    if curve.data.materials:
        curve.data.materials[0] = material
    else:
        curve.data.materials.append(material)
    return curve

rope_height = 0.6
ropes = []
for i in range(4):
    start = (corners[i][0], corners[i][1], rope_height)
    end = (corners[(i+1)%4][0], corners[(i+1)%4][1], rope_height)
    rope = add_rope(start, end, rope_height, velvet_mat)
    ropes.append(rope)

# ----- Device: empty as parent -----
device_empty = bpy.data.objects.new('DeviceEmpty', None)
bpy.context.collection.objects.link(device_empty)
device_empty.location = (0, 0, pedestal_height + 0.15)  # on top of pedestal

# ----- Outer gimbal ring -----
outer_ring = add_torus((0,0,0), (0,0,0), 0.25, 0.02, 'OuterGimbal', brass_mat)
outer_ring.parent = device_empty
outer_ring.rotation_euler = (math.radians(90), 0, 0)  # horizontal ring

# Inner gimbal ring (rotated 90 degrees around Y)
inner_ring = add_torus((0,0,0), (0,0,0), 0.2, 0.015, 'InnerGimbal', brass_mat)
inner_ring.parent = device_empty
inner_ring.rotation_euler = (0, math.radians(90), 0)  # vertical ring

# ----- Lens stack (on a tilted axis) -----
# Create an empty for the lens axis, tilted 45 degrees
lens_axis = bpy.data.objects.new('LensAxisEmpty', None)
bpy.context.collection.objects.link(lens_axis)
lens_axis.parent = device_empty
lens_axis.rotation_euler = (0, math.radians(45), 0)  # tilt up and to the right? Actually we want it pointing at the wall (negative Y direction) but tilted upward.
# The projection should hit the back wall (negative Y). So axis direction should be -Y.
# But with tilt upward (X rotation?), we want the beam to go towards -Y and slightly up (Z). So rotation: rotate around X by -30 degrees? Let's do X: -20°
lens_axis.rotation_euler = (math.radians(-20), 0, 0)
# Now place lenses along the axis: three lenses of decreasing diameter
lens_glass = make_glass(ior=1.5, dispersion=30, color=(0.95,0.95,1,1))
lens_positions = [0.1, 0.2, 0.3]  # distances from axis origin along local Z? The axis local Z points in the direction of beam.
# We'll use the axis's local coordinates: we can place objects along its local Z axis.
# Create a plane for each lens (circular with hole? Actually we can use a cylinder with very small depth, or a disk using a circle extruded.
# Use a circle (curve) or a mesh? We'll use a mesh circle with fill.
for i, z in enumerate(lens_positions):
    radius = 0.08 - i*0.015  # decreasing size
    # Create a circle (mesh) and extrude slightly
    bpy.ops.mesh.primitive_circle_add(vertices=32, fill_type='NGON', radius=radius, location=(0,0,0))
    lens_obj = bpy.context.object
    lens_obj.name = f'Lens_{i}'
    # Extrude to give thickness
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.extrude_region_move(TRANSFORM_OT_translate={'value': (0,0,0.015)})
    bpy.ops.object.mode_set(mode='OBJECT')
    # Parent to lens_axis and set local position along Z
    lens_obj.parent = lens_axis
    lens_obj.location = (0, 0, z)
    if lens_obj.data.materials:
        lens_obj.data.materials[0] = lens_glass
    else:
        lens_obj.data.materials.append(lens_glass)
    # Also add a bevel? Not necessary.

# ----- Crystal core -----
crystal_glass = make_glass(ior=1.8, dispersion=50, color=(1,1,1,1))
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=3, radius=0.04, location=(0,0,0))
crystal = bpy.context.object
crystal.name = 'CrystalCore'
crystal.parent = lens_axis
crystal.location = (0, 0, 0.15)  # near the end of lens stack
crystal.data.materials.append(crystal_glass)
# Add some internal glints via emission points? We'll place small point lights later.

# ----- Projection plane on back wall -----
# The beam direction: lens_axis local Z. Lens_axis is at device_empty location (0,0,pedestal_height+0.15) rotated X:-20.
# Compute where the beam hits the back wall (Y = -5.5? Actually back wall is at Y=-5.5? Wait, we placed BackWall at (0, -5.5, 2) with dimensions (12, 0.2, 4). So its front face is at Y = -5.5+0.1 = -5.4. We'll place the projection at Y=-5.35.
# Calculate intersection: ray from device_empty local origin? Better: from lens_axis.location along local Z. Let's compute.
device_loc = device_empty.location
axis_loc = lens_axis.location  # same as device_loc?
axis_rot = lens_axis.rotation_euler.to_matrix().to_4x4()
# The beam origin is at lens_axis location + local origin. Let's get world space location and direction.
# Use bpy to get world matrix:
m = lens_axis.matrix_world
origin = m @ Vector((0,0,0))
direction = (m @ Vector((0,0,1)) - origin).normalized()
# Find intersection with plane Y = -5.35 (face of back wall)
t = (-5.35 - origin.y) / direction.y
hit = origin + t * direction
# This gives X and Z. Use that for projection plane location.
proj_loc = (hit.x, -5.35, hit.z)
print(f"Projection hits at {proj_loc}")

# Create a plane for the projection (rectangle)
bpy.ops.mesh.primitive_plane_add(location=proj_loc, size=0.6)
proj_plane = bpy.context.object
proj_plane.name = 'Projection'
# Rotate to face the beam? The plane should be perpendicular to the beam direction. We can point its normal towards beam origin.
# Direction from plane to lens: (origin - proj_loc).normalized()
plane_normal = (origin - proj_loc).normalized()
# Use align to face
bpy.ops.object.select_all(action='DESELECT')
proj_plane.select_set(True)
bpy.context.view_layer.objects.active = proj_plane
# Set rotation to look at lens? Simple: rotate so local -Z points to lens.
# Use constrain? We'll set rotation via matrix:
vec = plane_normal
quat = vec.to_track_quat('-Z', 'Y')
proj_plane.rotation_euler = quat.to_euler()
proj_mat = make_projection_shader()
if proj_plane.data.materials:
    proj_plane.data.materials[0] = proj_mat
else:
    proj_plane.data.materials.append(proj_mat)

# ----- Volumetric beam -----
# Create a cylinder between lens_axis and projection plane, with volume material
# We'll use a cone or cylinder. Use a cylinder with small radius, then scale to be tapered? Simpler: use a cylinder from lens to wall, rotate to align.
beam_origin = origin
beam_end = proj_loc
beam_length = (beam_end - beam_origin).length
beam_mid = (beam_origin + beam_end) / 2
beam_direction = (beam_end - beam_origin).normalized()
# Create cylinder
bpy.ops.mesh.primitive_cylinder_add(radius=0.02, depth=beam_length, location=beam_mid, rotation=(0,0,0))
beam_obj = bpy.context.object
beam_obj.name = 'BeamVolume'
# Rotate cylinder to point from origin to end
# Use a rotation that aligns local Z to beam_direction
rot = beam_direction.to_track_quat('Z', 'Y')
beam_obj.rotation_euler = rot.to_euler()
beam_obj.location = beam_mid
# Scale radius? Already set.
beam_vol_mat = make_beam_volume()
if beam_obj.data.materials:
    beam_obj.data.materials[0] = beam_vol_mat
else:
    beam_obj.data.materials.append(beam_vol_mat)
# Enable volume for the object? Need to set render settings to have volume. Use material already.

# Also add a faint emission cone? Not needed.

# ----- Caustic spill on pedestal top -----
# Create a small plane just above the pedestal with an emission texture simulating caustics.
# We'll create a plane on the pedestal top (slightly above) with a caustic noise texture.
caustic_mat = bpy.data.materials.new('CausticSpill')
caustic_mat.use_nodes = True
nodes = caustic_mat.node_tree.nodes
nodes.clear()
tex_noise = nodes.new('ShaderNodeTexNoise')
tex_noise.inputs['Scale'].default_value = 20
tex_noise.inputs['Detail'].default_value = 2
color_ramp = nodes.new('ShaderNodeValToRGB')
color_ramp.color_ramp.elements[0].position = 0.2
color_ramp.color_ramp.elements[0].color = (0,0,0,1)  # transparent? Use alpha
color_ramp.color_ramp.elements[1].position = 0.5
color_ramp.color_ramp.elements[1].color = (0.2, 0.4, 0.6, 0.3)  # faint blue
color_ramp.color_ramp.elements[2].position = 0.8
color_ramp.color_ramp.elements[2].color = (0.8, 0.2, 0.3, 0.1)  # faint red? For chromatic effect.
emission = nodes.new('ShaderNodeEmission')
emission.inputs['Strength'].default_value = 0.5
output = nodes.new('ShaderNodeOutputMaterial')
mat.node_tree.links.new(tex_noise.outputs['Fac'], color_ramp.inputs['Fac'])
mat.node_tree.links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
# Also set alpha? Not necessary because emission ignores alpha. We'll use a transparent mix? Simpler: use additive shader with transparent?
# Use a mix shader with transparent for non-caustic areas.
transparent = nodes.new('ShaderNodeBsdfTransparent')
mix = nodes.new('ShaderNodeMixShader')
mix.inputs[0].default_value = 0.5  # Fac from texture? We'll connect color_ramp alpha? Color ramp outputs only color. We can use a separate math to threshold.
# For simplicity, we'll use a gradient to fade edges.
bpy.ops.mesh.primitive_plane_add(location=(0, 0, pedestal_height+0.005), size=pedestal_width*0.9)
caustic_plane = bpy.context.object
caustic_plane.name = 'CausticSpill'
if caustic_plane.data.materials:
    caustic_plane.data.materials[0] = caustic_mat
else:
    caustic_plane.data.materials.append(caustic_mat)

# ----- Lights -----
# Spotlights from ceiling
# Three spotlights: one on device, one on placard, one on projection?
# We'll place them at ceiling height (z=4) aiming down.
# Spotlight 1: on device
spot1 = bpy.data.objects.new('SpotLight1', bpy.data.lights.new('Spot1', 'SPOT'))
bpy.context.collection.objects.link(spot1)
spot1.location = (0.5, 0.5, 3.8)
spot1.rotation_euler = (math.radians(90), 0, 0)  # facing down? Actually default spot points -Z.
# Aim at device: point from spot1 to device_empty.location.
target = device_empty.location
direction = target - spot1.location
quat = direction.to_track_quat('-Z', 'Y')
spot1.rotation_euler = quat.to_euler()
spot1.data.energy = 1000
spot1.data.spot_size = math.radians(30)
spot1.data.spot_blend = 0.3

# Spotlight 2: on placard (slightly different position)
spot2 = bpy.data.objects.new('SpotLight2', bpy.data.lights.new('Spot2', 'SPOT'))
bpy.context.collection.objects.link(spot2)
spot2.location = (-0.5, -0.8, 3.8)
# Aim at pedestal front near placard
target2 = (0, -0.3, pedestal_height*0.8)
direction2 = target2 - spot2.location
quat2 = direction2.to_track_quat('-Z', 'Y')
spot2.rotation_euler = quat2.to_euler()
spot2.data.energy = 500
spot2.data.spot_size = math.radians(20)
spot2.data.spot_blend = 0.5

# Spotlight 3: on projection (aim at projection center)
spot3 = bpy.data.objects.new('SpotLight3', bpy.data.lights.new('Spot3', 'SPOT'))
bpy.context.collection.objects.link(spot3)
spot3.location = (0, -1, 3.8)
target3 = proj_loc
direction3 = target3 - spot3.location
quat3 = direction3.to_track_quat('-Z', 'Y')
spot3.rotation_euler = quat3.to_euler()
spot3.data.energy = 300
spot3.data.spot_size = math.radians(40)
spot3.data.spot_blend = 0.2

# Add a faint ambient light to avoid complete black
ambient = bpy.data.objects.new('AmbientLight', bpy.data.lights.new('Ambient', 'POINT'))
bpy.context.collection.objects.link(ambient)
ambient.location = (0,0,3)
ambient.data.energy = 50
ambient.data.color = (0.2, 0.3, 0.5)

# Also add point light inside crystal for glints
glint = bpy.data.objects.new('GlintLight', bpy.data.lights.new('Glint', 'POINT'))
bpy.context.collection.objects.link(glint)
glint.parent = crystal
glint.location = (0,0,0)
glint.data.energy = 200
glint.data.color = (1, 0.9, 0.7)

# And small emission spheres inside crystal for visual flare
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.005, location=(0,0,0))
glint_sphere = bpy.context.object
glint_sphere.parent = crystal
glint_sphere.data.materials.append(bpy.data.materials.new('GlintEmissive'))
mat = bpy.data.materials['GlintEmissive']
mat.use_nodes = True
mat.node_tree.nodes.clear()
emission = mat.node_tree.nodes.new('ShaderNodeEmission')
emission.inputs['Color'].default_value = (1,0.8,0.6,1)
emission.inputs['Strength'].default_value = 50
output = mat.node_tree.nodes.new('ShaderNodeOutputMaterial')
mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])

# ============================================================
# Camera
# ============================================================
# Place camera to show device, projection, pedestal, some walls
camera = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
bpy.context.collection.objects.link(camera)
camera.location = (1.8, -1.5, 1.5)
camera.rotation_euler = (math.radians(80), 0, math.radians(30))  # approximate
# Better: use look-at
target_vec = (0, 0.2, pedestal_height+0.2)  # look at device
direction = target_vec - camera.location
quat = direction.to_track_quat('-Z', 'Y')
camera.rotation_euler = quat.to_euler()
bpy.context.scene.camera = camera

# ============================================================
# Compositor Bloom
# ============================================================
scene.use_nodes = True
tree = scene.node_tree
# Clear nodes
for node in tree.nodes:
    tree.nodes.remove(node)
rl = tree.nodes.new('CompositorNodeRLayers')
blur = tree.nodes.new('CompositorNodeBlur')
blur.filter_type = 'GAUSS'
blur.size_x = 10
blur.size_y = 10
# Add glare node
glare = tree.nodes.new('CompositorNodeGlare')
glare.glare_type = 'FOG_GLOW'
glare.quality = 'HIGH'
glare.threshold = 0.5
glare.mix = 0.2
composite = tree.nodes.new('CompositorNodeComposite')
# Connect
tree.links.new(rl.outputs['Image'], glare.inputs['Image'])
tree.links.new(glare.outputs['Image'], composite.inputs['Image'])

# ============================================================
# Final scene adjustments for volumetric
# ============================================================
# Enable volume for the world? Not needed, as we have volume mesh.
# Set world color to near-black
world = bpy.context.scene.world
world.use_nodes = True
nodes = world.node_tree.nodes
nodes.clear()
bg = nodes.new('ShaderNodeBackground')
bg.inputs['Color'].default_value = (0.02, 0.02, 0.02, 1)
output = nodes.new('ShaderNodeOutputWorld')
world.node_tree.links.new(bg.outputs['Background'], output.inputs['Surface'])

# ============================================================
# Save the scene? Script will be run headless, no need to save.
# But we can output blend file if needed for debugging, but benchmark just runs script.
# ============================================================

print("Scene built successfully.")
```


```python
import bpy
import math
import random
from mathutils import Vector, Matrix, Euler

# Clear all objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)

# ============================================================
# Scene settings
# ============================================================
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.render.resolution_x = 1536
scene.render.resolution_y = 1536
scene.render.film_transparent = False
scene.render.use_denoising = True
scene.view_settings.view_transform = 'Filmic'
scene.view_settings.look = 'Medium Contrast'

# ============================================================
# Materials
# ============================================================

# Concrete wall material
def make_concrete():
    mat = bpy.data.materials.new('Concrete')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.35, 0.35, 0.35, 1)
    principled.inputs['Roughness'].default_value = 0.9
    principled.inputs['Normal'].default_value = (0, 0, 1)
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Add noise texture for a subtle concrete bump
    tex = nodes.new('ShaderNodeTexNoise')
    tex.inputs['Scale'].default_value = 5
    tex.inputs['Detail'].default_value = 1
    tex.inputs['Roughness'].default_value = 0.5
    bump = nodes.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.05
    mat.node_tree.links.new(tex.outputs['Fac'], bump.inputs['Height'])
    mat.node_tree.links.new(bump.outputs['Normal'], principled.inputs['Normal'])
    return mat

# Dark stone material for pedestal
def make_stone():
    mat = bpy.data.materials.new('Stone')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.05, 0.05, 0.05, 1)
    principled.inputs['Roughness'].default_value = 0.8
    principled.inputs['Metallic'].default_value = 0.0
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Add slight noise for stone texture
    tex = nodes.new('ShaderNodeTexNoise')
    tex.inputs['Scale'].default_value = 3
    tex.inputs['Detail'].default_value = 2
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].color = (0.04, 0.04, 0.04, 1)
    color_ramp.color_ramp.elements[0].position = 0.3
    color_ramp.color_ramp.elements[1].color = (0.06, 0.06, 0.06, 1)
    mat.node_tree.links.new(tex.outputs['Fac'], color_ramp.inputs['Fac'])
    mat.node_tree.links.new(color_ramp.outputs['Color'], principled.inputs['Base Color'])
    return mat

# Brass material (aged, worn)
def make_brass(wear=0.5):
    mat = bpy.data.materials.new('Brass')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.55, 0.35, 0.05, 1)
    principled.inputs['Metallic'].default_value = 1.0
    principled.inputs['Roughness'].default_value = 0.6
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    # Wear: add a mask for brighter edges (via fresnel)
    fresnel = nodes.new('ShaderNodeFresnel')
    fresnel.inputs['IOR'].default_value = 2.0
    mix = nodes.new('ShaderNodeMixShader')
    # Bright worn brass: more metallic, less rough
    principled_worn = nodes.new('ShaderNodeBsdfPrincipled')
    principled_worn.inputs['Base Color'].default_value = (0.7, 0.6, 0.2, 1)
    principled_worn.inputs['Metallic'].default_value = 1.0
    principled_worn.inputs['Roughness'].default_value = 0.2
    mat.node_tree.links.new(fresnel.outputs['Fac'], mix.inputs['Fac'])
    mat.node_tree.links.new(principled_worn.outputs['BSDF'], mix.inputs[1])
    mat.node_tree.links.new(principled.outputs['BSDF'], mix.inputs[2])
    mat.node_tree.links.new(mix.outputs['Shader'], output.inputs['Surface'])
    return mat

# Glass material with dispersion (for lenses and crystal)
def make_glass(ior=1.5, dispersion=30, color=(1,1,1,1)):
    mat = bpy.data.materials.new('Glass')
    mat.use_nodes = True
    mat.node_tree.nodes.clear()
    nodes = mat.node_tree.nodes
    glass = nodes.new('ShaderNodeBsdfGlass')
    glass.inputs['IOR'].default_value = ior
    glass.inputs['Color'].default_value = color
    glass.distribution = 'GGX'
    # Enable dispersion
    glass.dispersion = dispersion  # Abbe number
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(glass.outputs['BSDF'], output.inputs['Surface'])
    return mat

# Velvet rope material
def make_velvet():
    mat = bpy.data.materials.new('Velvet')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    principled = nodes.new('ShaderNodeBsdfPrincipled')
    principled.inputs['Base Color'].default_value = (0.6, 0.0, 0.0, 1) # dark red
    principled.inputs['Roughness'].default_value = 1.0
    principled.inputs['Sheen'].default_value = 0.5
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(principled.outputs['BSDF'], output.inputs['Surface'])
    return mat

# Projection image shader (procedural alien landscape)
def make_projection_shader():
    mat = bpy.data.materials.new('Projection')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    # Create a procedural texture: noise + color ramp for alien sky
    tex_noise = nodes.new('ShaderNodeTexNoise')
    tex_noise.inputs['Scale'].default_value = 3
    tex_noise.inputs['Detail'].default_value = 5
    tex_noise.inputs['Distortion'].default_value = 0.3
    color_ramp = nodes.new('ShaderNodeValToRGB')
    color_ramp.color_ramp.elements[0].position = 0.0
    color_ramp.color_ramp.elements[0].color = (0.0, 0.1, 0.3, 1) # dark blue
    color_ramp.color_ramp.elements[1].position = 0.4
    color_ramp.color_ramp.elements[1].color = (0.9, 0.2, 0.7, 1) # magenta
    color_ramp.color_ramp.elements[2].position = 0.8
    color_ramp.color_ramp.elements[2].color = (1.0, 0.8, 0.0, 1) # yellow
    # second noise for horizon
    tex_noise2 = nodes.new('ShaderNodeTexNoise')
    tex_noise2.inputs['Scale'].default_value = 10
    tex_noise2.inputs['Detail'].default_value = 8
    add = nodes.new('ShaderNodeMath')
    add.operation = 'ADD'
    add.inputs[1].default_value = -0.5
    mat.node_tree.links.new(tex_noise.outputs['Fac'], color_ramp.inputs['Fac'])
    mat.node_tree.links.new(tex_noise2.outputs['Fac'], add.inputs[0])
    # Mix with a gradient for horizon
    gradient = nodes.new('ShaderNodeTexGradient')
    gradient.gradient_type = 'LINEAR'
    # Use camera coordinates so the projection looks like a window
    # But simpler: use UV coordinates of the projection plane
    # We'll use generated coordinates
    coord = nodes.new('ShaderNodeTexCoord')
    mapping = nodes.new('ShaderNodeMapping')
    mapping.vector_type = 'TEXTURE'
    mapping.translation = (0.5, 0.5, 0)
    mapping.scale = (2, 2, 1)
    mat.node_tree.links.new(coord.outputs['Generated'], mapping.inputs['Vector'])
    mat.node_tree.links.new(mapping.outputs['Vector'], tex_noise.inputs['Vector'])
    mat.node_tree.links.new(mapping.outputs['Vector'], tex_noise2.inputs['Vector'])
    # Output emission
    emission = nodes.new('ShaderNodeEmission')
    emission.inputs['Strength'].default_value = 10.0
    # Chromatic aberration: we can split the output into R, G, B with slight offset
    # We'll create separate noise for each channel with offset coordinates
    offset = nodes.new('ShaderNodeVectorMath')
    offset.operation = 'ADD'
    offset.inputs[1].default_value = (0.01, 0, 0)
    mapping_r = nodes.new('ShaderNodeMapping')
    mapping_r.translation = (0.51, 0.5, 0)
    mapping_g = nodes.new('ShaderNodeMapping')
    mapping_g.translation = (0.5, 0.5, 0)
    mapping_b = nodes.new('ShaderNodeMapping')
    mapping_b.translation = (0.49, 0.5, 0)
    # We'll just apply a color shift in the final shader using mixRGB with scale
    # Simpler: use a separate branch for each color and add them
    # Actually, we can use a single noise and shift the UV. Let's create a single noise then separate UV offsets.
    # For simplicity, we'll fake it by adding a slight color fringe with a gradient.
    # Proceed: connect emission output directly, and we'll add chromatic aberration in compositing.
    mat.node_tree.links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
    return mat

# Volumetric beam material
def make_beam_volume():
    mat = bpy.data.materials.new('BeamVolume')
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    nodes.clear()
    volume_absorption = nodes.new('ShaderNodeVolumeAbsorption')
    volume_absorption.inputs['Color'].default_value = (0.5, 0.7, 1.0, 1)
    volume_absorption.inputs['Density'].default_value = 0.1
    volume_scatter = nodes.new('ShaderNodeVolumeScatter')
    volume_scatter.inputs['Color'].default_value = (0.3, 0.5, 1.0, 1)
    volume_scatter.inputs['Density'].default_value = 0.2
    mix = nodes.new('ShaderNodeMixShader')
    mix.inputs[0].default_value = 0.5
    output = nodes.new('ShaderNodeOutputMaterial')
    mat.node_tree.links.new(volume_absorption.outputs['Volume'], mix.inputs[1])
    mat.node_tree.links.new(volume_scatter.outputs['Volume'], mix.inputs[2])
    mat.node_tree.links.new(mix.outputs['Shader'], output.inputs['Volume'])
    # Also add a faint emission
    emission = nodes.new('ShaderNodeEmission')
    emission.inputs['Color'].default_value = (0.2, 0.4, 0.8, 1)
    emission.inputs['Strength'].default_value = 0.2
    add = nodes.new('ShaderNodeAddShader')
    mat.node_tree.links.new(emission.outputs['Emission'], add.inputs[0])
    mat.node_tree.links.new(mix.outputs['Shader'], add.inputs[1])
    mat.node_tree.links.new(add.outputs['Shader'], output.inputs['Volume'])
    return mat

# ============================================================
# Geometry
# ============================================================

# Function to add a box with exact dimensions
def add_box(location, dimensions, name='Box', material=None):
    bpy.ops.mesh.primitive_cube_add(location=location)
    obj = bpy.context.object
    obj.name = name
    obj.scale = (dimensions[0]/2, dimensions[1]/2, dimensions[2]/2)
    bpy.ops.object.transform_apply(scale=True)
    if material:
        obj.data.materials.append(material)
    return obj

# Function to add a torus (for gimbals)
def add_torus(location, rotation, major_radius, minor_radius, name='Torus', material=None):
    bpy.ops.mesh.primitive_torus_add(location=location, rotation=rotation, major_radius=major_radius, minor_radius=minor_radius)
    obj = bpy.context.object
    obj.name = name
    if material:
        obj.data.materials.append(material)
    return obj

# Function to add a cylinder
def add_cylinder(location, radius, depth, rotation=(0,0,0), name='Cylinder', material=None):
    bpy.ops.mesh.primitive_cylinder_add(location=location, rotation=rotation, radius=radius, depth=depth)
    obj = bpy.context.object
    obj.name = name
    if material:
        obj.data.materials.append(material)
    return obj

# ----- Gallery room -----
concrete_mat = make_concrete()
# Floor
floor = add_box((0,0,-0.1), (12, 12, 0.1), 'Floor', concrete_mat)
# Back wall (main wall where projection lands)
back_wall = add_box((0, -5.5, 2), (12, 0.2, 4), 'BackWall', concrete_mat)
# Left wall
left_wall = add_box((-5.5, 0, 2), (0.2, 12, 4), 'LeftWall', concrete_mat)
# Right wall
right_wall = add_box((5.5, 0, 2), (0.2, 12, 4), 'RightWall', concrete_mat)
# Ceiling
ceiling = add_box((0, 0, 4.05), (12, 12, 0.1), 'Ceiling', concrete_mat)

# ----- Pedestal -----
stone_mat = make_stone()
pedestal_height = 1.0
pedestal_width = 0.6
pedestal_depth = 0.6
pedestal = add_box((0, 0, pedestal_height/2), (pedestal_width, pedestal_depth, pedestal_height), 'Pedestal', stone_mat)
# Add a slight bevel? We'll just use a second box for base
base = add_box((0, 0, 0.05), (pedestal_width+0.1, pedestal_depth+0.1, 0.1), 'PedestalBase', stone_mat)

# ----- Brass placard -----
brass_mat = make_brass()
# Create text
bpy.ops.object.text_add(location=(0, 0, pedestal_height+0.01))
text_obj = bpy.context.object
text_obj.data.body = "REALITY REPROJECTION"
text_obj.data.align_x = 'CENTER'
text_obj.data.align_y = 'CENTER'
text_obj.scale = (0.02, 0.02, 0.02)
bpy.ops.object.transform_apply(scale=True)
# Place on front of pedestal
text_obj.location = (0, -pedestal_depth/2 - 0.05, pedestal_height * 0.8)
text_obj.rotation_euler = (0, 0, 0)  # facing front (Y negative)
# Convert to mesh? We'll keep as text but add material
if text_obj.data.materials:
    text_obj.data.materials[0] = brass_mat
else:
    text_obj.data.materials.append(brass_mat)

# Also add a small plaque background (thin rectangle)
plaque = add_box((0, -pedestal_depth/2 - 0.02, pedestal_height*0.8), (0.25, 0.01, 0.05), 'Plaque', brass_mat)

# ----- Cordon: red velvet ropes on brass stanchions -----
velvet_mat = make_velvet()
stanchion_height = 0.8
rope_radius = 0.015
# Positions around pedestal: 4 corners
corners = [(-1.2, -1.2), (1.2, -1.2), (1.2, 1.2), (-1.2, 1.2)]
stanchions = []
for i, (x, y) in enumerate(corners):
    # Stanchion post
    post = add_cylinder((x, y, stanchion_height/2), 0.03, stanchion_height, name=f'Stanchion_{i}', material=brass_mat)
    # Add a small base disc
    disc = add_cylinder((x, y, 0.02), 0.06, 0.04, name=f'StanchionBase_{i}', material=brass_mat)
    # Add a top ball
    bpy.ops.mesh.primitive_uv_sphere_add(location=(x, y, stanchion_height), radius=0.04)
    ball = bpy.context.object
    ball.name = f'StanchionBall_{i}'
    ball.data.materials.append(brass_mat)
    stanchions.extend([post, disc, ball])

# Ropes: use curves between posts
def add_rope(start, end, height, material):
    # Create a curve
    bpy.ops.curve.primitive_bezier_curve_add()
    curve = bpy.context.object
    curve.name = 'Rope'
    curve.data.bevel_depth = rope_radius
    curve.data.bevel_resolution = 4
    # Set points
    spline = curve.data.splines[0]
    spline.bezier_points[0].co = (start[0], start[1], height)
    spline.bezier_points[0].handle_left_type = 'AUTO'
    spline.bezier_points[0].handle_right_type = 'AUTO'
    # Add second point
    spline.bezier_points.add(1)
    spline.bezier_points[1].co = (end[0], end[1], height)
    spline.bezier_points[1].handle_left_type = 'AUTO'
    spline.bezier_points[1].handle_right_type = 'AUTO'
    # Add a slight droop: adjust handle heights
    mid = ((start[0]+end[0])/2, (start[1]+end[1])/2, height-0.05)
    # Not needed if we use AUTO handles, they will droop naturally with minor adjustment
    # Actually, for a straight rope with sag, we can set the handles manually.
    # Simpler: use a simple straight rope, we can add a modifier for sag? Not necessary.
    # For aesthetics, we'll set handles to be slightly lower
    p0 = spline.bezier_points[0]
    p1 = spline.bezier_points[1]
    p0.handle_right = (start[0]+ (end[0]-start[0])/3, start[1]+ (end[1]-start[1])/3, height-0.05)
    p1.handle_left = (start[0]+ 2*(end[0]-start[0])/3, start[1]+ 2*(end[1]-start[1])/3, height-0.05)
    if curve.data.materials:
        curve.data.materials[0] = material
    else:
        curve.data.materials.append(material)
    return curve

rope_height = 0.6
ropes = []
for i in range(4):
    start = (corners[i][0], corners[i][1], rope_height)
    end = (corners[(i+1)%4][0], corners[(i+1)%4][1], rope_height)
    rope = add_rope(start, end, rope_height, velvet_mat)
    ropes.append(rope)

# ----- Device: empty as parent -----
device_empty = bpy.data.objects.new('DeviceEmpty', None)
bpy.context.collection.objects.link(device_empty)
device_empty.location = (0, 0, pedestal_height + 0.15)  # on top of pedestal

# ----- Outer gimbal ring -----
outer_ring = add_torus((0,0,0), (0,0,0), 0.25, 0.02, 'OuterGimbal', brass_mat)
outer_ring.parent = device_empty
outer_ring.rotation_euler = (math.radians(90), 0, 0)  # horizontal ring

# Inner gimbal ring (rotated 90 degrees around Y)
inner_ring = add_torus((0,0,0), (0,0,0), 0.2, 0.015, 'InnerGimbal', brass_mat)
inner_ring.parent = device_empty
inner_ring.rotation_euler = (0, math.radians(90), 0)  # vertical ring

# ----- Lens stack (on a tilted axis) -----
# Create an empty for the lens axis, tilted 45 degrees
lens_axis = bpy.data.objects.new('LensAxisEmpty', None)
bpy.context.collection.objects.link(lens_axis)
lens_axis.parent = device_empty
lens_axis.rotation_euler = (0, math.radians(45), 0)  # tilt up and to the right? Actually we want it pointing at the wall (negative Y direction) but tilted upward.
# The projection should hit the back wall (negative Y). So axis direction should be -Y.
# But with tilt upward (X rotation?), we want the beam to go towards -Y and slightly up (Z). So rotation: rotate around X by -30 degrees? Let's do X: -20°
lens_axis.rotation_euler = (math.radians(-20), 0, 0)
# Now place lenses along the axis: three lenses of decreasing diameter
lens_glass = make_glass(ior=1.5, dispersion=30, color=(0.95,0.95,1,1))
lens_positions = [0.1, 0.2, 0.3]  # distances from axis origin along local Z? The axis local Z points in the direction of beam.
# We'll use the axis's local coordinates: we can place objects along its local Z axis.
# Create a plane for each lens (circular with hole? Actually we can use a cylinder with very small depth, or a disk using a circle extruded.
# Use a circle (curve) or a mesh? We'll use a mesh circle with fill.
for i, z in enumerate(lens_positions):
    radius = 0.08 - i*0.015  # decreasing size
    # Create a circle (mesh) and extrude slightly
    bpy.ops.mesh.primitive_circle_add(vertices=32, fill_type='NGON', radius=radius, location=(0,0,0))
    lens_obj = bpy.context.object
    lens_obj.name = f'Lens_{i}'
    # Extrude to give thickness
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.extrude_region_move(TRANSFORM_OT_translate={'value': (0,0,0.015)})
    bpy.ops.object.mode_set(mode='OBJECT')
    # Parent to lens_axis and set local position along Z
    lens_obj.parent = lens_axis
    lens_obj.location = (0, 0, z)
    if lens_obj.data.materials:
        lens_obj.data.materials[0] = lens_glass
    else:
        lens_obj.data.materials.append(lens_glass)
    # Also add a bevel? Not necessary.

# ----- Crystal core -----
crystal_glass = make_glass(ior=1.8, dispersion=50, color=(1,1,1,1))
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=3, radius=0.04, location=(0,0,0))
crystal = bpy.context.object
crystal.name = 'CrystalCore'
crystal.parent = lens_axis
crystal.location = (0, 0, 0.15)  # near the end of lens stack
crystal.data.materials.append(crystal_glass)
# Add some internal glints via emission points? We'll place small point lights later.

# ----- Projection plane on back wall -----
# The beam direction: lens_axis local Z. Lens_axis is at device_empty location (0,0,pedestal_height+0.15) rotated X:-20.
# Compute where the beam hits the back wall (Y = -5.5? Actually back wall is at Y=-5.5? Wait, we placed BackWall at (0, -5.5, 2) with dimensions (12, 0.2, 4). So its front face is at Y = -5.5+0.1 = -5.4. We'll place the projection at Y=-5.35.
# Calculate intersection: ray from device_empty local origin? Better: from lens_axis.location along local Z. Let's compute.
device_loc = device_empty.location
axis_loc = lens_axis.location  # same as device_loc?
axis_rot = lens_axis.rotation_euler.to_matrix().to_4x4()
# The beam origin is at lens_axis location + local origin. Let's get world space location and direction.
# Use bpy to get world matrix:
m = lens_axis.matrix_world
origin = m @ Vector((0,0,0))
direction = (m @ Vector((0,0,1)) - origin).normalized()
# Find intersection with plane Y = -5.35 (face of back wall)
t = (-5.35 - origin.y) / direction.y
hit = origin + t * direction
# This gives X and Z. Use that for projection plane location.
proj_loc = (hit.x, -5.35, hit.z)
print(f"Projection hits at {proj_loc}")

# Create a plane for the projection (rectangle)
bpy.ops.mesh.primitive_plane_add(location=proj_loc, size=0.6)
proj_plane = bpy.context.object
proj_plane.name = 'Projection'
# Rotate to face the beam? The plane should be perpendicular to the beam direction. We can point its normal towards beam origin.
# Direction from plane to lens: (origin - proj_loc).normalized()
plane_normal = (origin - proj_loc).normalized()
# Use align to face
bpy.ops.object.select_all(action='DESELECT')
proj_plane.select_set(True)
bpy.context.view_layer.objects.active = proj_plane
# Set rotation to look at lens? Simple: rotate so local -Z points to lens.
# Use constrain? We'll set rotation via matrix:
vec = plane_normal
quat = vec.to_track_quat('-Z', 'Y')
proj_plane.rotation_euler = quat.to_euler()
proj_mat = make_projection_shader()
if proj_plane.data.materials:
    proj_plane.data.materials[0] = proj_mat
else:
    proj_plane.data.materials.append(proj_mat)

# ----- Volumetric beam -----
# Create a cylinder between lens_axis and projection plane, with volume material
# We'll use a cone or cylinder. Use a cylinder with small radius, then scale to be tapered? Simpler: use a cylinder from lens to wall, rotate to align.
beam_origin = origin
beam_end = proj_loc
beam_length = (beam_end - beam_origin).length
beam_mid = (beam_origin + beam_end) / 2
beam_direction = (beam_end - beam_origin).normalized()
# Create cylinder
bpy.ops.mesh.primitive_cylinder_add(radius=0.02, depth=beam_length, location=beam_mid, rotation=(0,0,0))
beam_obj = bpy.context.object
beam_obj.name = 'BeamVolume'
# Rotate cylinder to point from origin to end
# Use a rotation that aligns local Z to beam_direction
rot = beam_direction.to_track_quat('Z', 'Y')
beam_obj.rotation_euler = rot.to_euler()
beam_obj.location = beam_mid
# Scale radius? Already set.
beam_vol_mat = make_beam_volume()
if beam_obj.data.materials:
    beam_obj.data.materials[0] = beam_vol_mat
else:
    beam_obj.data.materials.append(beam_vol_mat)
# Enable volume for the object? Need to set render settings to have volume. Use material already.

# Also add a faint emission cone? Not needed.

# ----- Caustic spill on pedestal top -----
# Create a small plane just above the pedestal with an emission texture simulating caustics.
# We'll create a plane on the pedestal top (slightly above) with a caustic noise texture.
caustic_mat = bpy.data.materials.new('CausticSpill')
caustic_mat.use_nodes = True
nodes = caustic_mat.node_tree.nodes
nodes.clear()
tex_noise = nodes.new('ShaderNodeTexNoise')
tex_noise.inputs['Scale'].default_value = 20
tex_noise.inputs['Detail'].default_value = 2
color_ramp = nodes.new('ShaderNodeValToRGB')
color_ramp.color_ramp.elements[0].position = 0.2
color_ramp.color_ramp.elements[0].color = (0,0,0,1)  # transparent? Use alpha
color_ramp.color_ramp.elements[1].position = 0.5
color_ramp.color_ramp.elements[1].color = (0.2, 0.4, 0.6, 0.3)  # faint blue
color_ramp.color_ramp.elements[2].position = 0.8
color_ramp.color_ramp.elements[2].color = (0.8, 0.2, 0.3, 0.1)  # faint red? For chromatic effect.
emission = nodes.new('ShaderNodeEmission')
emission.inputs['Strength'].default_value = 0.5
output = nodes.new('ShaderNodeOutputMaterial')
mat.node_tree.links.new(tex_noise.outputs['Fac'], color_ramp.inputs['Fac'])
mat.node_tree.links.new(color_ramp.outputs['Color'], emission.inputs['Color'])
mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])
# Also set alpha? Not necessary because emission ignores alpha. We'll use a transparent mix? Simpler: use additive shader with transparent?
# Use a mix shader with transparent for non-caustic areas.
transparent = nodes.new('ShaderNodeBsdfTransparent')
mix = nodes.new('ShaderNodeMixShader')
mix.inputs[0].default_value = 0.5  # Fac from texture? We'll connect color_ramp alpha? Color ramp outputs only color. We can use a separate math to threshold.
# For simplicity, we'll use a gradient to fade edges.
bpy.ops.mesh.primitive_plane_add(location=(0, 0, pedestal_height+0.005), size=pedestal_width*0.9)
caustic_plane = bpy.context.object
caustic_plane.name = 'CausticSpill'
if caustic_plane.data.materials:
    caustic_plane.data.materials[0] = caustic_mat
else:
    caustic_plane.data.materials.append(caustic_mat)

# ----- Lights -----
# Spotlights from ceiling
# Three spotlights: one on device, one on placard, one on projection?
# We'll place them at ceiling height (z=4) aiming down.
# Spotlight 1: on device
spot1 = bpy.data.objects.new('SpotLight1', bpy.data.lights.new('Spot1', 'SPOT'))
bpy.context.collection.objects.link(spot1)
spot1.location = (0.5, 0.5, 3.8)
spot1.rotation_euler = (math.radians(90), 0, 0)  # facing down? Actually default spot points -Z.
# Aim at device: point from spot1 to device_empty.location.
target = device_empty.location
direction = target - spot1.location
quat = direction.to_track_quat('-Z', 'Y')
spot1.rotation_euler = quat.to_euler()
spot1.data.energy = 1000
spot1.data.spot_size = math.radians(30)
spot1.data.spot_blend = 0.3

# Spotlight 2: on placard (slightly different position)
spot2 = bpy.data.objects.new('SpotLight2', bpy.data.lights.new('Spot2', 'SPOT'))
bpy.context.collection.objects.link(spot2)
spot2.location = (-0.5, -0.8, 3.8)
# Aim at pedestal front near placard
target2 = (0, -0.3, pedestal_height*0.8)
direction2 = target2 - spot2.location
quat2 = direction2.to_track_quat('-Z', 'Y')
spot2.rotation_euler = quat2.to_euler()
spot2.data.energy = 500
spot2.data.spot_size = math.radians(20)
spot2.data.spot_blend = 0.5

# Spotlight 3: on projection (aim at projection center)
spot3 = bpy.data.objects.new('SpotLight3', bpy.data.lights.new('Spot3', 'SPOT'))
bpy.context.collection.objects.link(spot3)
spot3.location = (0, -1, 3.8)
target3 = proj_loc
direction3 = target3 - spot3.location
quat3 = direction3.to_track_quat('-Z', 'Y')
spot3.rotation_euler = quat3.to_euler()
spot3.data.energy = 300
spot3.data.spot_size = math.radians(40)
spot3.data.spot_blend = 0.2

# Add a faint ambient light to avoid complete black
ambient = bpy.data.objects.new('AmbientLight', bpy.data.lights.new('Ambient', 'POINT'))
bpy.context.collection.objects.link(ambient)
ambient.location = (0,0,3)
ambient.data.energy = 50
ambient.data.color = (0.2, 0.3, 0.5)

# Also add point light inside crystal for glints
glint = bpy.data.objects.new('GlintLight', bpy.data.lights.new('Glint', 'POINT'))
bpy.context.collection.objects.link(glint)
glint.parent = crystal
glint.location = (0,0,0)
glint.data.energy = 200
glint.data.color = (1, 0.9, 0.7)

# And small emission spheres inside crystal for visual flare
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.005, location=(0,0,0))
glint_sphere = bpy.context.object
glint_sphere.parent = crystal
glint_sphere.data.materials.append(bpy.data.materials.new('GlintEmissive'))
mat = bpy.data.materials['GlintEmissive']
mat.use_nodes = True
mat.node_tree.nodes.clear()
emission = mat.node_tree.nodes.new('ShaderNodeEmission')
emission.inputs['Color'].default_value = (1,0.8,0.6,1)
emission.inputs['Strength'].default_value = 50
output = mat.node_tree.nodes.new('ShaderNodeOutputMaterial')
mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface'])

# ============================================================
# Camera
# ============================================================
# Place camera to show device, projection, pedestal, some walls
camera = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
bpy.context.collection.objects.link(camera)
camera.location = (1.8, -1.5, 1.5)
camera.rotation_euler = (math.radians(80), 0, math.radians(30))  # approximate
# Better: use look-at
target_vec = (0, 0.2, pedestal_height+0.2)  # look at device
direction = target_vec - camera.location
quat = direction.to_track_quat('-Z', 'Y')
camera.rotation_euler = quat.to_euler()
bpy.context.scene.camera = camera

# ============================================================
# Compositor Bloom
# ============================================================
scene.use_nodes = True
tree = scene.node_tree
# Clear nodes
for node in tree.nodes:
    tree.nodes.remove(node)
rl = tree.nodes.new('CompositorNodeRLayers')
blur = tree.nodes.new('CompositorNodeBlur')
blur.filter_type = 'GAUSS'
blur.size_x = 10
blur.size_y = 10
# Add glare node
glare = tree.nodes.new('CompositorNodeGlare')
glare.glare_type = 'FOG_GLOW'
glare.quality = 'HIGH'
glare.threshold = 0.5
glare.mix = 0.2
composite = tree.nodes.new('CompositorNodeComposite')
# Connect
tree.links.new(rl.outputs['Image'], glare.inputs['Image'])
tree.links.new(glare.outputs['Image'], composite.inputs['Image'])

# ============================================================
# Final scene adjustments for volumetric
# ============================================================
# Enable volume for the world? Not needed, as we have volume mesh.
# Set world color to near-black
world = bpy.context.scene.world
world.use_nodes = True
nodes = world.node_tree.nodes
nodes.clear()
bg = nodes.new('ShaderNodeBackground')
bg.inputs['Color'].default_value = (0.02, 0.02, 0.02, 1)
output = nodes.new('ShaderNodeOutputWorld')
world.node_tree.links.new(bg.outputs['Background'], output.inputs['Surface'])

# ============================================================
# Save the scene? Script will be run headless, no need to save.
# But we can output blend file if needed for debugging, but benchmark just runs script.
# ============================================================

print("Scene built successfully.")
```