@genex-ai/cli-demo 1.31.2 → 1.32.0-dev.650

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +18 -0
  2. package/dist/blender-mcp-Q6PSFYSE.js +241 -0
  3. package/dist/blender-serve-BF4FZ55Z.js +244 -0
  4. package/dist/chunk-2COG4P3T.js +968 -0
  5. package/dist/chunk-HYCSNWYX.js +126 -0
  6. package/dist/index.js +4759 -2830
  7. package/package.json +3 -3
  8. package/templates/blender-service/demo/castle.py +117 -0
  9. package/templates/blender-service/gpu_witness.py +245 -0
  10. package/templates/blender-service/ops.py +225 -0
  11. package/templates/blender-service/pool.py +910 -0
  12. package/templates/blender-service/server.py +611 -0
  13. package/templates/blender-service/supervisor.py +221 -0
  14. package/templates/blender-service/views.py +281 -0
  15. package/templates/controllers/character/follow-camera.ts +16 -1
  16. package/templates/controllers/character/meshy/meshy-loader.ts +3 -2
  17. package/templates/controllers/quality/deadline.ts +117 -0
  18. package/templates/controllers/quality/pick-asset.ts +49 -16
  19. package/templates/controllers/shared/physics-world.ts +6 -4
  20. package/templates/skills/genex-ai-character/SKILL.md +77 -15
  21. package/templates/skills/genex-ai-menu/SKILL.md +15 -11
  22. package/templates/skills/genex-ai-model/SKILL.md +45 -7
  23. package/templates/skills/genex-ai-texture/SKILL.md +1 -1
  24. package/templates/skills/genex-ai-video/SKILL.md +75 -17
  25. package/templates/skills/genex-blender-scene/SKILL.md +243 -0
  26. package/templates/skills/genex-game-director/SKILL.md +112 -46
  27. package/templates/skills/genex-game-director/references/design-contract.md +13 -5
  28. package/templates/skills/genex-game-director/references/routing-map.md +30 -45
  29. package/templates/skills/genex-getting-started/SKILL.md +2 -2
  30. package/templates/skills/genex-lane-card/SKILL.md +78 -0
  31. package/templates/skills/genex-monetization/SKILL.md +11 -17
  32. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +21 -0
  33. package/templates/skills/genex-threejs-character-controller/SKILL.md +17 -5
  34. package/templates/skills/genex-threejs-creatures/SKILL.md +8 -1
  35. package/templates/skills/genex-threejs-embed-auth/SKILL.md +12 -8
  36. package/templates/skills/genex-threejs-game-ui/SKILL.md +55 -6
  37. package/templates/skills/genex-threejs-procedural-assets/SKILL.md +17 -10
  38. package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -2
  39. package/templates/skills/genex-tool-audio/SKILL.md +3 -2
  40. package/templates/skills/genex-tool-character/SKILL.md +36 -5
  41. package/templates/skills/genex-tool-image/SKILL.md +4 -2
  42. package/templates/skills/genex-tool-model/SKILL.md +32 -6
  43. package/templates/skills/genex-tool-publish/SKILL.md +100 -0
  44. package/templates/skills/genex-tool-texture/SKILL.md +1 -1
  45. package/templates/skills/genex-tool-video/SKILL.md +28 -5
  46. package/templates/skills/genex-tool-workflow/SKILL.md +4 -1
  47. package/templates/skills/genex-updates/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.31.2",
3
+ "version": "1.32.0-dev.650",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,10 +40,10 @@
40
40
  "pngjs": "^7.0.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@dimforge/rapier3d-compat": "^0.19.3",
43
+ "@dimforge/rapier3d-compat": "^0.20.0",
44
+ "@genex-ai/multiplayer": "workspace:*",
44
45
  "@genex/meshy-animation-catalog": "workspace:*",
45
46
  "@genex/mobile-scan": "workspace:*",
46
- "@genex-ai/multiplayer": "workspace:*",
47
47
  "@pixiv/three-vrm": "^3.5.4",
48
48
  "@types/pngjs": "^6.0.5",
49
49
  "@types/three": "^0.185.0",
