Gunnar

mikronova/kyokkou

docker/agent/scenes/fluid_pipes.py

"""
Runs inside: blender --background --python fluid_pipes.py -- --config /in/config.json
bpy is always available here — it IS Blender's Python.
No external deps: json and math are stdlib.
"""
import bpy
import json
import math
import sys


def parse_config() -> dict:
    argv = sys.argv
    argv = argv[argv.index("--") + 1:] if "--" in argv else []
    path = "/in/config.json"
    for i, arg in enumerate(argv):
        if arg == "--config" and i + 1 < len(argv):
            path = argv[i + 1]
    with open(path) as f:
        return json.load(f)


METAL_COLORS = {
    "gold":   (1.0, 0.78, 0.18),
    "steel":  (0.70, 0.70, 0.75),
    "chrome": (0.90, 0.90, 0.95),
    "copper": (0.90, 0.44, 0.18),
}


def render(config: dict) -> None:
    output_path = config.get("output_path", "/out/render.png")
    samples     = int(config.get("render_samples", 128))
    metal       = config.get("metal", "chrome")

    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    scene.render.engine                     = "CYCLES"
    scene.cycles.samples                    = samples
    scene.render.resolution_x               = 1920
    scene.render.resolution_y               = 1080
    scene.render.filepath                   = output_path
    scene.render.image_settings.file_format = "PNG"

    # Pipe shell
    bpy.ops.mesh.primitive_cylinder_add(radius=0.30, depth=4.0, vertices=64)
    pipe = bpy.context.active_object
    pipe.name = "Pipe"
    sol = pipe.modifiers.new("Solidify", "SOLIDIFY")
    sol.thickness = 0.025
    _glass_mat(pipe, "GlassPipe", color=(0.85, 0.92, 1.0, 1.0), ior=1.46)

    # Liquid column
    bpy.ops.mesh.primitive_cylinder_add(radius=0.27, depth=4.0, vertices=64)
    liquid = bpy.context.active_object
    liquid.name = "Liquid"
    _metal_mat(liquid, "Liquid", metal)

    # Camera
    bpy.ops.object.camera_add(location=(3.5, -5.5, 2.2))
    cam = bpy.context.active_object
    cam.rotation_euler = (math.radians(68), 0.0, math.radians(32))
    scene.camera = cam

    # Key light
    bpy.ops.object.light_add(type="AREA", location=(4.0, -2.0, 6.0))
    key = bpy.context.active_object
    key.data.energy = 900
    key.data.size   = 3.0
    key.data.color  = (1.0, 0.95, 0.85)

    # Fill light
    bpy.ops.object.light_add(type="AREA", location=(-4.0, 2.0, 3.0))
    fill = bpy.context.active_object
    fill.data.energy = 250
    fill.data.size   = 6.0
    fill.data.color  = (0.8, 0.9, 1.0)

    bpy.ops.render.render(write_still=True)


def _glass_mat(obj, name: str, color: tuple, ior: float) -> None:
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    out   = nt.nodes.new("ShaderNodeOutputMaterial")
    glass = nt.nodes.new("ShaderNodeBsdfGlass")
    glass.inputs["Color"].default_value = color
    glass.inputs["IOR"].default_value   = ior
    nt.links.new(glass.outputs["BSDF"], out.inputs["Surface"])
    obj.data.materials.append(mat)


def _metal_mat(obj, name: str, metal: str) -> None:
    r, g, b = METAL_COLORS.get(metal, METAL_COLORS["chrome"])
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    out  = nt.nodes.new("ShaderNodeOutputMaterial")
    prin = nt.nodes.new("ShaderNodeBsdfPrincipled")
    prin.inputs["Base Color"].default_value = (r, g, b, 1.0)
    prin.inputs["Metallic"].default_value   = 1.0
    prin.inputs["Roughness"].default_value  = 0.04
    nt.links.new(prin.outputs["BSDF"], out.inputs["Surface"])
    obj.data.materials.append(mat)


if __name__ == "__main__":
    render(parse_config())