@volter/blender-engine 0.1.0

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 (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
@@ -0,0 +1,554 @@
1
+ """OUR BLENDER RENDERS WITH THREE.JS -- the ask, shipped INSIDE the Blender.
2
+
3
+ THE RENDER OVERRIDE IS NOT THE EDITOR'S. `session.py` is the editor tab's
4
+ long-lived session and carries this same override for the session it owns;
5
+ this file is the other delivery of it -- a Blender STARTUP module that ships in
6
+ the pack's resource tree (`scripts/startup/vgai_three.py`), so a bare
7
+ `blender -b ... -a` on ANY surface renders with three.js too. Blender imports
8
+ every module in `scripts/startup` and calls its `register()`.
9
+
10
+ THE ASK SHIPS WITH THE BLENDER; THE ANSWER DOES NOT. What renders is a
11
+ PRESENTER the host attached to this process (`browser/three/attach-presenter.ts`
12
+ around `browser/three/presenter.ts`): a canvas, the frame translated to
13
+ three.js, a camera, the display transform. This file only asks. The channel is
14
+ the directory protocol `session.py`'s header states, with one writer per
15
+ directory:
16
+
17
+ <root>/ask/ THIS PROGRAM writes and unlinks; the host only reads.
18
+ <root>/reply/ the HOST writes and unlinks; this program only reads.
19
+ <root>/arena.bin THIS PROGRAM writes (the export door does); host reads.
20
+
21
+ NO PRESENTER, NO OVERRIDE. `register()` asks `{"op": "hello"}` and waits two
22
+ seconds. Answered, it takes the engine ids and every render is a photograph.
23
+ Unanswered, it prints ONE line naming the root it waited at and registers
24
+ nothing at all, so Blender renders with Cycles -- correct, slow, and never
25
+ silent.
26
+
27
+ THE ENGINE IS TAKEN ON `load_post`, NOT IN `register()`, and that is a
28
+ measurement. Blender's `bpy.utils.load_scripts` registers every startup module
29
+ BEFORE it enables the add-ons, so at `register()` time `cycles.CyclesRender`
30
+ does not exist yet and taking the `CYCLES` id here would be overwritten by the
31
+ add-on moments later. Measured on the native 5.2.0 LTS oracle
32
+ (`blender -b --python-expr ...` with this module's probe in
33
+ `BLENDER_USER_SCRIPTS`): at `register()` `CYCLES` is unregistered, and the
34
+ FIRST `load_post` -- which Blender fires for the startup file itself, before
35
+ any `--python-expr` runs -- sees `<class 'cycles.CyclesRender'>`. So the
36
+ handler is where the ids are taken, and it runs again after every file load
37
+ because a load re-enables the add-ons and hands `CYCLES` back.
38
+
39
+ WHAT THIS FILE DOES NOT CARRY, deliberately: the editor session's incremental
40
+ geometry record (every frame ships in full -- `known` is always empty, so no
41
+ presenter can ever be asked about geometry it does not hold), its document,
42
+ its overlays, and its world SHADER reducer. A world here is a CONSTANT
43
+ background or nothing, named as a warning; see `_world`.
44
+ """
45
+
46
+ import base64
47
+ import json
48
+ import os
49
+ import sys
50
+ import time
51
+
52
+ import bpy
53
+
54
+ # THE EXPORT DOOR, compiled into this Blender (`bpy_web_export.cc`): one call
55
+ # evaluates the scene and writes every column into a side arena, naming each as
56
+ # `{offset, length, dtype, count, stride}` in a small JSON frame.
57
+ import _blender_web
58
+
59
+ ROOT = os.environ.get("VGAI_PRESENTER_ROOT", "/tmp/vgai-presenter")
60
+ ASK = os.path.join(ROOT, "ask")
61
+ REPLY = os.path.join(ROOT, "reply")
62
+ # WHERE THE EXPORT DOOR LEAVES THE ARENA. The host cannot reach this program's
63
+ # linear memory, so the arena crosses as a file -- the door writes it when
64
+ # `buffer_path` is set, and the host reads it beside the ask.
65
+ EXPORT_BUFFER_PATH = os.environ.get(
66
+ "VGAI_EXPORT_BUFFER_PATH", os.path.join(ROOT, "arena.bin")
67
+ )
68
+ # THE FRAME'S IDENTITY IS A PAIR, `(session, revision)`, and the presenter
69
+ # refuses a revision that went backwards under a name it already holds. One
70
+ # process is one session; the revision only ever counts up within it.
71
+ #
72
+ # THE PID IS NOT THE NAME, and that is measured rather than cautious. A
73
+ # presenter outlives the Blenders it serves, so two processes' names must
74
+ # differ -- and in the browser the guest's pids START OVER: the second
75
+ # `blender -b ... -a` on one page came up as the same pid, its revision
76
+ # restarted at 1 under a name the view already held at 24, and every render in
77
+ # it died on "The runtime frame is older than the displayed model" (measured
78
+ # 2026-09-20 in the substrate tab; the first run on a freshly loaded page
79
+ # always worked, which is what hid it). The start time is what makes the name
80
+ # this process's own.
81
+ SESSION = "vgai-three:%d-%d" % (os.getpid(), time.time_ns())
82
+ HELLO_TIMEOUT_SECONDS = 2.0
83
+ POLL_SECONDS = 0.002
84
+
85
+ _SEQUENCE = [0]
86
+ _REVISION = [0]
87
+ _WARNED = set()
88
+ _WARNINGS = []
89
+ def _say(line):
90
+ """One line on Blender's own stderr, resolved at CALL TIME.
91
+
92
+ WHERE THIS LINE COMES OUT, and it is not where a reader looks first.
93
+ MEASURED 2026-09-20 in the substrate tab with the whole command redirected
94
+ (`blender ... > log 2>&1`): Python's `sys.stderr` in this build is NOT the
95
+ process's fd 2. `print(..., file=sys.stderr)` and
96
+ `sys.stderr.buffer.write(...)` BOTH reached the terminal and NEITHER
97
+ reached the file, while `print()` to stdout reached the file and not the
98
+ terminal. So the one line this module ever prints -- the one that says
99
+ Cycles is about to render -- is read on the TERMINAL; a reader who greps
100
+ only a redirect will conclude the fallback is silent when it is not. That
101
+ reading cost an hour and a wrong diagnosis; it is written here so it costs
102
+ nobody else one.
103
+
104
+ The stream is taken at call time rather than captured at import because a
105
+ startup module is imported while Blender is still standing its streams up
106
+ and nothing here needs the captured one. Both spellings were measured to
107
+ work; this is the plainer.
108
+ """
109
+ print(line, file=sys.stderr or sys.stdout, flush=True)
110
+
111
+
112
+ def warn(what):
113
+ """A capability this render cannot reach, named ONCE and never raised."""
114
+ if what in _WARNED:
115
+ return
116
+ _WARNED.add(what)
117
+ _WARNINGS.append(what)
118
+ _say("@@VGAI-WARN " + what)
119
+
120
+
121
+ def _drop(path):
122
+ try:
123
+ os.unlink(path)
124
+ except OSError as error:
125
+ _say("@@VGAI-WARN vgai_three could not remove its own %s: %r" % (path, error))
126
+
127
+
128
+ def _prepare_directories():
129
+ for directory in (ROOT, ASK, REPLY):
130
+ try:
131
+ os.makedirs(directory, exist_ok=True)
132
+ except OSError as error:
133
+ _say("@@VGAI-ERROR vgai_three cannot create %s: %r" % (directory, error))
134
+ try:
135
+ os.chmod(directory, 0o777)
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def ask(payload, timeout=None):
141
+ """Block until the host answers. `None` when a bounded wait ran out.
142
+
143
+ We write `ask/<n>.json` + `ask/<n>.done`, read `reply/<n>.json` once
144
+ `reply/<n>.done` appears, and remove only our own two files; their
145
+ disappearance is what tells the host it may retire the reply it wrote.
146
+ `.done` goes last, both times.
147
+ """
148
+ name = str(_SEQUENCE[0])
149
+ _SEQUENCE[0] += 1
150
+ with open(os.path.join(ASK, name + ".json"), "w") as fh:
151
+ fh.write(json.dumps(payload))
152
+ with open(os.path.join(ASK, name + ".done"), "w") as fh:
153
+ fh.write("1")
154
+ marker = os.path.join(REPLY, name + ".done")
155
+ deadline = None if timeout is None else time.time() + timeout
156
+ while not os.path.exists(marker):
157
+ if deadline is not None and time.time() >= deadline:
158
+ _drop(os.path.join(ASK, name + ".json"))
159
+ _drop(os.path.join(ASK, name + ".done"))
160
+ return None
161
+ time.sleep(POLL_SECONDS)
162
+ with open(os.path.join(REPLY, name + ".json")) as fh:
163
+ body = fh.read()
164
+ _drop(os.path.join(ASK, name + ".json"))
165
+ _drop(os.path.join(ASK, name + ".done"))
166
+ return json.loads(body) if body else None
167
+
168
+
169
+ # ---------------------------------------------------------------- the frame
170
+
171
+
172
+ def _world(scene):
173
+ """The world as radiance, for the CONSTANT cases and no others.
174
+
175
+ The editor session reduces Blender's world node graph to the presenter's
176
+ expression grammar (sky textures, ramps, `Is Camera Ray` splits); that
177
+ reducer is two hundred lines and belongs to the session that ships with
178
+ the editor. Here a world is a constant colour and strength -- Blender's
179
+ own factory world is exactly that -- and anything else is named as a
180
+ warning and left out, so the model stays lit by its lamps and the picture
181
+ is never quietly wrong about which door failed.
182
+ """
183
+ world = scene.world
184
+ if world is None:
185
+ return None
186
+ if world.node_tree is None:
187
+ return {"color": [float(c) for c in list(world.color)[:3]], "strength": 1.0}
188
+ outputs = [
189
+ node
190
+ for node in world.node_tree.nodes
191
+ if node.bl_idname == "ShaderNodeOutputWorld" and node.is_active_output
192
+ ]
193
+ links = list(outputs[0].inputs["Surface"].links) if len(outputs) == 1 else []
194
+ background = links[0].from_node if len(links) == 1 else None
195
+ if (
196
+ background is None
197
+ or background.bl_idname != "ShaderNodeBackground"
198
+ or background.inputs["Color"].is_linked
199
+ or background.inputs["Strength"].is_linked
200
+ ):
201
+ warn(
202
+ "world: this Blender's render override reduces a CONSTANT world only (one "
203
+ "Background node with unlinked Color and Strength). This world's graph is "
204
+ "outside that, so the photograph is taken without a world; the editor's "
205
+ "own session (`session.py`) is what carries the full reducer."
206
+ )
207
+ return None
208
+ color = background.inputs["Color"].default_value
209
+ return {
210
+ "color": [float(c) for c in list(color)[:3]],
211
+ "strength": float(background.inputs["Strength"].default_value),
212
+ }
213
+
214
+
215
+ def _export():
216
+ """The C++ door's frame, with what the door does not describe merged in.
217
+
218
+ `known` is ALWAYS EMPTY: this process keeps no record of what a presenter
219
+ holds, so every frame ships its columns in full and the presenter's
220
+ unknown-geometry refusal can never fire.
221
+ """
222
+ options = {
223
+ "session": SESSION,
224
+ "evaluate": True,
225
+ "known": {},
226
+ "buffer_path": EXPORT_BUFFER_PATH,
227
+ }
228
+ frame = json.loads(_blender_web.export_frame(json.dumps(options)))
229
+ error = frame.get("error")
230
+ if error:
231
+ raise RuntimeError("Blender export door: %s" % error)
232
+ _REVISION[0] += 1
233
+ frame["session"] = SESSION
234
+ frame["revision"] = _REVISION[0]
235
+ scene = bpy.context.scene
236
+ frame["world"] = _world(scene)
237
+ frame["volumes"] = {}
238
+ # THE INSPECTION OVERLAYS ARE THE EDITOR'S, and a render hides them anyway.
239
+ frame["armatures"] = {}
240
+ frame["weights"] = None
241
+ warnings = list(frame.get("warnings", ()))
242
+ warnings.extend(_WARNINGS)
243
+ del _WARNINGS[:]
244
+ frame["warnings"] = warnings
245
+ return frame
246
+
247
+
248
+ def _present(capture):
249
+ answer = ask({"frame": _export(), "capture": capture})
250
+ if isinstance(answer, dict) and answer.get("error"):
251
+ raise RuntimeError(answer["error"])
252
+ return answer
253
+
254
+
255
+ # ---------------------------------------------------------------- the render engine
256
+
257
+
258
+ def _vertical_extent(cam, width, height):
259
+ """The photograph's vertical field of view in degrees (or, for an
260
+ orthographic camera, its vertical extent in Blender units), framed the way
261
+ Blender frames a render: `BKE_camera_params_compute_viewplane`.
262
+
263
+ Blender's `sensor_fit` says which sensor dimension spans which image
264
+ dimension. HORIZONTAL: `sensor_width` spans the image width, and the
265
+ vertical extent follows from the aspect. VERTICAL: `sensor_height` spans the
266
+ height. AUTO: `sensor_width` spans the LARGER image dimension. Pixel aspect
267
+ is not applied; the presenter renders square pixels."""
268
+ import math
269
+ landscape = width >= height
270
+ fit = cam.sensor_fit
271
+ if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
272
+ extent = float(cam.sensor_height if fit == "VERTICAL" else cam.sensor_width)
273
+ else:
274
+ extent = float(cam.sensor_width) * height / max(width, 1)
275
+ if cam.type == "ORTHO":
276
+ # `ortho_scale` spans the same dimension `sensor_fit` names.
277
+ if fit == "VERTICAL" or (fit == "AUTO" and not landscape):
278
+ return float(cam.ortho_scale)
279
+ return float(cam.ortho_scale) * height / max(width, 1)
280
+ return math.degrees(2.0 * math.atan(extent / (2.0 * float(cam.lens))))
281
+
282
+
283
+ def _photograph(depsgraph, width, height):
284
+ """three.js IS the renderer: the render is a photograph of the scene the
285
+ engine holds, taken through the scene's own camera at `scene.render`'s
286
+ exact resolution. `bpy.ops.render.render` semantics are the contract."""
287
+ scene = depsgraph.scene if hasattr(depsgraph, "scene") else bpy.context.scene
288
+ camera = scene.camera
289
+ if camera is None:
290
+ raise RuntimeError("The scene has no camera, so there is nothing to render")
291
+ matrix = camera.matrix_world
292
+ rotation = matrix.to_quaternion()
293
+ vector = __import__("mathutils").Vector
294
+ position = [float(v) for v in matrix.translation]
295
+ forward = rotation @ vector((0.0, 0.0, -1.0))
296
+ target = [position[i] + float(forward[i]) for i in range(3)]
297
+ # THE CAMERA'S ROLL, which position and target cannot express. A top-down
298
+ # render is the everyday case: looking straight down, "up" is a free choice
299
+ # and the photograph is wrong by an arbitrary rotation without it. All three
300
+ # go out in BLENDER'S frame; the presenter converts them through the model
301
+ # root's matrix and reports back through its inverse.
302
+ up = [float(v) for v in rotation @ vector((0.0, 1.0, 0.0))]
303
+ view = scene.view_settings
304
+ look = getattr(view, "look", "None") or "None"
305
+ transform = {
306
+ "Standard": "none",
307
+ "AgX": "agx",
308
+ "Filmic": "filmic",
309
+ "Khronos PBR Neutral": "neutral",
310
+ }.get(view.view_transform)
311
+ if transform is None:
312
+ raise RuntimeError(
313
+ "Blender view transform %s has no curve in the renderer" % view.view_transform
314
+ )
315
+ render = {
316
+ "width": int(width),
317
+ "height": int(height),
318
+ "fov": _vertical_extent(camera.data, int(width), int(height)),
319
+ "toneMapping": transform,
320
+ "exposure": float(2.0 ** view.exposure),
321
+ "orthographic": camera.data.type == "ORTHO",
322
+ "transparent": bool(scene.render.film_transparent),
323
+ }
324
+ # The renderer keys its baked look tables by the config's FULL name
325
+ # (`AgX - Medium High Contrast`), and `AgX - Base Contrast` is the AgX base
326
+ # view itself -- the same table as no look at all.
327
+ if look not in ("None", "AgX - Base Contrast"):
328
+ render["look"] = look
329
+ answer = _present(
330
+ {"position": position, "target": target, "up": up, "render": render}
331
+ )
332
+ if not isinstance(answer, dict) or "base64" not in answer:
333
+ raise RuntimeError("The renderer did not answer with a photograph")
334
+ _assert_photographed_from(answer.get("camera"), position, target, up)
335
+ return base64.b64decode(answer["base64"]), render
336
+
337
+
338
+ def _assert_photographed_from(reported, position, target, up):
339
+ """The photograph answers with the pose it ACTUALLY used, back in Blender's
340
+ frame, and it must be the pose that was sent -- exactly, with no tolerance.
341
+
342
+ This is an instrument, not a nicety. A render's image is allowed to differ
343
+ from Cycles (three.js is the renderer), so a camera placed in the wrong
344
+ frame produces a picture nobody can call wrong: the lane shipped for its
345
+ whole life photographing the model's underside from below the floor because
346
+ Blender's Z-up numbers were read as three.js world space.
347
+
348
+ The check is exact because the conversion is exact: the model root's world
349
+ matrix is a signed axis permutation, so a vector through it and its inverse
350
+ is bit-identical. A mismatch is the conversion being wrong, never the check
351
+ being too strict -- do not widen it.
352
+ """
353
+ sent = {"position": position, "target": target, "up": up}
354
+ if not isinstance(reported, dict):
355
+ raise RuntimeError(
356
+ "The renderer did not say which camera it photographed from, so the "
357
+ "render cannot be attributed to the scene camera %r" % (sent,)
358
+ )
359
+ for key in ("position", "target", "up"):
360
+ got = reported.get(key)
361
+ if not isinstance(got, (list, tuple)) or len(got) != 3:
362
+ raise RuntimeError(
363
+ "The renderer reported no %s for the camera it photographed from" % key
364
+ )
365
+ if [float(v) for v in got] != [float(v) for v in sent[key]]:
366
+ raise RuntimeError(
367
+ "The photograph was taken from a different camera than the scene's: "
368
+ "%s was sent as %r and came back as %r. The Blender-to-document "
369
+ "frame conversion in the presenter is wrong."
370
+ % (key, sent[key], list(got))
371
+ )
372
+
373
+
374
+ class VgaiRenderEngine(bpy.types.RenderEngine):
375
+ """The scene's renderer, so `write_still`, `save_render` and Render Result
376
+ behave as Blender's own.
377
+
378
+ Registered under the three engine ids a script names. The port has real
379
+ Cycles compiled in, so the Cycles add-on's own engine is unregistered
380
+ first -- otherwise `scene.render.engine = 'CYCLES'` resolves to it and
381
+ starts a path trace nobody asked for.
382
+ """
383
+
384
+ bl_idname = "VGAI_THREE"
385
+ bl_label = "three.js"
386
+ bl_use_preview = False
387
+
388
+ def render(self, depsgraph):
389
+ scene = depsgraph.scene
390
+ scale = scene.render.resolution_percentage / 100.0
391
+ width = max(1, int(scene.render.resolution_x * scale))
392
+ height = max(1, int(scene.render.resolution_y * scale))
393
+ try:
394
+ png, _render = _photograph(depsgraph, width, height)
395
+ except Exception as error: # noqa: BLE001 - reported to Blender as a render error
396
+ self.report({"ERROR"}, str(error))
397
+ return
398
+ path = os.path.join(ROOT, "render-%d.png" % int(time.time() * 1000))
399
+ with open(path, "wb") as fh:
400
+ fh.write(png)
401
+ result = self.begin_result(0, 0, width, height)
402
+ try:
403
+ result.layers[0].load_from_file(path)
404
+ except (RuntimeError, AttributeError) as error:
405
+ self.report({"ERROR"}, "Render result could not take the photograph: %s" % error)
406
+ self.end_result(result)
407
+ try:
408
+ os.unlink(path)
409
+ except OSError:
410
+ pass
411
+
412
+
413
+ def _engine_class(identifier):
414
+ """The registered RenderEngine class holding `identifier` as its `bl_idname`.
415
+
416
+ A Python-registered engine is exposed on `bpy.types` under its CLASS name
417
+ (`bpy.types.CyclesRender`), never under its id, so `bpy.types.CYCLES` is
418
+ always absent. The subclass tree is the one true registry."""
419
+ pending = list(bpy.types.RenderEngine.__subclasses__())
420
+ while pending:
421
+ cls = pending.pop()
422
+ if getattr(cls, "bl_idname", None) == identifier and getattr(cls, "is_registered", False):
423
+ return cls
424
+ pending.extend(cls.__subclasses__())
425
+ return None
426
+
427
+
428
+ def _release_from_owning_addon(existing):
429
+ """Take the class out of its OWNING ADD-ON's registration list as well.
430
+
431
+ `unregister_class(existing)` is only half the gesture. The add-on that
432
+ registered the class still holds it in its module-level `classes`, and
433
+ Blender tears every add-on down again on `read_factory_settings` and on
434
+ each `open_mainfile` -- so the add-on's own `unregister()` calls
435
+ `unregister_class` on a class that is already gone and raises
436
+ `RuntimeError: missing bl_rna attribute`. `addon_utils.disable` catches
437
+ that and prints a full traceback, and the remainder of that add-on's
438
+ `unregister()` never runs.
439
+
440
+ Dropping the class from `classes` makes the add-on's next
441
+ `register()`/`unregister()` pair agree with what this module actually did,
442
+ which is what keeps `scene.cycles` (the reason the add-on stays enabled at
443
+ all) reachable after a factory reset.
444
+ """
445
+ module = sys.modules.get(getattr(existing, "__module__", ""))
446
+ classes = getattr(module, "classes", None)
447
+ if isinstance(classes, tuple):
448
+ if existing in classes:
449
+ module.classes = tuple(cls for cls in classes if cls is not existing)
450
+ elif isinstance(classes, list):
451
+ while existing in classes:
452
+ classes.remove(existing)
453
+
454
+
455
+ def _register_engine():
456
+ """Take the three engine ids a script can name.
457
+
458
+ Blender refuses two classes with one `bl_idname`, so the add-on's engine
459
+ class is unregistered first and the bundle's patched registration sets a
460
+ built-in type aside; `bpy.utils.register_class` on a subclass whose
461
+ `bl_idname` is `CYCLES` (or `BLENDER_EEVEE`, the factory default) then
462
+ makes `scene.render.engine` resolve here. Only the Cycles ENGINE CLASS
463
+ goes: the add-on stays enabled, because `scene.cycles` is the add-on's
464
+ property group and a script sets `samples`, `use_denoising` and the rest
465
+ on it.
466
+ """
467
+ made, unavailable = [], []
468
+ for identifier, label in (
469
+ ("CYCLES", "Cycles"),
470
+ ("BLENDER_EEVEE", "EEVEE"),
471
+ ("BLENDER_WORKBENCH", "Workbench"),
472
+ ):
473
+ existing = _engine_class(identifier)
474
+ if existing is not None and issubclass(existing, VgaiRenderEngine):
475
+ made.append(identifier)
476
+ continue
477
+ if existing is not None:
478
+ try:
479
+ bpy.utils.unregister_class(existing)
480
+ except Exception: # noqa: BLE001
481
+ pass
482
+ else:
483
+ _release_from_owning_addon(existing)
484
+ engine = type(
485
+ "Vgai" + identifier.title().replace("_", ""),
486
+ (VgaiRenderEngine,),
487
+ {"bl_idname": identifier, "bl_label": label, "bl_use_preview": False},
488
+ )
489
+ try:
490
+ bpy.utils.register_class(engine)
491
+ made.append(identifier)
492
+ except Exception: # noqa: BLE001
493
+ # Upstream Blender refuses to let a Python engine take a BUILT-IN
494
+ # id ("is built-in"); the shipped bundle carries a patch that sets
495
+ # the built-in type aside instead. A bundle without that patch
496
+ # keeps `BLENDER_EEVEE` and `BLENDER_WORKBENCH`: a fact about the
497
+ # build, not a warning on every boot.
498
+ unavailable.append(identifier)
499
+ # `VGAI_THREE` is the engine under its own name, registered ONCE: this runs
500
+ # again after every file load (the ids have to be retaken), and Blender
501
+ # refuses a class it already holds -- which is not a capability anyone
502
+ # lost, so it is not a warning.
503
+ if _engine_class(VgaiRenderEngine.bl_idname) is None:
504
+ try:
505
+ bpy.utils.register_class(VgaiRenderEngine)
506
+ except Exception as error: # noqa: BLE001
507
+ warn("could not register the three.js engine: %s" % error)
508
+ if _engine_class(VgaiRenderEngine.bl_idname) is not None:
509
+ made.append(VgaiRenderEngine.bl_idname)
510
+ return made, unavailable
511
+
512
+
513
+ # ---------------------------------------------------------------- registration
514
+
515
+
516
+ def _adopt():
517
+ """Take the engine ids and point every scene at ours."""
518
+ _register_engine()
519
+ for scene in bpy.data.scenes:
520
+ try:
521
+ scene.render.engine = "VGAI_THREE"
522
+ except (TypeError, AttributeError) as error:
523
+ warn("scene %s kept its own render engine: %s" % (scene.name, error))
524
+
525
+
526
+ @bpy.app.handlers.persistent
527
+ def _load_post(_arg):
528
+ # A file load re-enables every add-on, and the Cycles add-on's engine class
529
+ # comes back under `CYCLES` while ours is gone. The ids are taken again on
530
+ # every load -- and the FIRST of these, for the startup file, is where they
531
+ # are taken at all (see this module's header).
532
+ _adopt()
533
+
534
+
535
+ def register():
536
+ _prepare_directories()
537
+ if ask({"op": "hello"}, timeout=HELLO_TIMEOUT_SECONDS) is None:
538
+ _say(
539
+ "vgai: rendering with Cycles — no presenter answered at %s within 2 s"
540
+ % ROOT
541
+ )
542
+ return
543
+ bpy.app.handlers.load_post.append(_load_post)
544
+ # BELT AND BRACES, AND NEITHER IS A GUESS. Startup modules register before
545
+ # the add-ons are enabled, so ordinarily `CYCLES` is absent here and the
546
+ # first `load_post` is what takes it; a host that imports this module after
547
+ # the add-ons are up finds it present and adopts immediately.
548
+ if _engine_class("CYCLES") is not None:
549
+ _adopt()
550
+
551
+
552
+ def unregister():
553
+ if _load_post in bpy.app.handlers.load_post:
554
+ bpy.app.handlers.load_post.remove(_load_post)