@@ -0,0 +1,117 @@
1
+ """The M0/M1 acceptance artifact: a castle room built from primitives.
2
+
3
+ Deliberately NOT a torture test of Blender -- it is a torture test of the LOOP.
4
+ It exercises the things a real composition step needs (booleans, arrays,
5
+ materials, instancing, transforms) and produces something a human can judge in
6
+ one glance: either the contact sheet shows a castle room, or it does not.
7
+ """
8
+
9
+ import math
10
+
11
+ import bpy
12
+
13
+ ROOM = 12.0
14
+ WALL_H = 5.0
15
+ WALL_T = 0.6
16
+
17
+
18
+ def _clear():
19
+ bpy.ops.wm.read_factory_settings(use_empty=True)
20
+
21
+
22
+ def _mat(name, rgb, rough=0.85):
23
+ m = bpy.data.materials.new(name)
24
+ m.use_nodes = True
25
+ bsdf = m.node_tree.nodes["Principled BSDF"]
26
+ bsdf.inputs["Base Color"].default_value = (*rgb, 1.0)
27
+ # 'Roughness' is stable across 4.x; 'Specular' was RENAMED in 4.0 and is the
28
+ # single most common KeyError in LLM-written bpy. Do not reach for it.
29
+ bsdf.inputs["Roughness"].default_value = rough
30
+ # BOTH, and the second one is the trap: Workbench's MATERIAL colour mode --
31
+ # what every contact sheet renders -- reads `diffuse_color`, NOT the
32
+ # Principled BSDF. Setting only the BSDF gives a scene that exports with
33
+ # correct colours, reports materialCount>0, and renders ENTIRELY GREY.
34
+ # Numbers cannot catch this; the picture can. Measured 2026-08-31.
35
+ m.diffuse_color = (*rgb, 1.0)
36
+ return m
37
+
38
+
39
+ def _cube(name, loc, scale, mat):
40
+ bpy.ops.mesh.primitive_cube_add(size=2.0, location=loc)
41
+ o = bpy.context.active_object
42
+ o.name = name
43
+ o.scale = scale
44
+ o.data.materials.append(mat)
45
+ return o
46
+
47
+
48
+ def build():
49
+ _clear()
50
+ stone = _mat("Stone", (0.46, 0.44, 0.41))
51
+ dark = _mat("StoneDark", (0.26, 0.25, 0.24))
52
+ wood = _mat("Wood", (0.24, 0.14, 0.07))
53
+ roofm = _mat("Roof", (0.35, 0.12, 0.12), rough=0.6)
54
+ fire = _mat("Fire", (1.0, 0.45, 0.1), rough=0.4)
55
+
56
+ _cube("Floor", (0, 0, -0.25), (ROOM / 2, ROOM / 2, 0.25), dark)
57
+
58
+ half = ROOM / 2
59
+ # Three solid walls; the south wall is split to leave a doorway.
60
+ _cube("Wall_N", (0, half, WALL_H / 2), (half, WALL_T / 2, WALL_H / 2), stone)
61
+ _cube("Wall_E", (half, 0, WALL_H / 2), (WALL_T / 2, half, WALL_H / 2), stone)
62
+ _cube("Wall_W", (-half, 0, WALL_H / 2), (WALL_T / 2, half, WALL_H / 2), stone)
63
+ door = 2.2
64
+ side = (ROOM - door) / 4
65
+ for sgn in (-1, 1):
66
+ _cube(f"Wall_S{'L' if sgn < 0 else 'R'}",
67
+ (sgn * (door / 2 + side), -half, WALL_H / 2),
68
+ (side, WALL_T / 2, WALL_H / 2), stone)
69
+ _cube("Lintel", (0, -half, WALL_H - 0.5), (door / 2, WALL_T / 2, 0.5), stone)
70
+
71
+ # Crenellations: a real array, not four hand-placed boxes.
72
+ for i in range(9):
73
+ x = -half + 0.6 + i * (ROOM - 1.2) / 8
74
+ for y in (half, -half):
75
+ _cube(f"Cren_{i}_{'n' if y > 0 else 's'}",
76
+ (x, y, WALL_H + 0.35), (0.35, WALL_T / 2, 0.35), stone)
77
+
78
+ # Corner towers with conical roofs.
79
+ for i, (sx, sy) in enumerate(((1, 1), (1, -1), (-1, 1), (-1, -1))):
80
+ cx, cy = sx * half, sy * half
81
+ bpy.ops.mesh.primitive_cylinder_add(radius=1.5, depth=WALL_H + 2.0,
82
+ location=(cx, cy, (WALL_H + 2.0) / 2), vertices=16)
83
+ t = bpy.context.active_object
84
+ t.name = f"Tower_{i}"
85
+ t.data.materials.append(stone)
86
+ bpy.ops.mesh.primitive_cone_add(radius1=2.0, depth=2.4, vertices=16,
87
+ location=(cx, cy, WALL_H + 2.0 + 1.2))
88
+ r = bpy.context.active_object
89
+ r.name = f"TowerRoof_{i}"
90
+ r.data.materials.append(roofm)
91
+
92
+ # A table, so the room reads as inhabited and the sheet has a scale cue.
93
+ _cube("TableTop", (0, 1.0, 1.1), (2.2, 0.9, 0.12), wood)
94
+ for dx in (-1.9, 1.9):
95
+ for dy in (-0.65, 0.65):
96
+ _cube(f"Leg_{dx}_{dy}", (dx, 1.0 + dy, 0.55), (0.12, 0.12, 0.55), wood)
97
+
98
+ # Wall torches -- emissive-ish spheres; they also prove material assignment.
99
+ for i, (tx, ty) in enumerate(((-half + 0.6, 2.5), (-half + 0.6, -2.5),
100
+ (half - 0.6, 2.5), (half - 0.6, -2.5))):
101
+ _cube(f"Sconce_{i}", (tx, ty, 3.0), (0.15, 0.15, 0.4), wood)
102
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=0.32, location=(tx, ty, 3.5), segments=12, ring_count=8)
103
+ f = bpy.context.active_object
104
+ f.name = f"Flame_{i}"
105
+ f.data.materials.append(fire)
106
+
107
+ bpy.ops.object.select_all(action="DESELECT")
108
+ n = len(bpy.context.scene.objects)
109
+ tris = sum(
110
+ sum(max(len(p.vertices) - 2, 0) for p in o.data.polygons)
111
+ for o in bpy.context.scene.objects if o.type == "MESH"
112
+ )
113
+ print(f"castle: {n} objects, {tris} tris")
114
+ return n, tris
115
+
116
+
117
+ build()
@@ -0,0 +1,245 @@
1
+ """Fail-closed GPU witness.
2
+
3
+ NAMED gpu_witness, NOT gpu: Blender ships a built-in `gpu` module, and a local
4
+ `gpu.py` is shadowed by it. Measured 2026-09-01 -- `import gpu` returned
5
+ Blender's module, `assert_gpu_or_die` did not exist on it, and the assertion
6
+ never ran. The container still failed closed, which hid the mistake.
7
+
8
+ There are FIVE independent ways to end up rendering on the CPU with a
9
+ correct-looking image, a 200 response and a zero exit code. This module exists
10
+ because any one of them alone would let the GPU tier pass every functional test
11
+ while delivering nothing but a bill:
12
+
13
+ 1. EEVEE RENDERS ON THE CPU. Measured on the CPU image (Mesa 22.3.6): a full
14
+ EEVEE contact sheet with correct shadows, 47.5s vs 0.4s for Workbench. So
15
+ "EEVEE worked" is a PERFORMANCE signal, never a GPU signal. Dockerfile.gpu
16
+ installs no Mesa vendor at all, so there is nothing to fall back onto --
17
+ that absence is the backstop, not this module.
18
+ 2. `prefs.devices` is EMPTY until refresh_devices() populates it, so the
19
+ obvious enabling loop iterates zero elements and reports success.
20
+ 3. `scene.cycles.device` defaults to 'CPU' and is stored PER SCENE inside the
21
+ .blend, so perfect preferences still render on CPU.
22
+ 4. Blanket `d.use = True` enables HYBRID CPU+GPU, which every other check
23
+ still calls "GPU".
24
+ 5. An uncaught exception under --python exits 0. (Handled by
25
+ --python-exit-code 1 in the CMD; nothing here raises, on purpose.)
26
+
27
+ Nothing in this module raises. Every failure path calls sys.exit() explicitly.
28
+ """
29
+
30
+ import os
31
+ import sys
32
+ import tempfile
33
+
34
+ import bpy
35
+
36
+ # 4.2/4.5 renamed the engine; it is BLENDER_EEVEE again on 5.0+. One constant.
37
+ def _eevee_engine():
38
+ """The EEVEE id for THIS Blender. Renamed to BLENDER_EEVEE_NEXT in 4.2 and
39
+ renamed BACK to BLENDER_EEVEE in 5.0, so a literal is wrong on one side of
40
+ that line — and setting an unknown engine id fails at render, far from the
41
+ cause. Resolved from the running build rather than assumed.
42
+
43
+ Mirrored by eeveeEngineFor() in apps/cli/src/lib/blender-local.ts, which
44
+ gates which Blenders the local lane will drive."""
45
+ try:
46
+ items = bpy.types.RenderSettings.bl_rna.properties["engine"].enum_items
47
+ names = {i.identifier for i in items}
48
+ except Exception: # noqa: BLE001
49
+ names = set()
50
+ for candidate in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"):
51
+ if candidate in names:
52
+ return candidate
53
+ return "BLENDER_EEVEE_NEXT" if bpy.app.version < (5, 0, 0) else "BLENDER_EEVEE"
54
+
55
+
56
+ EEVEE_ENGINE = _eevee_engine()
57
+
58
+ # Preference order, not a hardcoded value. OptiX first because production is
59
+ # NVIDIA, but the vendor is DISCOVERED from what the build actually offers --
60
+ # which is what lets the identical service be exercised on an Apple-silicon Mac
61
+ # (METAL) or an AMD box (HIP) instead of only on the target hardware.
62
+ # GENEX_BLENDER_CYCLES_BACKEND pins one explicitly.
63
+ CYCLES_BACKEND_PREFERENCE = ("OPTIX", "CUDA", "METAL", "HIP", "ONEAPI")
64
+ SOFTWARE_DEVICES = ("SOFTWARE", "NONE", "UNKNOWN")
65
+
66
+
67
+ def _force_context():
68
+ """Render 4x4 with EEVEE. Returns True if it actually produced pixels.
69
+
70
+ ITS SUCCESS IS NOT EVIDENCE OF A GPU. MEASURED 2026-09-01 on the CPU image
71
+ (Debian bookworm, Mesa 22.3.6 llvmpipe): EEVEE renders correctly, with real
72
+ shadows, entirely on the CPU -- 47.5s for a 4-view sheet against 0.4s for
73
+ Workbench. An earlier version of this module treated a successful probe as
74
+ the strongest GPU signal, which would have PASSED on any GPU-less host that
75
+ has Mesa installed.
76
+
77
+ What it is actually for: forcing the lazily-created GPU context so
78
+ gpu.platform can be queried at all, and catching a broken graphics stack.
79
+ The real GPU discriminator is configure_cycles() -- enumerating an OptiX
80
+ device needs a real driver and cannot be faked by a software rasterizer."""
81
+ s = bpy.context.scene
82
+ r = s.render
83
+ ok = False
84
+ keep = (r.engine, r.resolution_x, r.resolution_y, r.filepath,
85
+ r.image_settings.file_format, r.resolution_percentage)
86
+ try:
87
+ r.engine = EEVEE_ENGINE
88
+ r.resolution_x = r.resolution_y = 4
89
+ r.resolution_percentage = 100
90
+ r.image_settings.file_format = "PNG"
91
+ # mkstemp, NOT a fixed path. This is a LIVE SINGLE-TENANT BUG, not
92
+ # only a multi-seat one: a leftover file from any earlier run makes the
93
+ # `getsize() > 0` check below true for a probe that rendered nothing, so
94
+ # a GPU-less box passes an assertion whose entire job is to fail there.
95
+ # With seats it is also a boot race between children.
96
+ fd, probe_path = tempfile.mkstemp(prefix="genex-gpu-probe-", suffix=".png")
97
+ os.close(fd)
98
+ os.unlink(probe_path) # Blender must create it, or it proves nothing.
99
+ r.filepath = probe_path
100
+ # A camera is required and a fresh scene has none, so the probe must
101
+ # bring its own or it fails for a reason that has nothing to do with
102
+ # the GPU. Measured: "Cannot render, no camera".
103
+ cam_data = bpy.data.cameras.new("__genex_probe_cam")
104
+ cam = bpy.data.objects.new("__genex_probe_cam", cam_data)
105
+ s.collection.objects.link(cam)
106
+ prev_cam, s.camera = s.camera, cam
107
+ try:
108
+ bpy.ops.render.render(write_still=True)
109
+ ok = os.path.exists(probe_path) and os.path.getsize(probe_path) > 0
110
+ finally:
111
+ s.camera = prev_cam
112
+ bpy.data.objects.remove(cam, do_unlink=True)
113
+ bpy.data.cameras.remove(cam_data)
114
+ try:
115
+ os.unlink(probe_path)
116
+ except OSError:
117
+ pass
118
+ except Exception as e: # noqa: BLE001 - a probe failure is a verdict, not a crash
119
+ sys.stderr.write("[gpu] probe render failed: %s\n" % e)
120
+ finally:
121
+ (r.engine, r.resolution_x, r.resolution_y, r.filepath,
122
+ r.image_settings.file_format, r.resolution_percentage) = keep
123
+ return ok
124
+
125
+
126
+ def configure_cycles():
127
+ """Enable OptiX devices and return their names. [] means no GPU device."""
128
+ addon = bpy.context.preferences.addons.get("cycles")
129
+ if addon is None:
130
+ return []
131
+ prefs = addon.preferences
132
+ # refresh_devices(), NOT get_devices(): the latter is deprecated on 4.5 and
133
+ # returns None, so the ubiquitous `prefs.get_devices()[0]` idiom raises
134
+ # TypeError. And prefs.devices is empty until this call.
135
+ try:
136
+ prefs.refresh_devices()
137
+ except Exception as e: # noqa: BLE001
138
+ sys.stderr.write("[gpu] refresh_devices failed: %s\n" % e)
139
+ return []
140
+
141
+ # bl_rna enum_items is EMPTY for this dynamic enum — get_device_types() is
142
+ # the only reliable way to ask what this build supports.
143
+ try:
144
+ available = {t[0] for t in prefs.get_device_types(bpy.context)}
145
+ except Exception: # noqa: BLE001
146
+ available = set()
147
+ # NONE is always offered and means CPU — never treat it as a GPU backend.
148
+ available.discard("NONE")
149
+
150
+ pinned = os.environ.get("GENEX_BLENDER_CYCLES_BACKEND")
151
+ if pinned:
152
+ backend = pinned if pinned in available else None
153
+ else:
154
+ backend = next((b for b in CYCLES_BACKEND_PREFERENCE if b in available), None)
155
+ if backend is None:
156
+ sys.stderr.write("[gpu] no GPU compute backend offered (have: %s)\n"
157
+ % sorted(available))
158
+ return []
159
+
160
+ prefs.compute_device_type = backend
161
+ prefs.refresh_devices()
162
+
163
+ gpus = []
164
+ for d in prefs.devices:
165
+ # NOT `d.use = True`. The CPU is ALWAYS in the candidate list, and new
166
+ # GPU entries already default to use=True, so blanket-enabling silently
167
+ # turns on hybrid CPU+GPU rendering that still reports as "GPU".
168
+ d.use = (d.type == backend)
169
+ if d.use:
170
+ gpus.append("%s (%s)" % (d.name, backend))
171
+
172
+ # Per SCENE, and stored in the .blend — so a restored checkpoint can arrive
173
+ # with device='CPU' even when preferences are perfect.
174
+ for scene in bpy.data.scenes:
175
+ try:
176
+ scene.cycles.device = "GPU"
177
+ except AttributeError:
178
+ pass # cycles addon present but scene not yet initialised
179
+ return gpus
180
+
181
+
182
+ def witness():
183
+ """What the renderer actually is, right now. Cheap enough to call at boot."""
184
+ import gpu as bgpu
185
+
186
+ probe_ok = _force_context()
187
+ try:
188
+ return {
189
+ "eeveeProbe": probe_ok,
190
+ "backend": bgpu.platform.backend_type_get(),
191
+ "device": bgpu.platform.device_type_get(),
192
+ "renderer": bgpu.platform.renderer_get(),
193
+ "vendor": bgpu.platform.vendor_get(),
194
+ "glVersion": bgpu.platform.version_get(),
195
+ "cyclesDevices": configure_cycles(),
196
+ "eeveeEngine": EEVEE_ENGINE,
197
+ }
198
+ except Exception as e: # noqa: BLE001
199
+ return {"eeveeProbe": probe_ok, "backend": "NONE", "device": "UNKNOWN",
200
+ "renderer": str(e), "vendor": "", "glVersion": "",
201
+ "cyclesDevices": configure_cycles(), "eeveeEngine": EEVEE_ENGINE}
202
+
203
+
204
+ def assert_gpu_or_die():
205
+ """Refuse to serve on software. Called BEFORE the socket opens, so a
206
+ misconfigured GPU pod never accepts a request it would bill for and answer
207
+ from the CPU."""
208
+ w = witness()
209
+ # The verdict rests on INDEPENDENT EVIDENCE, not on gpu.platform alone.
210
+ # Blender may decline to answer gpu.platform in background mode even when a
211
+ # GPU is present and working, so treating UNKNOWN as fatal would refuse a
212
+ # healthy pod and take the whole tier down. Explicit SOFTWARE is fatal; an
213
+ # unanswered query is only fatal when the EEVEE probe ALSO produced nothing.
214
+ if w["device"] == "SOFTWARE":
215
+ sys.stderr.write("[svc] FATAL gpu_tier_on_software: device reported SOFTWARE (%s)\n"
216
+ % w["renderer"])
217
+ sys.stderr.flush()
218
+ sys.exit(3)
219
+ if not w["eeveeProbe"]:
220
+ sys.stderr.write(
221
+ "[svc] FATAL eevee_unavailable: probe render produced nothing "
222
+ "(backend=%s device=%s renderer=%r)\n"
223
+ % (w["backend"], w["device"], w["renderer"]))
224
+ sys.stderr.flush()
225
+ sys.exit(3)
226
+ if w["device"] in ("UNKNOWN", "NONE"):
227
+ # Not fatal, but no longer silent: on a healthy NVIDIA pod this answers
228
+ # "NVIDIA", so an unanswered query means the rasterizer path -- the one
229
+ # every contact sheet uses -- is unverified.
230
+ sys.stderr.write(
231
+ "[svc] WARN rasterizer_unverified: gpu.platform reported %s. Cycles "
232
+ "has a GPU device, but EEVEE/Workbench GPU use is unconfirmed.\n"
233
+ % w["device"])
234
+ if not w["cyclesDevices"]:
235
+ # A software rasterizer cannot enumerate one. If this fires on a box you
236
+ # believe has a GPU, the driver or container toolkit is the problem.
237
+ sys.stderr.write("[svc] FATAL no_cycles_device: no GPU compute device "
238
+ "enumerated (this is the real GPU gate; EEVEE rendering "
239
+ "proves nothing)\n")
240
+ sys.stderr.flush()
241
+ sys.exit(4)
242
+ sys.stderr.write("[svc] GPU ok: %s %s | cycles: %s\n"
243
+ % (w["device"], w["renderer"], ", ".join(w["cyclesDevices"])))
244
+ sys.stderr.flush()
245
+ return w
@@ -0,0 +1,225 @@
1
+ """Scene operations. Every mutating call runs on the HTTP server's own thread,
2
+ which is Blender's main thread -- bpy is not thread-safe and the server is
3
+ deliberately non-threading so this holds without a queue."""
4
+
5
+ import contextlib
6
+ import io
7
+ import os
8
+ import tempfile
9
+ import traceback
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ import bpy
14
+
15
+ from views import scene_bounds
16
+
17
+ # The exec namespace PERSISTS across calls, so an agent can define a helper in
18
+ # one step and use it in the next. That is the difference between a REPL and a
19
+ # series of unrelated scripts.
20
+ NAMESPACE = {}
21
+
22
+
23
+ def reset_namespace():
24
+ import math
25
+ import random
26
+
27
+ import bmesh
28
+ import mathutils
29
+
30
+ NAMESPACE.clear()
31
+ NAMESPACE.update(
32
+ {"bpy": bpy, "bmesh": bmesh, "mathutils": mathutils, "math": math, "random": random}
33
+ )
34
+
35
+
36
+ def run_script(source):
37
+ """Execute arbitrary Python against the live scene.
38
+
39
+ Arbitrary by design: a fixed tool vocabulary cannot express 'build a castle'.
40
+ The containment story is the container -- disposable, egress-locked in the
41
+ hosted path -- never a sanitizer on this string, which does not work.
42
+ """
43
+ out, err = io.StringIO(), io.StringIO()
44
+ error = None
45
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
46
+ try:
47
+ exec(compile(source, "<genex-exec>", "exec"), NAMESPACE)
48
+ except Exception:
49
+ error = traceback.format_exc(limit=8)
50
+ return {"stdout": out.getvalue(), "stderr": err.getvalue(), "error": error}
51
+
52
+
53
+ def _tri_count(obj):
54
+ if obj.type != "MESH" or obj.data is None:
55
+ return 0
56
+ return sum(max(len(p.vertices) - 2, 0) for p in obj.data.polygons)
57
+
58
+
59
+ def scene_info():
60
+ """The numeric truths. These are the GATE -- the picture is for steering.
61
+
62
+ A screenshot judge is measurably bad at pass/fail (WorldCoder-Bench: a
63
+ screenshot+VLM gate passes 2.7% of outputs), so an agent should assert on
64
+ these counts and use the render to decide what to change.
65
+ """
66
+ objects, total_tris = [], 0
67
+ for obj in bpy.context.scene.objects:
68
+ tris = _tri_count(obj)
69
+ total_tris += tris
70
+ objects.append(
71
+ {
72
+ "name": obj.name,
73
+ "type": obj.type,
74
+ "tris": tris,
75
+ "location": [round(v, 4) for v in obj.location],
76
+ "dimensions": [round(v, 4) for v in obj.dimensions],
77
+ "materials": [m.name for m in obj.data.materials if m]
78
+ if getattr(obj.data, "materials", None)
79
+ else [],
80
+ "visible": obj.visible_get(),
81
+ }
82
+ )
83
+ center, radius = scene_bounds()
84
+ return {
85
+ "objectCount": len(objects),
86
+ "meshCount": sum(1 for o in objects if o["type"] == "MESH"),
87
+ "totalTris": total_tris,
88
+ "materialCount": len(bpy.data.materials),
89
+ "bounds": {
90
+ "center": [round(v, 4) for v in center],
91
+ "radius": round(radius, 4),
92
+ },
93
+ "objects": objects,
94
+ }
95
+
96
+
97
+ def reset_scene():
98
+ bpy.ops.wm.read_factory_settings(use_empty=True)
99
+ reset_namespace()
100
+ return scene_info()
101
+
102
+
103
+ # A picture the agent LOOKS AT goes inline; an artifact the GAME SHIPS goes to
104
+ # object storage. Base64 inflates 4/3 plus JSON escaping, so ~7 MB of raw GLB
105
+ # already blows RunPod's 10 MB /run ceiling -- and it fails only on long,
106
+ # successful sessions that generated a lot of texture, which is the worst time.
107
+ INLINE_GLB_MAX_BYTES = 6 * 1024 * 1024
108
+
109
+
110
+ def export_glb(path):
111
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
112
+ bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=False)
113
+ return {"path": path, "bytes": os.path.getsize(path)}
114
+
115
+
116
+ def upload_file(path, url, headers=None):
117
+ """PUT the bytes to a presigned URL. The service never holds an account key
118
+ -- the caller mints a single-object, short-TTL URL, so code running inside
119
+ /exec has nothing durable to steal."""
120
+ import urllib.request
121
+
122
+ with open(path, "rb") as f:
123
+ body = f.read()
124
+ req = urllib.request.Request(url, data=body, method="PUT")
125
+ for k, v in (headers or {}).items():
126
+ req.add_header(k, v)
127
+ with urllib.request.urlopen(req, timeout=300) as r: # noqa: S310 - caller-minted
128
+ return {"status": r.status, "bytes": len(body)}
129
+
130
+
131
+ def save_checkpoint(path):
132
+ """Whole-scene snapshot. Measured elsewhere at tens to hundreds of ms, and
133
+ it hides inside the agent's think time. What makes a pod disposable."""
134
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
135
+ bpy.ops.wm.save_as_mainfile(filepath=path, copy=True, compress=True)
136
+ return os.path.getsize(path)
137
+
138
+
139
+ def load_checkpoint(path):
140
+ """Restore a snapshot. NOTE: this invalidates every live bpy reference, so
141
+ the exec namespace is reset alongside it -- a held Python handle raises
142
+ `StructRNA has been removed` long after the restore, somewhere unrelated."""
143
+ bpy.ops.wm.open_mainfile(filepath=path)
144
+ reset_namespace()
145
+
146
+
147
+ def _host_allowed(url):
148
+ """Empty allowlist = permissive, which is correct for the admin-only spike
149
+ and wrong for the hosted lane. GENEX_BLENDER_IMPORT_HOSTS narrows it without
150
+ a code change when this reaches a session."""
151
+ allow = [h.strip() for h in os.environ.get("GENEX_BLENDER_IMPORT_HOSTS", "").split(",") if h.strip()]
152
+ if not allow:
153
+ return True
154
+ host = (urllib.parse.urlparse(url).hostname or "").lower()
155
+ return any(host == a.lower() or host.endswith("." + a.lower().lstrip(".")) for a in allow)
156
+
157
+
158
+ def import_glb(url):
159
+ if not _host_allowed(url):
160
+ raise ValueError(f"import host not allowed: {urllib.parse.urlparse(url).hostname}")
161
+ before = {o.name for o in bpy.context.scene.objects}
162
+ fd, tmp = tempfile.mkstemp(suffix=".glb", prefix="genex-import-")
163
+ os.close(fd)
164
+ try:
165
+ with urllib.request.urlopen(url, timeout=60) as r: # noqa: S310 - allowlisted above
166
+ with open(tmp, "wb") as f:
167
+ f.write(r.read())
168
+ bpy.ops.import_scene.gltf(filepath=tmp)
169
+ finally:
170
+ os.unlink(tmp)
171
+ added = [o.name for o in bpy.context.scene.objects if o.name not in before]
172
+ return {"imported": added, "count": len(added)}
173
+
174
+
175
+ def scene_digest():
176
+ """A fingerprint of everything a render depends on, for the sheet-repeat guard.
177
+
178
+ Built from the data blocks directly, not from scene_info(): that summary
179
+ carries location, dimensions and material NAMES, and nothing else — so a
180
+ rotation, a scale, a modifier, a moved vertex, a dimmed light or a recoloured
181
+ material all hashed identically and the model was told the picture was
182
+ unchanged (review finding). Vertex coordinates go through numpy, so a 600k
183
+ triangle mesh costs one foreach_get and one hash rather than a Python loop.
184
+ """
185
+ import hashlib
186
+ h = hashlib.sha256()
187
+ scene = bpy.context.scene
188
+ for obj in scene.objects:
189
+ # The RNA transform, NOT matrix_world: a script that sets rotation_euler
190
+ # leaves matrix_world stale until the depsgraph evaluates, which in
191
+ # background mode is not before this runs. Measured live: a rotation
192
+ # hashed identical through matrix_world and the guard said "unchanged".
193
+ parent = obj.parent.name if obj.parent else ""
194
+ h.update(("%s|%s|%s|%s|%s|%s|%d|%s\n" % (
195
+ obj.name, obj.type, parent, tuple(obj.location),
196
+ tuple(obj.rotation_euler), tuple(obj.scale),
197
+ obj.hide_render, [m.type for m in getattr(obj, "modifiers", [])],
198
+ )).encode("utf-8"))
199
+ data = obj.data
200
+ if obj.type == "MESH" and data is not None:
201
+ n = len(data.vertices)
202
+ if n:
203
+ try:
204
+ import numpy as np
205
+ buf = np.empty(n * 3, dtype=np.float32)
206
+ data.vertices.foreach_get("co", buf)
207
+ h.update(buf.tobytes())
208
+ except Exception:
209
+ h.update(str(n).encode())
210
+ for m in data.materials:
211
+ if m is None:
212
+ continue
213
+ h.update(("mat:%s:%s\n" % (m.name, tuple(m.diffuse_color))).encode())
214
+ if m.use_nodes and m.node_tree:
215
+ for node in m.node_tree.nodes:
216
+ if node.type == "BSDF_PRINCIPLED":
217
+ h.update(("bsdf:%s\n" % (tuple(node.inputs["Base Color"].default_value),)).encode())
218
+ elif obj.type == "LIGHT" and data is not None:
219
+ h.update(("light:%s:%s:%s\n" % (data.type, data.energy, tuple(data.color))).encode())
220
+ elif obj.type == "CAMERA" and data is not None:
221
+ h.update(("cam:%s:%s\n" % (data.lens, data.type)).encode())
222
+ w = scene.world
223
+ if w is not None:
224
+ h.update(("world:%s\n" % (tuple(getattr(w, "color", (0, 0, 0))),)).encode())
225
+ return h.hexdigest()