File size: 35,479 Bytes
4020561 0f30926 4020561 acee7fc 4020561 2ebe6b8 9ec1838 4020561 ff5b293 4020561 b433cba 4020561 b433cba 4020561 2ebe6b8 4020561 2ebe6b8 1508f86 2ebe6b8 1508f86 2ebe6b8 d2e7c8b 4020561 0f30926 4020561 0f30926 52d8f84 0f30926 4020561 e8ac225 4020561 da41a53 4020561 9ec1838 e8ac225 4020561 183027a 4020561 2ebe6b8 4020561 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 | #!/usr/bin/env python3
"""Molecular Gigafactory β Procedural Blender Scene Generator.
Usage:
# Test render single frame at 480p
blender --background --python mol_giga_scene.py -- --test --frame 500
# Render all phases at production resolution
blender --background --python mol_giga_scene.py -- --render-all --phase all
# Render specific phase
blender --background --python mol_giga_scene.py -- --render-all --phase A
# Render with custom resolution
blender --background --python mol_giga_scene.py -- --render-all --res 720 --phase all
"""
import bpy
import math
import mathutils
import os
import sys
import json
import time
import shutil
import argparse
from pathlib import Path
# ---------------------------------------------------------------------------
# CONFIGURATION
# ---------------------------------------------------------------------------
CONFIG = {
"output_dir": "/tmp/output",
"progress_file": "/tmp/output/progress.json",
"resolution_x": 1280, # 720p default for v0.01
"resolution_y": 720,
"fps": 24,
"samples": 64,
"rack_count": 5, # v0.01: 5 racks
"rack_rows": 13,
"rack_cols": 11,
"rack_height": 2.2,
"rack_width": 1.0,
"rack_depth": 0.8,
"room_width": 12.0,
"room_depth": 10.0,
"room_height": 3.5,
}
# Animation phase definitions (frames for each phase, 24fps, 45s total = 1080 frames)
PHASES = {
"A": {"name": "Exterior fly-in", "start": 1, "end": 240, "desc": "0-10s approach"},
"B": {"name": "Roof cutaway", "start": 241, "end": 480, "desc": "10-20s descend"},
"C": {"name": "Rack reveal", "start": 481, "end": 960, "desc": "20-40s interior"},
"D": {"name": "Detail annotations", "start": 961, "end": 1320, "desc": "40-55s close-up"},
"E": {"name": "Final hold", "start": 1321, "end": 1440, "desc": "55-60s settle"},
}
# ---------------------------------------------------------------------------
# CLI ARGUMENTS
# ---------------------------------------------------------------------------
def parse_args():
"""Parse command-line arguments after '--'."""
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
parser = argparse.ArgumentParser(description="Molecular Gigafactory Scene Generator")
parser.add_argument("--test", action="store_true", help="Test render single frame")
parser.add_argument("--frame", type=int, default=500, help="Frame to test-render")
parser.add_argument("--render-all", action="store_true", help="Render full animation")
parser.add_argument("--phase", type=str, default="all", help="Phase to render (A/B/C/D/E/all)")
parser.add_argument("--res", type=int, default=None, help="Resolution (720 or 1080)")
parser.add_argument("--samples", type=int, default=None, help="Override sample count")
parser.add_argument("--output", type=str, default=None, help="Override output directory")
parser.add_argument("--cpu", action="store_true", help="Use CPU rendering (avoids GPU kernel hangs)")
parser.add_argument("--smoke", action="store_true", help="Minimal 64x64 1-sample render test")
return parser.parse_args(argv)
# ---------------------------------------------------------------------------
# UTILITIES
# ---------------------------------------------------------------------------
def make_material(name, color_hex, metallic=0.0, roughness=0.5, emit_strength=0.0, emit_hex=None, alpha=1.0):
"""Create a PBR material from hex colors matching the apex theme palette."""
def hex_to_rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
nodes.clear()
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.inputs["Base Color"].default_value = (*hex_to_rgb(color_hex), 1.0)
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Emission Strength"].default_value = emit_strength
if emit_hex:
# Blender 3.6 uses "Emission", Blender 4.0 uses "Emission Color"
emit_input = "Emission" if bpy.app.version[0] < 4 else "Emission Color"
bsdf.inputs[emit_input].default_value = (*hex_to_rgb(emit_hex), 1.0)
if alpha < 1.0:
mat.blend_method = 'BLEND'
bsdf.inputs["Alpha"].default_value = alpha
out = nodes.new("ShaderNodeOutputMaterial")
mat.node_tree.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def _detach_from_collections(obj):
"""Unlink from all collections so caller can link where needed."""
for coll in list(obj.users_collection):
coll.objects.unlink(obj)
def add_box(name, half_extents, location=(0, 0, 0)):
"""Add a box primitive and return it (not linked to any collection)."""
bpy.ops.mesh.primitive_cube_add(size=1)
obj = bpy.context.object
obj.name = name
obj.scale = half_extents
obj.location = location
_detach_from_collections(obj)
return obj
def add_cylinder(name, radius, depth, location=(0, 0, 0), rotation=(0, 0, 0)):
"""Add a cylinder primitive (not linked to any collection)."""
bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=depth, location=location)
obj = bpy.context.object
obj.name = name
obj.rotation_euler = rotation
_detach_from_collections(obj)
return obj
def add_sphere(name, radius, location=(0, 0, 0)):
"""Add a UV sphere (not linked to any collection)."""
bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=location)
obj = bpy.context.object
obj.name = name
_detach_from_collections(obj)
return obj
def save_progress(data):
"""Write progress JSON for crash recovery."""
os.makedirs(os.path.dirname(CONFIG["progress_file"]), exist_ok=True)
with open(CONFIG["progress_file"], "w") as f:
json.dump(data, f)
def load_progress():
"""Read progress JSON, return None if not found."""
try:
with open(CONFIG["progress_file"]) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
# ---------------------------------------------------------------------------
# 1. CLEAR SCENE
# ---------------------------------------------------------------------------
def clear_scene():
"""Remove all objects from the default scene."""
bpy.ops.wm.read_factory_settings(use_empty=True)
# Set render settings
scene = bpy.context.scene
scene.render.resolution_x = CONFIG["resolution_x"]
scene.render.resolution_y = CONFIG["resolution_y"]
scene.render.resolution_percentage = 100
scene.render.fps = CONFIG["fps"]
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
scene.frame_start = 1
scene.frame_end = 1440
# ---------------------------------------------------------------------------
# 2. MATERIALS
# ---------------------------------------------------------------------------
def create_all_materials():
"""Create all shared materials, return a dict for easy lookup."""
mats = {}
mats["pcb"] = make_material("PCB", "#1a5c1a", roughness=0.6)
mats["case"] = make_material("Case", "#2a2a2a", metallic=0.2, roughness=0.7)
mats["chip"] = make_material("Chip", "#222222", metallic=0.5, roughness=0.3)
mats["silver"] = make_material("Silver", "#c0c0c0", metallic=0.9, roughness=0.15)
mats["copper"] = make_material("Copper", "#b87333", metallic=0.8, roughness=0.2)
mats["battery"] = make_material("Battery", "#335599", metallic=0.3, roughness=0.4)
mats["led"] = make_material("LED", "#22c55e", emit_strength=5.0, emit_hex="#22c55e")
mats["flow"] = make_material("FlowCell", "#ccddff", metallic=0.0, roughness=0.1, alpha=0.7)
mats["rack"] = make_material("Rack", "#333333", metallic=0.7, roughness=0.4)
mats["floor"] = make_material("Floor", "#0d0d0d", roughness=0.8)
mats["wall"] = make_material("Wall", "#1a1a1a", roughness=0.9)
mats["ceiling"] = make_material("Ceiling", "#222222", roughness=0.8)
mats["cable"] = make_material("Cable", "#111111", roughness=0.6)
mats["trace"] = make_material("Trace", "#4ade80", emit_strength=2.0, emit_hex="#4ade80")
mats["wireframe"]= make_material("Wireframe","#4ade80", emit_strength=1.5, emit_hex="#4ade80")
mats["text_anno"]= make_material("Annotation","#4ade80", emit_strength=3.0, emit_hex="#4ade80")
return mats
# ---------------------------------------------------------------------------
# 3. MR1 DEVICE MODELS (3 LODs)
# ---------------------------------------------------------------------------
def build_mr1_lod0():
"""High-detail MR1 device (~8k tris). Returns a Collection."""
col = bpy.data.collections.new("MR1_LOD0")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
# PCB base
pcb = add_box("PCB", (0.070, 0.035, 0.0008), (0, 0, 0.0008))
pcb.data.materials.append(mats["pcb"])
col.objects.link(pcb)
# ESP32-S3
esp = add_box("ESP32", (0.009, 0.009, 0.0015), (0.050, 0.015, 0.0023))
esp.data.materials.append(mats["chip"])
col.objects.link(esp)
# LMP7721 preamp
lmp = add_box("LMP7721", (0.0025, 0.0025, 0.00075), (-0.010, 0.000, 0.0018))
lmp.data.materials.append(mats["silver"])
col.objects.link(lmp)
# ADC
adc = add_box("MAX11169", (0.003, 0.003, 0.00075), (0.010, -0.010, 0.0018))
adc.data.materials.append(mats["chip"])
col.objects.link(adc)
# DAC
dac = add_box("MCP4822", (0.003, 0.003, 0.00075), (0.020, -0.010, 0.0018))
dac.data.materials.append(mats["chip"])
col.objects.link(dac)
# 6 batteries
for i in range(6):
b = add_cylinder(f"Batt{i+1}", 0.009, 0.065,
(-0.045 + i * 0.018, -0.025, 0.034),
(0, math.radians(90), 0))
b.data.materials.append(mats["battery"])
col.objects.link(b)
# Flow cell
flow = add_box("FlowCell", (0.010, 0.005, 0.0025), (-0.060, 0.0, 0.003))
flow.data.materials.append(mats["flow"])
col.objects.link(flow)
# Copper shield can (wireframe box over preamp)
shield = add_box("ShieldCan", (0.012, 0.010, 0.005), (-0.010, 0.0, 0.005))
shield.data.materials.append(mats["copper"])
mod = shield.modifiers.new("Wireframe", 'WIREFRAME')
mod.thickness = 0.0003
col.objects.link(shield)
# LED indicator
led = add_sphere("LED", 0.0015, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
# Ag/AgCl electrodes
for j, xo in enumerate([-0.002, 0.002]):
e = add_cylinder(f"Elec{j+1}", 0.00025, 0.006, (-0.060 + xo, 0.0, 0.006))
e.data.materials.append(mats["silver"])
col.objects.link(e)
# Case shell (wireframe display for now, solid in render)
case = add_box("CaseShell", (0.075, 0.039, 0.009), (0, 0, 0.001))
case.data.materials.append(mats["case"])
mod = case.modifiers.new("Solidify", 'SOLIDIFY')
mod.thickness = 0.001
mod.offset = 1
col.objects.link(case)
return col
def build_mr1_lod1():
"""Medium-detail MR1 (~1.2k tris). Simplified: case + PCB block + batteries."""
col = bpy.data.collections.new("MR1_LOD1")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
# Main body block (PCB + case combined)
body = add_box("Body", (0.075, 0.039, 0.009), (0, 0, 0.001))
body.data.materials.append(mats["case"])
col.objects.link(body)
# Simplified PCB surface on top
pcb_top = add_box("PCB_Top", (0.070, 0.035, 0.0003), (0, 0, 0.010))
pcb_top.data.materials.append(mats["pcb"])
col.objects.link(pcb_top)
# Battery cylinders (6)
for i in range(6):
b = add_cylinder(f"B{i}", 0.009, 0.065,
(-0.045 + i * 0.018, -0.025, 0.034),
(0, math.radians(90), 0))
b.data.materials.append(mats["battery"])
col.objects.link(b)
# LED
led = add_sphere("LED", 0.002, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
return col
def build_mr1_lod2():
"""Low-detail MR1 (~200 tris). Single box with emissive LED point."""
col = bpy.data.collections.new("MR1_LOD2")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
body = add_box("Body", (0.075, 0.039, 0.009))
body.data.materials.append(mats["case"])
col.objects.link(body)
led = add_sphere("LED", 0.003, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
return col
# ---------------------------------------------------------------------------
# 4. RACK
# ---------------------------------------------------------------------------
def build_rack(x, z, lod0_col, lod1_col, lod2_col):
"""Build a double-sided rack at position (x, 0, z) with LOD instances."""
mats = create_all_materials()
rack = bpy.data.collections.new(f"Rack_{int(x)}_{int(z)}")
bpy.context.scene.collection.children.link(rack)
# Frame
hw = CONFIG["rack_width"] / 2
hd = CONFIG["rack_depth"] / 2
hh = CONFIG["rack_height"] / 2
th = 0.02 # extrusion thickness
# Vertical posts (4 corners)
for dx in [-hw + th, hw - th]:
for dz in [-hd + th, hd - th]:
post = add_box("Post", (th/2, th/2, hh),
(x + dx, hh, z + dz))
post.data.materials.append(mats["rack"])
rack.objects.link(post)
# Horizontal rails (front and back, at each row position)
row_spacing = CONFIG["rack_height"] / CONFIG["rack_rows"]
for r in range(CONFIG["rack_rows"] + 1):
ry = r * row_spacing
for face_z in [-hd, hd]:
rail = add_box("Rail", (hw/2, 0.005, 0.005),
(x + hw/2, ry, z + face_z))
rail.data.materials.append(mats["rack"])
rack.objects.link(rail)
# Device instances β front and back face
col_spacing_w = (CONFIG["rack_width"] - 0.1) / CONFIG["rack_cols"]
col_spacing_h = (CONFIG["rack_height"] - 0.15) / CONFIG["rack_rows"]
start_w = -CONFIG["rack_width"]/2 + 0.05 + col_spacing_w/2
start_h = 0.08 + col_spacing_h/2
for face_z in [-hd + 0.09, hd - 0.09]: # offset for rack depth
for r in range(CONFIG["rack_rows"]):
for c in range(CONFIG["rack_cols"]):
dx = start_w + c * col_spacing_w
dy = start_h + r * col_spacing_h
dz = face_z
# Device lies flat: thickness (Z) points up (rack Y),
# long axis (X) goes into rack depth (rack Z)
empty = bpy.data.objects.new(f"Dev_{r}_{c}", None)
empty.empty_display_type = 'PLAIN_AXES'
empty.location = (x + dx, dy, z + dz)
empty.rotation_euler = (math.radians(90), 0, 0)
empty.instance_type = 'COLLECTION'
empty.instance_collection = lod0_col
rack.objects.link(empty)
return rack
# ---------------------------------------------------------------------------
# 5. FACILITY
# ---------------------------------------------------------------------------
def build_facility(mr1_lod0, mr1_lod1, mr1_lod2):
"""Build room, floor, walls, ceiling, lighting, racks."""
mats = create_all_materials()
scene_col = bpy.context.scene.collection
rw = CONFIG["room_width"]
rd = CONFIG["room_depth"]
rh = CONFIG["room_height"]
# Floor β large dark platform
floor = add_box("Floor", (rw/2 + 2, rd/2 + 2, 0.01), (rw/2, 0, rd/2))
floor.data.materials.append(mats["floor"])
scene_col.objects.link(floor)
# Walls β skip for v0.01; facility is an open platform
# Ceiling β skip; we want to see inside from above in exterior shots
# Racks β arrange in a grid
rack_count = CONFIG["rack_count"]
racks_per_row = 3 # for 5 racks: 2 rows of 2+3
rack_spacing_x = 2.5
rack_spacing_z = 2.5
start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
start_z = 2.0
hero_index = 1 # rack the camera approaches in phases D-E
for i in range(rack_count):
rx = start_x + (i % racks_per_row) * rack_spacing_x
rz = start_z + (i // racks_per_row) * rack_spacing_z
lod = mr1_lod0 if i == hero_index else mr1_lod1
build_rack(rx, rz, lod, mr1_lod1, mr1_lod2)
# Overhead cable trays
for i in range(rack_count + 1):
cx = start_x + (i % racks_per_row) * rack_spacing_x - rack_spacing_x/2
tray = add_box("CableTray", (0.15, 0.03, rd/2), (cx, rh - 0.5, rd/2))
tray.data.materials.append(mats["cable"])
scene_col.objects.link(tray)
# Lighting β overhead area lamps + key sun
for lx in [rw * 0.25, rw * 0.5, rw * 0.75]:
for lz in [rd * 0.25, rd * 0.5, rd * 0.75]:
bpy.ops.object.light_add(type='AREA', location=(lx, rh - 0.1, lz))
light = bpy.context.object
light.data.energy = 150.0
light.data.size = 0.8
light.data.color = (0.9, 0.95, 1.0)
# Key sun for ambient fill (needed for EEVEE visibility)
bpy.ops.object.light_add(type='SUN', location=(rw * 0.3, rh + 5, rd * 0.3))
sun = bpy.context.object
sun.data.energy = 3.0
sun.data.angle = math.radians(15)
# ---------------------------------------------------------------------------
# 6. CAMERA ANIMATION (NURBS PATH)
# ---------------------------------------------------------------------------
def setup_camera_animation():
"""Create NURBS path + Follow Path constraint on camera."""
rw = CONFIG["room_width"]
rd = CONFIG["room_depth"]
rh = CONFIG["room_height"]
# Compute rack positions (matching build_facility layout)
racks_per_row = 3
rack_spacing_x = 2.5
rack_spacing_z = 2.5
start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
start_z = 2.0
# Target rack for close-up phases D-E: second rack in first row
target_rack_x = start_x + rack_spacing_x
target_rack_z = start_z
# Waypoints for the 5-phase animation (x, y, z, weight)
waypoints = [
# Phase A: exterior approach (high above, looking down)
(rw/2, 50.0, rd/2 - 10, 1.0),
(rw/2, 30.0, rd/2 - 5, 1.0),
(rw/2, 15.0, rd/2 - 2, 1.0),
(rw/2, rh + 2, rd/2, 1.0), # just above roof, frame 240
# Phase B: roof cutaway (descend through roof)
(rw/2, rh - 0.5, rd/2, 1.0), # just below roof
(rw/2, rh - 1.5, rd/2, 1.0), # entering room
# Phase C: rack reveal (pan across room at ceiling height)
(rw * 0.8, rh - 2, rd * 0.7, 1.0),
(rw * 0.5, rh - 2.5, rd * 0.5, 1.0),
(rw * 0.3, 1.7, rd * 0.4, 1.0), # eye level, looking at racks
# Phase D: detail close-up (dolly toward target rack)
(target_rack_x, 1.5, target_rack_z, 1.0),
(target_rack_x + 0.3, 1.3, target_rack_z + 0.3, 1.0),
# Phase E: final hold
(target_rack_x + 0.5, 1.2, target_rack_z + 0.5, 1.0),
]
total_frames = 1440
# Camera β placed at first waypoint
first_wp = waypoints[0][:3]
bpy.ops.object.camera_add(location=first_wp)
cam = bpy.context.object
cam.name = "MainCamera"
cam.data.lens = 28
bpy.context.scene.camera = cam
# LookAt target β starts at room center
bpy.ops.object.empty_add(type='PLAIN_AXES', location=(rw/2, 1.0, rd/2))
target = bpy.context.object
target.name = "LookAtTarget"
# Animate target position
target.location = (rw/2, 1.0, rd/2)
target.keyframe_insert('location', frame=1)
target.location = (rw/2, rh - 3, rd/2)
target.keyframe_insert('location', frame=60)
target.location = (rw * 0.7, rh - 1.5, rd * 0.7)
target.keyframe_insert('location', frame=240)
target.location = (rw * 0.5, 1.0, rd * 0.5)
target.keyframe_insert('location', frame=480)
target.location = (target_rack_x, 1.0, target_rack_z)
target.keyframe_insert('location', frame=960)
target.location = (target_rack_x + 0.5, 1.2, target_rack_z + 0.5)
target.keyframe_insert('location', frame=1440)
# Direct keyframe camera position at phase boundaries (no constraint conflicts)
camera_keys = {
1: (rw/2, 50.0, rd/2 - 10), # Phase A start: high above, south
120: (rw/2, 30.0, rd/2 - 3),
240: (rw/2, rh + 2, rd/2), # Phase AβB: just above roof
360: (rw/2, rh - 1, rd/2), # Phase B: inside room, ceiling height
480: (rw * 0.7, rh - 2, rd * 0.7),# Phase BβC: panning across
720: (rw * 0.5, rh - 2.5, rd * 0.5),
960: (target_rack_x - 0.5, 1.5, target_rack_z), # Phase CβD: approach target rack
1200: (target_rack_x, 1.3, target_rack_z + 0.2), # Phase D: close-up
1440: (target_rack_x + 0.5, 1.2, target_rack_z + 0.5), # Phase E: final hold
}
for frame, pos in camera_keys.items():
cam.location = pos
cam.keyframe_insert('location', frame=frame)
# Set Track To constraint LAST so it isn't overridden
track = cam.constraints.new('TRACK_TO')
track.target = target
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'
# Smooth interpolation for all fcurves
if cam.animation_data and cam.animation_data.action:
for fcu in cam.animation_data.action.fcurves:
for kf in fcu.keyframe_points:
kf.interpolation = 'BEZIER'
if target.animation_data and target.animation_data.action:
for fcu in target.animation_data.action.fcurves:
for kf in fcu.keyframe_points:
kf.interpolation = 'BEZIER'
return cam, target, None
# ---------------------------------------------------------------------------
# 7. ANNOTATIONS
# ---------------------------------------------------------------------------
def create_annotations():
"""Create 3D text annotation objects with keyframed visibility."""
mats = create_all_materials()
annot_col = bpy.data.collections.new("Annotations")
bpy.context.scene.collection.children.link(annot_col)
# Find a good position near the central rack
rack_x = CONFIG["room_width"] / 2
rack_z = 2.0
annotations = [
("MR1 Molecular Streamer", (rack_x - 0.5, 1.8, rack_z + 0.5), 961),
("10,000 devices / 35 racks", (rack_x - 0.5, 1.5, rack_z + 0.5), 1000),
("ESP32-S3 Β· LMP7721 Β· MAX11169", (rack_x - 0.5, 1.2, rack_z + 0.5), 1040),
("Flow Cell: Silicon Micropore", (rack_x - 0.5, 0.9, rack_z + 0.5), 1080),
("286 devices per rack", (rack_x - 0.5, 0.6, rack_z + 0.5), 1120),
]
for text, loc, appear_frame in annotations:
bpy.ops.object.text_add(location=loc)
txt_obj = bpy.context.object
txt_obj.name = f"Anno_{text[:20]}"
txt_obj.data.body = text
txt_obj.data.size = 0.08
txt_obj.data.extrude = 0.005
txt_obj.data.align_x = 'LEFT'
txt_obj.data.font = bpy.data.fonts.load(
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
) if os.path.exists("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf") else None
txt_obj.data.materials.append(mats["text_anno"])
# Visibility keyframes
txt_obj.hide_viewport = True
txt_obj.hide_render = True
txt_obj.keyframe_insert('hide_render', frame=appear_frame - 1)
txt_obj.keyframe_insert('hide_viewport', frame=appear_frame - 1)
txt_obj.hide_render = False
txt_obj.hide_viewport = False
txt_obj.keyframe_insert('hide_render', frame=appear_frame)
txt_obj.keyframe_insert('hide_viewport', frame=appear_frame)
annot_col.objects.link(txt_obj)
# ---------------------------------------------------------------------------
# 8. RENDER SETTINGS
# ---------------------------------------------------------------------------
def configure_render(engine='CYCLES', use_cpu=False):
"""Set render engine and quality settings."""
scene = bpy.context.scene
if engine == 'CYCLES':
scene.render.engine = 'CYCLES'
if use_cpu:
scene.cycles.device = 'CPU'
print("Cycles: using CPU (--cpu flag)")
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
# CPU is 20-50x slower than GPU β use lighter settings
if CONFIG["samples"] > 32:
CONFIG["samples"] = 32
scene.cycles.samples = CONFIG["samples"]
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = False
scene.cycles.max_bounces = 4
scene.cycles.diffuse_bounces = 2
scene.cycles.glossy_bounces = 2
scene.cycles.transmission_bounces = 4
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '512'
scene.cycles.use_square_samples = True
else:
prefs = bpy.context.preferences.addons['cycles'].preferences
# Prefer CUDA over OptiX β CUDA kernels pre-compile in warmup and
# don't trigger the A100 JIT hang that OptiX does.
for device_type in ['CUDA', 'OPTIX']:
try:
prefs.compute_device_type = device_type
prefs.get_devices()
gpu_found = any(d.type == device_type for d in prefs.devices)
if gpu_found:
print(f"Cycles: using {device_type}")
break
except Exception:
continue
# Enable all GPU devices (matching device types)
for device in prefs.devices:
device.use = device.type in ('OPTIX', 'CUDA')
print(f" Device: {device.name} ({device.type}) β {'enabled' if device.use else 'skipped'}")
scene.cycles.device = 'GPU'
scene.cycles.samples = CONFIG["samples"]
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.01
scene.cycles.max_bounces = 8
scene.cycles.diffuse_bounces = 4
scene.cycles.glossy_bounces = 4
scene.cycles.transmission_bounces = 8
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '1024'
scene.cycles.use_square_samples = True
# Match denoiser to detected compute device (OptiX needs RT cores)
if prefs.compute_device_type == 'OPTIX':
scene.cycles.denoiser = 'OPTIX'
else:
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
# Blender 4.0+ features β guard for 3.6 LTS compatibility
if hasattr(scene.cycles, 'use_compact_bvh'):
scene.cycles.use_compact_bvh = True
if hasattr(scene.cycles, 'bvh_type'):
scene.cycles.bvh_type = 'STATIC'
else:
scene.render.engine = 'BLENDER_EEVEE'
scene.eevee.taa_render_samples = 32
scene.eevee.use_bloom = True
scene.eevee.bloom_intensity = 0.3
scene.eevee.use_gtao = True
# Simplify for performance
scene.render.use_simplify = True
scene.render.simplify_subdivision = 0
scene.render.simplify_child_particles = 0.0
# Color management
scene.view_settings.view_transform = 'Standard'
scene.view_settings.look = 'None'
# ---------------------------------------------------------------------------
# 9. WORLD / ENVIRONMENT
# ---------------------------------------------------------------------------
def setup_world():
"""Dark world matching apex theme β dim ambient fill."""
world = bpy.data.worlds.new("ApexDark")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.01, 0.02, 0.01, 1.0)
bg.inputs["Strength"].default_value = 1.0
# ---------------------------------------------------------------------------
# 10. RENDER LOOP WITH CHECKPOINTING
# ---------------------------------------------------------------------------
def render_phase(phase_key, engine='CYCLES'):
"""Render all frames for a given phase, with checkpoint/resume."""
phase = PHASES[phase_key]
scene = bpy.context.scene
out_dir = os.path.join(CONFIG["output_dir"], f"phase_{phase_key}")
os.makedirs(out_dir, exist_ok=True)
preview_dir = os.path.join(CONFIG["output_dir"], "preview")
os.makedirs(preview_dir, exist_ok=True)
scene.frame_start = phase["start"]
scene.frame_end = phase["end"]
scene.render.filepath = os.path.join(out_dir, "frame_####.png")
# Check progress
progress = load_progress() or {}
phase_progress = progress.get(f"phase_{phase_key}", {})
last_frame = phase_progress.get("last_frame", phase["start"] - 1)
if last_frame >= phase["end"]:
print(f"Phase {phase_key} already complete, skipping.")
return
start_from = last_frame + 1
# Register a render-write handler to copy each completed frame as live preview
live_preview_frame = [0] # mutable closure
def on_frame_written(scene):
live_preview_frame[0] += 1
frame_file = scene.render.filepath
# Blender replaces #### with frame number in the path
if "####" in frame_file:
frame_file = frame_file.replace("####", f"{scene.frame_current:04d}")
preview_path = os.path.join(preview_dir, "latest_frame.png")
if os.path.exists(frame_file):
shutil.copy2(frame_file, preview_path)
if live_preview_frame[0] % 10 == 0:
progress[f"phase_{phase_key}"] = {"last_frame": scene.frame_current, "timestamp": time.time()}
save_progress(progress)
print(f" [{phase_key}] Frame {scene.frame_current}/{phase['end']} ({100*scene.frame_current/phase['end']:.0f}%)")
bpy.app.handlers.render_write.append(on_frame_written)
try:
print(f"Rendering phase {phase_key} ({phase['name']}): frames {start_from}-{phase['end']}")
# Always use per-frame write_still=True β animation=True triggers
# a GPU kernel compilation path that hangs on A100 (both 4.0.2 and 3.6.5).
# write_still=True uses a simpler kernel path that works.
for frame in range(start_from, phase["end"] + 1):
scene.frame_set(frame)
scene.render.filepath = os.path.join(out_dir, f"frame_{frame:04d}.png")
bpy.ops.render.render(write_still=True)
finally:
bpy.app.handlers.render_write.remove(on_frame_written)
# Mark phase complete
progress[f"phase_{phase_key}"] = {"last_frame": phase["end"], "timestamp": time.time(), "complete": True}
save_progress(progress)
print(f"Phase {phase_key} complete.")
def render_test_frame(frame_num, engine='EEVEE'):
"""Render a single test frame at low resolution."""
scene = bpy.context.scene
scene.render.resolution_x = 640
scene.render.resolution_y = 360
test_path = os.path.join(CONFIG["output_dir"], f"test_frame_{frame_num:04d}.png")
scene.render.filepath = test_path
scene.frame_set(frame_num)
# Debug: camera position
cam = bpy.data.objects.get('MainCamera')
if cam:
loc = cam.matrix_world.translation
direction = cam.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1))
print(f" Camera pos: {loc}, looking: {direction}")
if engine == 'EEVEE':
scene.render.engine = 'BLENDER_EEVEE'
scene.eevee.taa_render_samples = 32
else:
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 8
scene.cycles.use_denoising = False
bpy.ops.render.render(write_still=True)
print(f"Test frame rendered to {test_path}")
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
def main():
args = parse_args()
# Apply overrides
if args.res:
CONFIG["resolution_x"] = 1920 if args.res == 1080 else 1280
CONFIG["resolution_y"] = 1080 if args.res == 1080 else 720
if args.samples:
CONFIG["samples"] = args.samples
if args.output:
CONFIG["output_dir"] = args.output
CONFIG["progress_file"] = os.path.join(args.output, "progress.json")
print(f"=== Molecular Gigafactory Scene Generator ===")
print(f"Resolution: {CONFIG['resolution_x']}x{CONFIG['resolution_y']}")
print(f"Samples: {CONFIG['samples']}")
print(f"Output: {CONFIG['output_dir']}")
# Build scene
print("Clearing scene...")
clear_scene()
print("Setting up world...")
setup_world()
print("Building MR1 device models...")
mr1_lod0 = build_mr1_lod0()
mr1_lod1 = build_mr1_lod1()
mr1_lod2 = build_mr1_lod2()
print("Building facility...")
build_facility(mr1_lod0, mr1_lod1, mr1_lod2)
print("Setting up camera animation...")
setup_camera_animation()
print("Creating annotations...")
create_annotations()
if args.smoke:
print("=== SMOKE TEST: 64x64, 1 sample, CPU Cycles ===")
scene = bpy.context.scene
scene.render.resolution_x = 64
scene.render.resolution_y = 64
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 1
scene.cycles.use_denoising = False
scene.cycles.use_adaptive_sampling = False
scene.cycles.max_bounces = 1
scene.cycles.diffuse_bounces = 1
scene.cycles.glossy_bounces = 1
scene.cycles.transmission_bounces = 1
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '128'
scene.frame_set(1)
smoke_path = os.path.join(CONFIG["output_dir"], "smoke_test.png")
scene.render.filepath = smoke_path
print(f"Rendering smoke test to {smoke_path}...")
bpy.ops.render.render(write_still=True)
if os.path.exists(smoke_path):
print(f"SMOKE TEST PASSED: {os.path.getsize(smoke_path)} bytes")
else:
print(f"SMOKE TEST FAILED: no output file")
elif args.test:
engine = 'CYCLES' if args.cpu else 'EEVEE'
print(f"Test render at frame {args.frame} (engine={engine})...")
if engine == 'CYCLES':
configure_render('CYCLES', use_cpu=True)
render_test_frame(args.frame, engine)
elif args.render_all:
engine = 'CYCLES'
print(f"Configuring {engine} render engine...")
configure_render(engine, use_cpu=args.cpu)
if args.phase == "all":
for phase_key in ["A", "B", "C", "D", "E"]:
print(f"\n--- Phase {phase_key}: {PHASES[phase_key]['name']} ---")
render_phase(phase_key, engine)
else:
render_phase(args.phase, engine)
print("\n=== All rendering complete ===")
else:
print("No action specified. Use --test or --render-all.")
print("Scene built successfully β ready for rendering.")
if __name__ == "__main__":
main()
|