Johnny Osbournce commited on
Commit
4020561
·
1 Parent(s): 2c107fa

GPU render: Blender 4.0.2 + Cycles + Gradio auto-start

Browse files
Files changed (3) hide show
  1. Dockerfile +39 -0
  2. app.py +252 -0
  3. scene.py +809 -0
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
2
+
3
+ # System deps: Blender needs X11 libs even headless, ffmpeg for encoding
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ wget xz-utils \
6
+ libx11-6 libxi6 libxxf86vm1 libxfixes3 libxrender1 \
7
+ libgl1-mesa-glx libglib2.0-0 libsm6 libgomp1 libtbb12 \
8
+ ffmpeg python3-pip python3-dev \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Blender 4.0.2 from official tarball
12
+ RUN wget -q https://download.blender.org/release/Blender4.0/blender-4.0.2-linux-x64.tar.xz \
13
+ && tar -xf blender-4.0.2-linux-x64.tar.xz -C /opt \
14
+ && rm blender-4.0.2-linux-x64.tar.xz \
15
+ && ln -s /opt/blender-4.0.2/blender /usr/local/bin/blender
16
+
17
+ # Verify Blender
18
+ RUN blender --background --python-expr "import bpy; print(f'Blender {bpy.app.version_string} OK, CUDA: {bpy.app.build_options.cuda}')"
19
+
20
+ # Python deps
21
+ RUN pip3 install --no-cache-dir gradio huggingface_hub
22
+
23
+ # App code
24
+ COPY scripts/mol_giga_scene.py /app/scene.py
25
+ COPY scripts/gradio_render.py /app/app.py
26
+
27
+ WORKDIR /app
28
+ ENV PYTHONUNBUFFERED=1
29
+ ENV BLENDER_SYSTEM_SCRIPTS=/opt/blender-4.0.2/4.0/scripts
30
+
31
+ # Pre-compile CUDA kernels on build (saves render startup time)
32
+ RUN blender --background --python-expr "
33
+ import bpy
34
+ bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA'
35
+ bpy.context.preferences.addons['cycles'].preferences.get_devices()
36
+ print('CUDA devices enumerated')
37
+ " 2>&1 || true
38
+
39
+ CMD ["python3", "app.py"]
app.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Gradio wrapper for Molecular Gigafactory GPU rendering on HF Space.
3
+ Auto-starts render on boot; persists progress via checkpoint file.
4
+ Uploads final MP4 to HF Dataset repo so it survives Space restarts.
5
+ """
6
+
7
+ import gradio as gr
8
+ import subprocess
9
+ import threading
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import shutil
15
+ from pathlib import Path
16
+ from huggingface_hub import HfApi, upload_file, create_repo
17
+
18
+ OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "/app/output")
19
+ PROGRESS_FILE = os.path.join(OUTPUT_DIR, "progress.json")
20
+ STATUS_FILE = os.path.join(OUTPUT_DIR, "status.json")
21
+ HF_DATASET = "MolecularReality/mol-giga-output"
22
+ HF_TOKEN = os.environ.get("HF_TOKEN")
23
+
24
+ PHASES = ["A", "B", "C", "D", "E"]
25
+ RENDER_RES = "720"
26
+ RENDER_SAMPLES = "64"
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Render engine
30
+ # ---------------------------------------------------------------------------
31
+ def run_blender_phase(phase):
32
+ """Run Blender for a single phase. Returns True on success."""
33
+ phase_dir = os.path.join(OUTPUT_DIR, f"phase_{phase}")
34
+ os.makedirs(phase_dir, exist_ok=True)
35
+
36
+ cmd = [
37
+ "blender", "--background",
38
+ "--python", "/app/scene.py", "--",
39
+ "--render-all", f"--phase={phase}",
40
+ f"--output={OUTPUT_DIR}",
41
+ f"--res={RENDER_RES}",
42
+ f"--samples={RENDER_SAMPLES}",
43
+ ]
44
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200)
45
+ if result.returncode != 0:
46
+ print(f"Phase {phase} FAILED:\n{result.stderr[-2000:]}")
47
+ return False
48
+
49
+ # Encode phase frames to MP4
50
+ phase_mp4 = os.path.join(OUTPUT_DIR, f"phase_{phase}.mp4")
51
+ frames_pattern = os.path.join(phase_dir, "frame_%04d.png")
52
+ if os.path.exists(os.path.join(phase_dir, "frame_0001.png")):
53
+ enc = subprocess.run([
54
+ "ffmpeg", "-y", "-framerate", "24",
55
+ "-i", frames_pattern,
56
+ "-c:v", "libx264", "-preset", "fast", "-crf", "18",
57
+ "-pix_fmt", "yuv420p",
58
+ phase_mp4,
59
+ ], capture_output=True, text=True, timeout=600)
60
+ if enc.returncode != 0:
61
+ print(f"ffmpeg phase {phase} FAILED:\n{enc.stderr[-1000:]}")
62
+ return False
63
+
64
+ return os.path.exists(phase_mp4)
65
+
66
+
67
+ def encode_final():
68
+ """Concat phase MP4s into final movie."""
69
+ concat_list = os.path.join(OUTPUT_DIR, "concat.txt")
70
+ mp4_paths = []
71
+ for phase in PHASES:
72
+ mp4 = os.path.join(OUTPUT_DIR, f"phase_{phase}.mp4")
73
+ if os.path.exists(mp4):
74
+ mp4_paths.append(mp4)
75
+
76
+ if not mp4_paths:
77
+ return None
78
+
79
+ with open(concat_list, "w") as f:
80
+ for mp4 in mp4_paths:
81
+ f.write(f"file '{mp4}'\n")
82
+
83
+ final_mp4 = os.path.join(OUTPUT_DIR, "Molecular-Gigafactory.mp4")
84
+ subprocess.run([
85
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
86
+ "-i", concat_list, "-c", "copy", final_mp4,
87
+ ], capture_output=True, text=True, timeout=300)
88
+
89
+ return final_mp4 if os.path.exists(final_mp4) else None
90
+
91
+
92
+ def upload_result(mp4_path):
93
+ """Upload final MP4 to HF Dataset."""
94
+ if not HF_TOKEN:
95
+ print("WARNING: HF_TOKEN not set, cannot upload")
96
+ return False
97
+ try:
98
+ api = HfApi(token=HF_TOKEN)
99
+ create_repo(HF_DATASET, repo_type="dataset", exist_ok=True, token=HF_TOKEN)
100
+ upload_file(
101
+ path_or_fileobj=mp4_path,
102
+ path_in_repo="Molecular-Gigafactory.mp4",
103
+ repo_id=HF_DATASET,
104
+ repo_type="dataset",
105
+ token=HF_TOKEN,
106
+ )
107
+ # Also upload poster frame
108
+ poster = os.path.join(OUTPUT_DIR, "poster.jpg")
109
+ subprocess.run([
110
+ "ffmpeg", "-y", "-i", mp4_path, "-ss", "30", "-vframes", "1",
111
+ "-q:v", "2", poster,
112
+ ], capture_output=True, timeout=60)
113
+ if os.path.exists(poster):
114
+ upload_file(
115
+ path_or_fileobj=poster,
116
+ path_in_repo="Molecular-Gigafactory-poster.jpg",
117
+ repo_id=HF_DATASET,
118
+ repo_type="dataset",
119
+ token=HF_TOKEN,
120
+ )
121
+ return True
122
+ except Exception as e:
123
+ print(f"Upload failed: {e}")
124
+ return False
125
+
126
+
127
+ def save_status(data):
128
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
129
+ with open(STATUS_FILE, "w") as f:
130
+ json.dump(data, f)
131
+
132
+
133
+ def load_status():
134
+ try:
135
+ with open(STATUS_FILE) as f:
136
+ return json.load(f)
137
+ except (FileNotFoundError, json.JSONDecodeError):
138
+ return {"phase": "idle", "progress": 0, "message": "Ready"}
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Background render thread
143
+ # ---------------------------------------------------------------------------
144
+ def render_all_phases():
145
+ """Render all phases, encode, upload. Runs in background thread."""
146
+ save_status({"phase": "starting", "progress": 0, "message": "Initializing..."})
147
+
148
+ completed_phases = []
149
+ for phase in PHASES:
150
+ # Check if phase already done (from checkpoint)
151
+ progress = {}
152
+ try:
153
+ with open(PROGRESS_FILE) as f:
154
+ progress = json.load(f)
155
+ except (FileNotFoundError, json.JSONDecodeError):
156
+ pass
157
+
158
+ phase_key = f"phase_{phase}"
159
+ if progress.get(phase_key, {}).get("complete"):
160
+ msg = f"Phase {phase} already complete, skipping"
161
+ print(msg)
162
+ save_status({"phase": phase, "progress": int((len(completed_phases) + 1) / len(PHASES) * 100),
163
+ "message": msg})
164
+ completed_phases.append(phase)
165
+ continue
166
+
167
+ msg = f"Rendering phase {phase}..."
168
+ print(msg)
169
+ save_status({"phase": phase, "progress": int(len(completed_phases) / len(PHASES) * 100),
170
+ "message": msg})
171
+
172
+ ok = run_blender_phase(phase)
173
+ if ok:
174
+ completed_phases.append(phase)
175
+ save_status({"phase": phase, "progress": int(len(completed_phases) / len(PHASES) * 100),
176
+ "message": f"Phase {phase} done"})
177
+ else:
178
+ save_status({"phase": phase, "progress": int(len(completed_phases) / len(PHASES) * 100),
179
+ "message": f"Phase {phase} FAILED — will retry on restart"})
180
+ return # Stop here; next boot will resume from checkpoint
181
+
182
+ # All phases done — encode final
183
+ save_status({"phase": "encoding", "progress": 85, "message": "Encoding final MP4..."})
184
+ final_mp4 = encode_final()
185
+ if not final_mp4:
186
+ save_status({"phase": "error", "progress": 85, "message": "Final encode failed"})
187
+ return
188
+
189
+ # Upload
190
+ save_status({"phase": "uploading", "progress": 90, "message": "Uploading to HF Dataset..."})
191
+ ok = upload_result(final_mp4)
192
+ if ok:
193
+ save_status({"phase": "done", "progress": 100,
194
+ "message": f"Complete! MP4 uploaded to {HF_DATASET}"})
195
+ else:
196
+ save_status({"phase": "done", "progress": 100,
197
+ "message": f"Render complete but upload failed. MP4 at {final_mp4}"})
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Gradio UI
202
+ # ---------------------------------------------------------------------------
203
+ def get_status():
204
+ s = load_status()
205
+ return json.dumps(s, indent=2)
206
+
207
+
208
+ def start_render():
209
+ s = load_status()
210
+ if s.get("phase") in ("running", "starting", "encoding", "uploading"):
211
+ return "Render already in progress!"
212
+ threading.Thread(target=render_all_phases, daemon=True).start()
213
+ return "Render started! Check status below."
214
+
215
+
216
+ with gr.Blocks(title="Molecular Gigafactory Render", theme="soft") as demo:
217
+ gr.Markdown("""
218
+ # Molecular Gigafactory — GPU Render Service
219
+ Auto-starts rendering on Space boot. Results uploaded to HF Dataset.
220
+ """)
221
+
222
+ with gr.Row():
223
+ start_btn = gr.Button("Start Render", variant="primary")
224
+ refresh_btn = gr.Button("Refresh Status")
225
+
226
+ status_display = gr.Textbox(
227
+ value=get_status(), label="Status", lines=6, every=10
228
+ )
229
+
230
+ start_btn.click(start_render, outputs=[status_display])
231
+ refresh_btn.click(get_status, outputs=[status_display])
232
+
233
+ # Auto-refresh status every 10 seconds
234
+ demo.load(get_status, outputs=[status_display], every=10)
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Auto-start on boot
239
+ # ---------------------------------------------------------------------------
240
+ if __name__ == "__main__":
241
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
242
+
243
+ # Check if we need to auto-start
244
+ status = load_status()
245
+ if status.get("phase") in ("idle", "error", None) or status.get("progress", 0) < 100:
246
+ print(f"Auto-starting render (current status: {status.get('phase')})")
247
+ threading.Thread(target=render_all_phases, daemon=True).start()
248
+ else:
249
+ print(f"Render already complete (status: {status.get('phase')})")
250
+
251
+ demo.queue(default_concurrency_limit=1)
252
+ demo.launch(server_name="0.0.0.0", server_port=7860)
scene.py ADDED
@@ -0,0 +1,809 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Molecular Gigafactory — Procedural Blender Scene Generator.
3
+
4
+ Usage:
5
+ # Test render single frame at 480p
6
+ blender --background --python mol_giga_scene.py -- --test --frame 500
7
+
8
+ # Render all phases at production resolution
9
+ blender --background --python mol_giga_scene.py -- --render-all --phase all
10
+
11
+ # Render specific phase
12
+ blender --background --python mol_giga_scene.py -- --render-all --phase A
13
+
14
+ # Render with custom resolution
15
+ blender --background --python mol_giga_scene.py -- --render-all --res 720 --phase all
16
+ """
17
+
18
+ import bpy
19
+ import math
20
+ import mathutils
21
+ import os
22
+ import sys
23
+ import json
24
+ import time
25
+ import argparse
26
+ from pathlib import Path
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # CONFIGURATION
30
+ # ---------------------------------------------------------------------------
31
+ CONFIG = {
32
+ "output_dir": "/var/www/epicquest.bio/app/static/videos/frames",
33
+ "progress_file": "/var/www/epicquest.bio/app/static/videos/frames/progress.json",
34
+ "resolution_x": 1280, # 720p default for v0.01
35
+ "resolution_y": 720,
36
+ "fps": 24,
37
+ "samples": 64,
38
+ "rack_count": 5, # v0.01: 5 racks
39
+ "rack_rows": 13,
40
+ "rack_cols": 11,
41
+ "rack_height": 2.2,
42
+ "rack_width": 1.0,
43
+ "rack_depth": 0.8,
44
+ "room_width": 12.0,
45
+ "room_depth": 10.0,
46
+ "room_height": 3.5,
47
+ }
48
+
49
+ # Animation phase definitions (frames for each phase, 24fps, 45s total = 1080 frames)
50
+ PHASES = {
51
+ "A": {"name": "Exterior fly-in", "start": 1, "end": 240, "desc": "0-10s approach"},
52
+ "B": {"name": "Roof cutaway", "start": 241, "end": 480, "desc": "10-20s descend"},
53
+ "C": {"name": "Rack reveal", "start": 481, "end": 960, "desc": "20-40s interior"},
54
+ "D": {"name": "Detail annotations", "start": 961, "end": 1320, "desc": "40-55s close-up"},
55
+ "E": {"name": "Final hold", "start": 1321, "end": 1440, "desc": "55-60s settle"},
56
+ }
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # CLI ARGUMENTS
60
+ # ---------------------------------------------------------------------------
61
+ def parse_args():
62
+ """Parse command-line arguments after '--'."""
63
+ argv = sys.argv
64
+ if "--" in argv:
65
+ argv = argv[argv.index("--") + 1:]
66
+ else:
67
+ argv = []
68
+
69
+ parser = argparse.ArgumentParser(description="Molecular Gigafactory Scene Generator")
70
+ parser.add_argument("--test", action="store_true", help="Test render single frame")
71
+ parser.add_argument("--frame", type=int, default=500, help="Frame to test-render")
72
+ parser.add_argument("--render-all", action="store_true", help="Render full animation")
73
+ parser.add_argument("--phase", type=str, default="all", help="Phase to render (A/B/C/D/E/all)")
74
+ parser.add_argument("--res", type=int, default=None, help="Resolution (720 or 1080)")
75
+ parser.add_argument("--samples", type=int, default=None, help="Override sample count")
76
+ parser.add_argument("--output", type=str, default=None, help="Override output directory")
77
+ return parser.parse_args(argv)
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # UTILITIES
82
+ # ---------------------------------------------------------------------------
83
+ def make_material(name, color_hex, metallic=0.0, roughness=0.5, emit_strength=0.0, emit_hex=None, alpha=1.0):
84
+ """Create a PBR material from hex colors matching the apex theme palette."""
85
+ def hex_to_rgb(h):
86
+ h = h.lstrip("#")
87
+ return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
88
+
89
+ mat = bpy.data.materials.new(name)
90
+ mat.use_nodes = True
91
+ nodes = mat.node_tree.nodes
92
+ nodes.clear()
93
+ bsdf = nodes.new("ShaderNodeBsdfPrincipled")
94
+ bsdf.inputs["Base Color"].default_value = (*hex_to_rgb(color_hex), 1.0)
95
+ bsdf.inputs["Metallic"].default_value = metallic
96
+ bsdf.inputs["Roughness"].default_value = roughness
97
+ bsdf.inputs["Emission Strength"].default_value = emit_strength
98
+ if emit_hex:
99
+ bsdf.inputs["Emission Color"].default_value = (*hex_to_rgb(emit_hex), 1.0)
100
+ if alpha < 1.0:
101
+ mat.blend_method = 'BLEND'
102
+ bsdf.inputs["Alpha"].default_value = alpha
103
+ out = nodes.new("ShaderNodeOutputMaterial")
104
+ mat.node_tree.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
105
+ return mat
106
+
107
+
108
+ def _detach_from_collections(obj):
109
+ """Unlink from all collections so caller can link where needed."""
110
+ for coll in list(obj.users_collection):
111
+ coll.objects.unlink(obj)
112
+
113
+
114
+ def add_box(name, half_extents, location=(0, 0, 0)):
115
+ """Add a box primitive and return it (not linked to any collection)."""
116
+ bpy.ops.mesh.primitive_cube_add(size=1)
117
+ obj = bpy.context.object
118
+ obj.name = name
119
+ obj.scale = half_extents
120
+ obj.location = location
121
+ _detach_from_collections(obj)
122
+ return obj
123
+
124
+
125
+ def add_cylinder(name, radius, depth, location=(0, 0, 0), rotation=(0, 0, 0)):
126
+ """Add a cylinder primitive (not linked to any collection)."""
127
+ bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=depth, location=location)
128
+ obj = bpy.context.object
129
+ obj.name = name
130
+ obj.rotation_euler = rotation
131
+ _detach_from_collections(obj)
132
+ return obj
133
+
134
+
135
+ def add_sphere(name, radius, location=(0, 0, 0)):
136
+ """Add a UV sphere (not linked to any collection)."""
137
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=location)
138
+ obj = bpy.context.object
139
+ obj.name = name
140
+ _detach_from_collections(obj)
141
+ return obj
142
+
143
+
144
+ def save_progress(data):
145
+ """Write progress JSON for crash recovery."""
146
+ os.makedirs(os.path.dirname(CONFIG["progress_file"]), exist_ok=True)
147
+ with open(CONFIG["progress_file"], "w") as f:
148
+ json.dump(data, f)
149
+
150
+
151
+ def load_progress():
152
+ """Read progress JSON, return None if not found."""
153
+ try:
154
+ with open(CONFIG["progress_file"]) as f:
155
+ return json.load(f)
156
+ except (FileNotFoundError, json.JSONDecodeError):
157
+ return None
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # 1. CLEAR SCENE
162
+ # ---------------------------------------------------------------------------
163
+ def clear_scene():
164
+ """Remove all objects from the default scene."""
165
+ bpy.ops.wm.read_factory_settings(use_empty=True)
166
+ # Set render settings
167
+ scene = bpy.context.scene
168
+ scene.render.resolution_x = CONFIG["resolution_x"]
169
+ scene.render.resolution_y = CONFIG["resolution_y"]
170
+ scene.render.resolution_percentage = 100
171
+ scene.render.fps = CONFIG["fps"]
172
+ scene.render.image_settings.file_format = 'PNG'
173
+ scene.render.image_settings.color_mode = 'RGBA'
174
+ scene.frame_start = 1
175
+ scene.frame_end = 1440
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # 2. MATERIALS
180
+ # ---------------------------------------------------------------------------
181
+ def create_all_materials():
182
+ """Create all shared materials, return a dict for easy lookup."""
183
+ mats = {}
184
+ mats["pcb"] = make_material("PCB", "#1a5c1a", roughness=0.6)
185
+ mats["case"] = make_material("Case", "#2a2a2a", metallic=0.2, roughness=0.7)
186
+ mats["chip"] = make_material("Chip", "#222222", metallic=0.5, roughness=0.3)
187
+ mats["silver"] = make_material("Silver", "#c0c0c0", metallic=0.9, roughness=0.15)
188
+ mats["copper"] = make_material("Copper", "#b87333", metallic=0.8, roughness=0.2)
189
+ mats["battery"] = make_material("Battery", "#335599", metallic=0.3, roughness=0.4)
190
+ mats["led"] = make_material("LED", "#22c55e", emit_strength=5.0, emit_hex="#22c55e")
191
+ mats["flow"] = make_material("FlowCell", "#ccddff", metallic=0.0, roughness=0.1, alpha=0.7)
192
+ mats["rack"] = make_material("Rack", "#333333", metallic=0.7, roughness=0.4)
193
+ mats["floor"] = make_material("Floor", "#0d0d0d", roughness=0.8)
194
+ mats["wall"] = make_material("Wall", "#1a1a1a", roughness=0.9)
195
+ mats["ceiling"] = make_material("Ceiling", "#222222", roughness=0.8)
196
+ mats["cable"] = make_material("Cable", "#111111", roughness=0.6)
197
+ mats["trace"] = make_material("Trace", "#4ade80", emit_strength=2.0, emit_hex="#4ade80")
198
+ mats["wireframe"]= make_material("Wireframe","#4ade80", emit_strength=1.5, emit_hex="#4ade80")
199
+ mats["text_anno"]= make_material("Annotation","#4ade80", emit_strength=3.0, emit_hex="#4ade80")
200
+ return mats
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # 3. MR1 DEVICE MODELS (3 LODs)
205
+ # ---------------------------------------------------------------------------
206
+ def build_mr1_lod0():
207
+ """High-detail MR1 device (~8k tris). Returns a Collection."""
208
+ col = bpy.data.collections.new("MR1_LOD0")
209
+ bpy.context.scene.collection.children.link(col)
210
+ mats = create_all_materials()
211
+
212
+ # PCB base
213
+ pcb = add_box("PCB", (0.070, 0.035, 0.0008), (0, 0, 0.0008))
214
+ pcb.data.materials.append(mats["pcb"])
215
+ col.objects.link(pcb)
216
+
217
+ # ESP32-S3
218
+ esp = add_box("ESP32", (0.009, 0.009, 0.0015), (0.050, 0.015, 0.0023))
219
+ esp.data.materials.append(mats["chip"])
220
+ col.objects.link(esp)
221
+
222
+ # LMP7721 preamp
223
+ lmp = add_box("LMP7721", (0.0025, 0.0025, 0.00075), (-0.010, 0.000, 0.0018))
224
+ lmp.data.materials.append(mats["silver"])
225
+ col.objects.link(lmp)
226
+
227
+ # ADC
228
+ adc = add_box("MAX11169", (0.003, 0.003, 0.00075), (0.010, -0.010, 0.0018))
229
+ adc.data.materials.append(mats["chip"])
230
+ col.objects.link(adc)
231
+
232
+ # DAC
233
+ dac = add_box("MCP4822", (0.003, 0.003, 0.00075), (0.020, -0.010, 0.0018))
234
+ dac.data.materials.append(mats["chip"])
235
+ col.objects.link(dac)
236
+
237
+ # 6 batteries
238
+ for i in range(6):
239
+ b = add_cylinder(f"Batt{i+1}", 0.009, 0.065,
240
+ (-0.045 + i * 0.018, -0.025, 0.034),
241
+ (0, math.radians(90), 0))
242
+ b.data.materials.append(mats["battery"])
243
+ col.objects.link(b)
244
+
245
+ # Flow cell
246
+ flow = add_box("FlowCell", (0.010, 0.005, 0.0025), (-0.060, 0.0, 0.003))
247
+ flow.data.materials.append(mats["flow"])
248
+ col.objects.link(flow)
249
+
250
+ # Copper shield can (wireframe box over preamp)
251
+ shield = add_box("ShieldCan", (0.012, 0.010, 0.005), (-0.010, 0.0, 0.005))
252
+ shield.data.materials.append(mats["copper"])
253
+ mod = shield.modifiers.new("Wireframe", 'WIREFRAME')
254
+ mod.thickness = 0.0003
255
+ col.objects.link(shield)
256
+
257
+ # LED indicator
258
+ led = add_sphere("LED", 0.0015, (0.060, 0.025, 0.003))
259
+ led.data.materials.append(mats["led"])
260
+ col.objects.link(led)
261
+
262
+ # Ag/AgCl electrodes
263
+ for j, xo in enumerate([-0.002, 0.002]):
264
+ e = add_cylinder(f"Elec{j+1}", 0.00025, 0.006, (-0.060 + xo, 0.0, 0.006))
265
+ e.data.materials.append(mats["silver"])
266
+ col.objects.link(e)
267
+
268
+ # Case shell (wireframe display for now, solid in render)
269
+ case = add_box("CaseShell", (0.075, 0.039, 0.009), (0, 0, 0.001))
270
+ case.data.materials.append(mats["case"])
271
+ mod = case.modifiers.new("Solidify", 'SOLIDIFY')
272
+ mod.thickness = 0.001
273
+ mod.offset = 1
274
+ col.objects.link(case)
275
+
276
+ return col
277
+
278
+
279
+ def build_mr1_lod1():
280
+ """Medium-detail MR1 (~1.2k tris). Simplified: case + PCB block + batteries."""
281
+ col = bpy.data.collections.new("MR1_LOD1")
282
+ bpy.context.scene.collection.children.link(col)
283
+ mats = create_all_materials()
284
+
285
+ # Main body block (PCB + case combined)
286
+ body = add_box("Body", (0.075, 0.039, 0.009), (0, 0, 0.001))
287
+ body.data.materials.append(mats["case"])
288
+ col.objects.link(body)
289
+
290
+ # Simplified PCB surface on top
291
+ pcb_top = add_box("PCB_Top", (0.070, 0.035, 0.0003), (0, 0, 0.010))
292
+ pcb_top.data.materials.append(mats["pcb"])
293
+ col.objects.link(pcb_top)
294
+
295
+ # Battery cylinders (6)
296
+ for i in range(6):
297
+ b = add_cylinder(f"B{i}", 0.009, 0.065,
298
+ (-0.045 + i * 0.018, -0.025, 0.034),
299
+ (0, math.radians(90), 0))
300
+ b.data.materials.append(mats["battery"])
301
+ col.objects.link(b)
302
+
303
+ # LED
304
+ led = add_sphere("LED", 0.002, (0.060, 0.025, 0.003))
305
+ led.data.materials.append(mats["led"])
306
+ col.objects.link(led)
307
+
308
+ return col
309
+
310
+
311
+ def build_mr1_lod2():
312
+ """Low-detail MR1 (~200 tris). Single box with emissive LED point."""
313
+ col = bpy.data.collections.new("MR1_LOD2")
314
+ bpy.context.scene.collection.children.link(col)
315
+ mats = create_all_materials()
316
+
317
+ body = add_box("Body", (0.075, 0.039, 0.009))
318
+ body.data.materials.append(mats["case"])
319
+ col.objects.link(body)
320
+
321
+ led = add_sphere("LED", 0.003, (0.060, 0.025, 0.003))
322
+ led.data.materials.append(mats["led"])
323
+ col.objects.link(led)
324
+
325
+ return col
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # 4. RACK
330
+ # ---------------------------------------------------------------------------
331
+ def build_rack(x, z, lod0_col, lod1_col, lod2_col):
332
+ """Build a double-sided rack at position (x, 0, z) with LOD instances."""
333
+ mats = create_all_materials()
334
+ rack = bpy.data.collections.new(f"Rack_{int(x)}_{int(z)}")
335
+ bpy.context.scene.collection.children.link(rack)
336
+
337
+ # Frame
338
+ hw = CONFIG["rack_width"] / 2
339
+ hd = CONFIG["rack_depth"] / 2
340
+ hh = CONFIG["rack_height"] / 2
341
+ th = 0.02 # extrusion thickness
342
+
343
+ # Vertical posts (4 corners)
344
+ for dx in [-hw + th, hw - th]:
345
+ for dz in [-hd + th, hd - th]:
346
+ post = add_box("Post", (th/2, th/2, hh),
347
+ (x + dx, hh, z + dz))
348
+ post.data.materials.append(mats["rack"])
349
+ rack.objects.link(post)
350
+
351
+ # Horizontal rails (front and back, at each row position)
352
+ row_spacing = CONFIG["rack_height"] / CONFIG["rack_rows"]
353
+ for r in range(CONFIG["rack_rows"] + 1):
354
+ ry = r * row_spacing
355
+ for face_z in [-hd, hd]:
356
+ rail = add_box("Rail", (hw/2, 0.005, 0.005),
357
+ (x + hw/2, ry, z + face_z))
358
+ rail.data.materials.append(mats["rack"])
359
+ rack.objects.link(rail)
360
+
361
+ # Device instances — front and back face
362
+ col_spacing_w = (CONFIG["rack_width"] - 0.1) / CONFIG["rack_cols"]
363
+ col_spacing_h = (CONFIG["rack_height"] - 0.15) / CONFIG["rack_rows"]
364
+ start_w = -CONFIG["rack_width"]/2 + 0.05 + col_spacing_w/2
365
+ start_h = 0.08 + col_spacing_h/2
366
+
367
+ for face_z in [-hd + 0.09, hd - 0.09]: # offset for rack depth
368
+ for r in range(CONFIG["rack_rows"]):
369
+ for c in range(CONFIG["rack_cols"]):
370
+ dx = start_w + c * col_spacing_w
371
+ dy = start_h + r * col_spacing_h
372
+ dz = face_z
373
+
374
+ # Device lies flat: thickness (Z) points up (rack Y),
375
+ # long axis (X) goes into rack depth (rack Z)
376
+ empty = bpy.data.objects.new(f"Dev_{r}_{c}", None)
377
+ empty.empty_display_type = 'PLAIN_AXES'
378
+ empty.location = (x + dx, dy, z + dz)
379
+ empty.rotation_euler = (math.radians(90), 0, 0)
380
+ empty.instance_type = 'COLLECTION'
381
+ empty.instance_collection = lod0_col
382
+ rack.objects.link(empty)
383
+
384
+ return rack
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # 5. FACILITY
389
+ # ---------------------------------------------------------------------------
390
+ def build_facility(mr1_lod0, mr1_lod1, mr1_lod2):
391
+ """Build room, floor, walls, ceiling, lighting, racks."""
392
+ mats = create_all_materials()
393
+ scene_col = bpy.context.scene.collection
394
+
395
+ rw = CONFIG["room_width"]
396
+ rd = CONFIG["room_depth"]
397
+ rh = CONFIG["room_height"]
398
+
399
+ # Floor — large dark platform
400
+ floor = add_box("Floor", (rw/2 + 2, rd/2 + 2, 0.01), (rw/2, 0, rd/2))
401
+ floor.data.materials.append(mats["floor"])
402
+ scene_col.objects.link(floor)
403
+
404
+ # Walls — skip for v0.01; facility is an open platform
405
+ # Ceiling — skip; we want to see inside from above in exterior shots
406
+
407
+ # Racks — arrange in a grid
408
+ rack_count = CONFIG["rack_count"]
409
+ racks_per_row = 3 # for 5 racks: 2 rows of 2+3
410
+ rack_spacing_x = 2.5
411
+ rack_spacing_z = 2.5
412
+ start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
413
+ start_z = 2.0
414
+
415
+ for i in range(rack_count):
416
+ rx = start_x + (i % racks_per_row) * rack_spacing_x
417
+ rz = start_z + (i // racks_per_row) * rack_spacing_z
418
+ build_rack(rx, rz, mr1_lod0, mr1_lod1, mr1_lod2)
419
+
420
+ # Overhead cable trays
421
+ for i in range(rack_count + 1):
422
+ cx = start_x + (i % racks_per_row) * rack_spacing_x - rack_spacing_x/2
423
+ tray = add_box("CableTray", (0.15, 0.03, rd/2), (cx, rh - 0.5, rd/2))
424
+ tray.data.materials.append(mats["cable"])
425
+ scene_col.objects.link(tray)
426
+
427
+ # Lighting — overhead area lamps + key sun
428
+ for lx in [rw * 0.25, rw * 0.5, rw * 0.75]:
429
+ for lz in [rd * 0.25, rd * 0.5, rd * 0.75]:
430
+ bpy.ops.object.light_add(type='AREA', location=(lx, rh - 0.1, lz))
431
+ light = bpy.context.object
432
+ light.data.energy = 150.0
433
+ light.data.size = 0.8
434
+ light.data.color = (0.9, 0.95, 1.0)
435
+
436
+ # Key sun for ambient fill (needed for EEVEE visibility)
437
+ bpy.ops.object.light_add(type='SUN', location=(rw * 0.3, rh + 5, rd * 0.3))
438
+ sun = bpy.context.object
439
+ sun.data.energy = 3.0
440
+ sun.data.angle = math.radians(15)
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # 6. CAMERA ANIMATION (NURBS PATH)
445
+ # ---------------------------------------------------------------------------
446
+ def setup_camera_animation():
447
+ """Create NURBS path + Follow Path constraint on camera."""
448
+ rw = CONFIG["room_width"]
449
+ rd = CONFIG["room_depth"]
450
+ rh = CONFIG["room_height"]
451
+
452
+ # Compute rack positions (matching build_facility layout)
453
+ racks_per_row = 3
454
+ rack_spacing_x = 2.5
455
+ rack_spacing_z = 2.5
456
+ start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
457
+ start_z = 2.0
458
+
459
+ # Target rack for close-up phases D-E: second rack in first row
460
+ target_rack_x = start_x + rack_spacing_x
461
+ target_rack_z = start_z
462
+
463
+ # Waypoints for the 5-phase animation (x, y, z, weight)
464
+ waypoints = [
465
+ # Phase A: exterior approach (high above, looking down)
466
+ (rw/2, 50.0, rd/2 - 10, 1.0),
467
+ (rw/2, 30.0, rd/2 - 5, 1.0),
468
+ (rw/2, 15.0, rd/2 - 2, 1.0),
469
+ (rw/2, rh + 2, rd/2, 1.0), # just above roof, frame 240
470
+
471
+ # Phase B: roof cutaway (descend through roof)
472
+ (rw/2, rh - 0.5, rd/2, 1.0), # just below roof
473
+ (rw/2, rh - 1.5, rd/2, 1.0), # entering room
474
+
475
+ # Phase C: rack reveal (pan across room at ceiling height)
476
+ (rw * 0.8, rh - 2, rd * 0.7, 1.0),
477
+ (rw * 0.5, rh - 2.5, rd * 0.5, 1.0),
478
+ (rw * 0.3, 1.7, rd * 0.4, 1.0), # eye level, looking at racks
479
+
480
+ # Phase D: detail close-up (dolly toward target rack)
481
+ (target_rack_x, 1.5, target_rack_z, 1.0),
482
+ (target_rack_x + 0.3, 1.3, target_rack_z + 0.3, 1.0),
483
+
484
+ # Phase E: final hold
485
+ (target_rack_x + 0.5, 1.2, target_rack_z + 0.5, 1.0),
486
+ ]
487
+
488
+ total_frames = 1440
489
+
490
+ # Camera — placed at first waypoint
491
+ first_wp = waypoints[0][:3]
492
+ bpy.ops.object.camera_add(location=first_wp)
493
+ cam = bpy.context.object
494
+ cam.name = "MainCamera"
495
+ cam.data.lens = 28
496
+ bpy.context.scene.camera = cam
497
+
498
+ # LookAt target — starts at room center
499
+ bpy.ops.object.empty_add(type='PLAIN_AXES', location=(rw/2, 1.0, rd/2))
500
+ target = bpy.context.object
501
+ target.name = "LookAtTarget"
502
+
503
+ # Animate target position
504
+ target.location = (rw/2, 1.0, rd/2)
505
+ target.keyframe_insert('location', frame=1)
506
+ target.location = (rw/2, rh - 3, rd/2)
507
+ target.keyframe_insert('location', frame=60)
508
+ target.location = (rw * 0.7, rh - 1.5, rd * 0.7)
509
+ target.keyframe_insert('location', frame=240)
510
+ target.location = (rw * 0.5, 1.0, rd * 0.5)
511
+ target.keyframe_insert('location', frame=480)
512
+ target.location = (target_rack_x, 1.0, target_rack_z)
513
+ target.keyframe_insert('location', frame=960)
514
+ target.location = (target_rack_x + 0.5, 1.2, target_rack_z + 0.5)
515
+ target.keyframe_insert('location', frame=1440)
516
+
517
+ # Direct keyframe camera position at phase boundaries (no constraint conflicts)
518
+ camera_keys = {
519
+ 1: (rw/2, 50.0, rd/2 - 10), # Phase A start: high above, south
520
+ 120: (rw/2, 30.0, rd/2 - 3),
521
+ 240: (rw/2, rh + 2, rd/2), # Phase A→B: just above roof
522
+ 360: (rw/2, rh - 1, rd/2), # Phase B: inside room, ceiling height
523
+ 480: (rw * 0.7, rh - 2, rd * 0.7),# Phase B→C: panning across
524
+ 720: (rw * 0.5, rh - 2.5, rd * 0.5),
525
+ 960: (target_rack_x - 0.5, 1.5, target_rack_z), # Phase C→D: approach target rack
526
+ 1200: (target_rack_x, 1.3, target_rack_z + 0.2), # Phase D: close-up
527
+ 1440: (target_rack_x + 0.5, 1.2, target_rack_z + 0.5), # Phase E: final hold
528
+ }
529
+
530
+ for frame, pos in camera_keys.items():
531
+ cam.location = pos
532
+ cam.keyframe_insert('location', frame=frame)
533
+
534
+ # Set Track To constraint LAST so it isn't overridden
535
+ track = cam.constraints.new('TRACK_TO')
536
+ track.target = target
537
+ track.track_axis = 'TRACK_NEGATIVE_Z'
538
+ track.up_axis = 'UP_Y'
539
+
540
+ # Smooth interpolation for all fcurves
541
+ if cam.animation_data and cam.animation_data.action:
542
+ for fcu in cam.animation_data.action.fcurves:
543
+ for kf in fcu.keyframe_points:
544
+ kf.interpolation = 'BEZIER'
545
+ if target.animation_data and target.animation_data.action:
546
+ for fcu in target.animation_data.action.fcurves:
547
+ for kf in fcu.keyframe_points:
548
+ kf.interpolation = 'BEZIER'
549
+
550
+ return cam, target, None
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # 7. ANNOTATIONS
555
+ # ---------------------------------------------------------------------------
556
+ def create_annotations():
557
+ """Create 3D text annotation objects with keyframed visibility."""
558
+ mats = create_all_materials()
559
+ annot_col = bpy.data.collections.new("Annotations")
560
+ bpy.context.scene.collection.children.link(annot_col)
561
+
562
+ # Find a good position near the central rack
563
+ rack_x = CONFIG["room_width"] / 2
564
+ rack_z = 2.0
565
+
566
+ annotations = [
567
+ ("MR1 Molecular Streamer", (rack_x - 0.5, 1.8, rack_z + 0.5), 961),
568
+ ("10,000 devices / 35 racks", (rack_x - 0.5, 1.5, rack_z + 0.5), 1000),
569
+ ("ESP32-S3 · LMP7721 · MAX11169", (rack_x - 0.5, 1.2, rack_z + 0.5), 1040),
570
+ ("Flow Cell: Silicon Micropore", (rack_x - 0.5, 0.9, rack_z + 0.5), 1080),
571
+ ("286 devices per rack", (rack_x - 0.5, 0.6, rack_z + 0.5), 1120),
572
+ ]
573
+
574
+ for text, loc, appear_frame in annotations:
575
+ bpy.ops.object.text_add(location=loc)
576
+ txt_obj = bpy.context.object
577
+ txt_obj.name = f"Anno_{text[:20]}"
578
+ txt_obj.data.body = text
579
+ txt_obj.data.size = 0.08
580
+ txt_obj.data.extrude = 0.005
581
+ txt_obj.data.align_x = 'LEFT'
582
+ txt_obj.data.font = bpy.data.fonts.load(
583
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
584
+ ) if os.path.exists("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf") else None
585
+
586
+ txt_obj.data.materials.append(mats["text_anno"])
587
+
588
+ # Visibility keyframes
589
+ txt_obj.hide_viewport = True
590
+ txt_obj.hide_render = True
591
+ txt_obj.keyframe_insert('hide_render', frame=appear_frame - 1)
592
+ txt_obj.keyframe_insert('hide_viewport', frame=appear_frame - 1)
593
+ txt_obj.hide_render = False
594
+ txt_obj.hide_viewport = False
595
+ txt_obj.keyframe_insert('hide_render', frame=appear_frame)
596
+ txt_obj.keyframe_insert('hide_viewport', frame=appear_frame)
597
+
598
+ annot_col.objects.link(txt_obj)
599
+
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # 8. RENDER SETTINGS
603
+ # ---------------------------------------------------------------------------
604
+ def configure_render(engine='CYCLES'):
605
+ """Set render engine and quality settings."""
606
+ scene = bpy.context.scene
607
+
608
+ if engine == 'CYCLES':
609
+ scene.render.engine = 'CYCLES'
610
+ prefs = bpy.context.preferences.addons['cycles'].preferences
611
+
612
+ # Try OptiX first (faster), fall back to CUDA
613
+ for device_type in ['OPTIX', 'CUDA']:
614
+ try:
615
+ prefs.compute_device_type = device_type
616
+ prefs.get_devices()
617
+ gpu_found = any(d.type == device_type for d in prefs.devices)
618
+ if gpu_found:
619
+ print(f"Cycles: using {device_type}")
620
+ break
621
+ except Exception:
622
+ continue
623
+
624
+ # Enable all GPU devices
625
+ for device in prefs.devices:
626
+ device.use = device.type in ('OPTIX', 'CUDA')
627
+ print(f" Device: {device.name} ({device.type}) — {'enabled' if device.use else 'skipped'}")
628
+
629
+ scene.cycles.device = 'GPU'
630
+ scene.cycles.samples = CONFIG["samples"]
631
+ scene.cycles.use_denoising = True
632
+ scene.cycles.denoiser = 'OPTIX' # OptiX denoiser on GPU
633
+ scene.cycles.use_adaptive_sampling = True
634
+ scene.cycles.adaptive_threshold = 0.01
635
+ scene.cycles.max_bounces = 8
636
+ scene.cycles.diffuse_bounces = 4
637
+ scene.cycles.glossy_bounces = 4
638
+ scene.cycles.transmission_bounces = 8
639
+ scene.cycles.volume_bounces = 0
640
+ scene.cycles.texture_limit = 1024
641
+ scene.cycles.use_compact_bvh = True
642
+ scene.cycles.bvh_type = 'STATIC'
643
+ scene.cycles.use_square_samples = True
644
+ else:
645
+ scene.render.engine = 'BLENDER_EEVEE'
646
+ scene.eevee.taa_render_samples = 32
647
+ scene.eevee.use_bloom = True
648
+ scene.eevee.bloom_intensity = 0.3
649
+ scene.eevee.use_gtao = True
650
+
651
+ # Simplify for performance
652
+ scene.render.use_simplify = True
653
+ scene.render.simplify_subdivision = 0
654
+ scene.render.simplify_child_particles = 0.0
655
+
656
+ # Color management
657
+ scene.view_settings.view_transform = 'Standard'
658
+ scene.view_settings.look = 'None'
659
+
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # 9. WORLD / ENVIRONMENT
663
+ # ---------------------------------------------------------------------------
664
+ def setup_world():
665
+ """Dark world matching apex theme — dim ambient fill."""
666
+ world = bpy.data.worlds.new("ApexDark")
667
+ bpy.context.scene.world = world
668
+ world.use_nodes = True
669
+ bg = world.node_tree.nodes["Background"]
670
+ bg.inputs["Color"].default_value = (0.01, 0.02, 0.01, 1.0)
671
+ bg.inputs["Strength"].default_value = 1.0
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # 10. RENDER LOOP WITH CHECKPOINTING
676
+ # ---------------------------------------------------------------------------
677
+ def render_phase(phase_key, engine='CYCLES'):
678
+ """Render all frames for a given phase, with checkpoint/resume."""
679
+ phase = PHASES[phase_key]
680
+ scene = bpy.context.scene
681
+
682
+ out_dir = os.path.join(CONFIG["output_dir"], f"phase_{phase_key}")
683
+ os.makedirs(out_dir, exist_ok=True)
684
+
685
+ scene.frame_start = phase["start"]
686
+ scene.frame_end = phase["end"]
687
+ scene.render.filepath = os.path.join(out_dir, "frame_####.png")
688
+
689
+ # Check progress
690
+ progress = load_progress() or {}
691
+ phase_progress = progress.get(f"phase_{phase_key}", {})
692
+ last_frame = phase_progress.get("last_frame", phase["start"] - 1)
693
+
694
+ if last_frame >= phase["end"]:
695
+ print(f"Phase {phase_key} already complete, skipping.")
696
+ return
697
+
698
+ start_from = last_frame + 1
699
+ print(f"Rendering phase {phase_key} ({phase['name']}): frames {start_from}-{phase['end']}")
700
+
701
+ for frame in range(start_from, phase["end"] + 1):
702
+ scene.frame_set(frame)
703
+ scene.render.filepath = os.path.join(out_dir, f"frame_{frame:04d}.png")
704
+ bpy.ops.render.render(write_still=True)
705
+
706
+ if frame % 30 == 0:
707
+ progress[f"phase_{phase_key}"] = {"last_frame": frame, "timestamp": time.time()}
708
+ save_progress(progress)
709
+ print(f" Progress: {frame}/{phase['end']} ({100*frame/phase['end']:.0f}%)")
710
+
711
+ # Mark phase complete
712
+ progress[f"phase_{phase_key}"] = {"last_frame": phase["end"], "timestamp": time.time(), "complete": True}
713
+ save_progress(progress)
714
+ print(f"Phase {phase_key} complete.")
715
+
716
+
717
+ def render_test_frame(frame_num, engine='EEVEE'):
718
+ """Render a single test frame at low resolution."""
719
+ scene = bpy.context.scene
720
+ scene.render.resolution_x = 640
721
+ scene.render.resolution_y = 360
722
+
723
+ test_path = os.path.join(CONFIG["output_dir"], f"test_frame_{frame_num:04d}.png")
724
+ scene.render.filepath = test_path
725
+ scene.frame_set(frame_num)
726
+
727
+ # Debug: camera position
728
+ cam = bpy.data.objects.get('MainCamera')
729
+ if cam:
730
+ loc = cam.matrix_world.translation
731
+ direction = cam.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1))
732
+ print(f" Camera pos: {loc}, looking: {direction}")
733
+
734
+ if engine == 'EEVEE':
735
+ scene.render.engine = 'BLENDER_EEVEE'
736
+ scene.eevee.taa_render_samples = 32
737
+ else:
738
+ scene.render.engine = 'CYCLES'
739
+ scene.cycles.samples = 8
740
+ scene.cycles.use_denoising = False
741
+
742
+ bpy.ops.render.render(write_still=True)
743
+ print(f"Test frame rendered to {test_path}")
744
+
745
+
746
+ # ---------------------------------------------------------------------------
747
+ # MAIN
748
+ # ---------------------------------------------------------------------------
749
+ def main():
750
+ args = parse_args()
751
+
752
+ # Apply overrides
753
+ if args.res:
754
+ CONFIG["resolution_x"] = 1920 if args.res == 1080 else 1280
755
+ CONFIG["resolution_y"] = 1080 if args.res == 1080 else 720
756
+ if args.samples:
757
+ CONFIG["samples"] = args.samples
758
+ if args.output:
759
+ CONFIG["output_dir"] = args.output
760
+
761
+ print(f"=== Molecular Gigafactory Scene Generator ===")
762
+ print(f"Resolution: {CONFIG['resolution_x']}x{CONFIG['resolution_y']}")
763
+ print(f"Samples: {CONFIG['samples']}")
764
+ print(f"Output: {CONFIG['output_dir']}")
765
+
766
+ # Build scene
767
+ print("Clearing scene...")
768
+ clear_scene()
769
+
770
+ print("Setting up world...")
771
+ setup_world()
772
+
773
+ print("Building MR1 device models...")
774
+ mr1_lod0 = build_mr1_lod0()
775
+ mr1_lod1 = build_mr1_lod1()
776
+ mr1_lod2 = build_mr1_lod2()
777
+
778
+ print("Building facility...")
779
+ build_facility(mr1_lod0, mr1_lod1, mr1_lod2)
780
+
781
+ print("Setting up camera animation...")
782
+ setup_camera_animation()
783
+
784
+ print("Creating annotations...")
785
+ create_annotations()
786
+
787
+ if args.test:
788
+ print(f"Test render at frame {args.frame}...")
789
+ render_test_frame(args.frame, 'EEVEE')
790
+ elif args.render_all:
791
+ engine = 'CYCLES'
792
+ print(f"Configuring {engine} render engine...")
793
+ configure_render(engine)
794
+
795
+ if args.phase == "all":
796
+ for phase_key in ["A", "B", "C", "D", "E"]:
797
+ print(f"\n--- Phase {phase_key}: {PHASES[phase_key]['name']} ---")
798
+ render_phase(phase_key, engine)
799
+ else:
800
+ render_phase(args.phase, engine)
801
+
802
+ print("\n=== All rendering complete ===")
803
+ else:
804
+ print("No action specified. Use --test or --render-all.")
805
+ print("Scene built successfully — ready for rendering.")
806
+
807
+
808
+ if __name__ == "__main__":
809
+ main()