@volter/blender-engine 0.1.131 → 0.1.133

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.
@@ -129,6 +129,7 @@ export interface BlenderEngineOptions {
129
129
  * answered as a whole frame is.
130
130
  */
131
131
  export interface SessionAsk {
132
+ evaluation?: 'render';
132
133
  checkpoint?: string;
133
134
  frame?: unknown;
134
135
  hold?: unknown;
@@ -78,7 +78,10 @@ export type WorkerRequest =
78
78
  * bone, the LOCAL transform at every integer frame of the action's range,
79
79
  * plus the scene's frame range/fps and the summary row's key columns.
80
80
  * READ ONLY: keying, moving a key and setting a range are edits. */
81
- | { id: number; op: 'action-clip'; object?: string; bake?: boolean }
81
+ | { id: number; op: 'action-clip'; object?: string; bake?: boolean; action?: string }
82
+ /** EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT (`session.py`'s `rna_armature_actions`):
83
+ * each action that animates one of its pose bones, assigned or not. READ ONLY. */
84
+ | { id: number; op: 'armature-actions' }
82
85
  /** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
83
86
  * Blender draws on no row of that type is refused by name. */
84
87
  | { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
@@ -179,6 +182,9 @@ export interface FileListSnapshot {
179
182
  }
180
183
 
181
184
  export interface CaptureRequest {
185
+ /** Geometry was exported from the render engine's RENDER depsgraph. It belongs
186
+ * to this capture alone and must never replace the live viewport frame. */
187
+ evaluation?: 'render';
182
188
  /** A viewport screenshot's bound, the longer side of a square frame.
183
189
  * Absent on a render, which states its exact pixel dimensions instead. */
184
190
  size?: number;
@@ -33,7 +33,7 @@ type Request = DistributiveOmit<WorkerRequest, 'id'>;
33
33
 
34
34
  export interface BlenderRuntimeOptions {
35
35
  /** Stage one verified mesh/image without replacing the displayed frame. */
36
- stage?(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
36
+ stage?(part: { session: string; revision: number; evaluation?: 'render'; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
37
37
  /** Operation-only diagnostics, never request payloads. The optional observer
38
38
  * must not affect work, even if it throws. End calls balance overlapping work. */
39
39
  work?(label: string): () => void;
@@ -423,15 +423,22 @@ export class BlenderRuntime {
423
423
  * false` answers the header and the summary row's key columns without the
424
424
  * sampled tracks, which is what a Timeline that only needs to DRAW asks
425
425
  * for. */
426
- async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
426
+ async actionClip(options?: { object?: string; bake?: boolean; action?: string }): Promise<BlenderActionClip> {
427
427
  await this.#ready();
428
428
  return (await this.#request({
429
429
  op: 'action-clip',
430
430
  ...(options?.object === undefined ? {} : { object: options.object }),
431
431
  ...(options?.bake === undefined ? {} : { bake: options.bake }),
432
+ ...(options?.action === undefined ? {} : { action: options.action }),
432
433
  })) as BlenderActionClip;
433
434
  }
434
435
 
436
+ /** Every armature and the actions that can play on it, assigned or not (a game's clip library). */
437
+ async armatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } }> {
438
+ await this.#ready();
439
+ return (await this.#request({ op: 'armature-actions' })) as { armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } };
440
+ }
441
+
435
442
  /** Write ONE restriction column (the eye, the render camera, a collection's
436
443
  * Exclude). A column Blender draws on no row of that type is refused by
437
444
  * name rather than written somewhere near it. */
@@ -645,7 +652,7 @@ export class BlenderRuntime {
645
652
  }
646
653
 
647
654
  readonly #frameStream = new FrameStreamReader();
648
- #stagedIdentity: { session: string; revision: number } | null = null;
655
+ #stagedIdentity: { session: string; revision: number; evaluation?: 'render' } | null = null;
649
656
  #discardStagedFrame(): void {
650
657
  this.#frameStream.reset();
651
658
  const part = this.#stagedIdentity; this.#stagedIdentity = null;
@@ -661,13 +668,13 @@ export class BlenderRuntime {
661
668
  try {
662
669
  const decoded = await this.#frameStream.accept(reply.chunk);
663
670
  if (decoded) {
664
- const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
671
+ const value = decoded.value as ({ op: 'stage'; session: string; revision: number; evaluation?: 'render' } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
665
672
  if (value.op === 'present') {
666
673
  await this.#receive({ ...value, id: reply.id } as WorkerReply);
667
674
  return;
668
675
  }
669
676
  if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
670
- this.#stagedIdentity = { session: value.session, revision: value.revision };
677
+ this.#stagedIdentity = { session: value.session, revision: value.revision, ...(value.evaluation ? { evaluation: value.evaluation } : {}) };
671
678
  await this.#options.stage(value);
672
679
  }
673
680
  this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
@@ -708,7 +715,7 @@ export class BlenderRuntime {
708
715
  // true the moment the frame arrives. A presenter that refuses the frame
709
716
  // has not un-modelled it.
710
717
  const frame = reply.frame as { session?: unknown; revision?: unknown } | null;
711
- if (typeof frame?.session === 'string' && typeof frame.revision === 'number')
718
+ if (reply.capture?.evaluation !== 'render' && typeof frame?.session === 'string' && typeof frame.revision === 'number')
712
719
  this.#presented = { session: frame.session, revision: frame.revision };
713
720
  const end = this.#work('presenting worker frame');
714
721
  try {
@@ -1085,7 +1085,10 @@ def material_graph(material):
1085
1085
  if surface is None or surface.bl_idname not in _GRAPH_SURFACES:
1086
1086
  return None
1087
1087
  sockets = [surface.inputs[name] for name in _GRAPH_SURFACES[surface.bl_idname]]
1088
- if not any(_links_into(s) for s in sockets):
1088
+ # Emission reflects no light even when its inputs are constants. The
1089
+ # reduced MeshPhysicalMaterial otherwise adds diffuse/specular response
1090
+ # to it (including a black Emission used as an unlit control).
1091
+ if surface.bl_idname != "ShaderNodeEmission" and not any(_links_into(s) for s in sockets):
1089
1092
  return None
1090
1093
  # The door is quiet about a material whose graph ships, so a linked input
1091
1094
  # the graph does not carry is named here.
@@ -1480,7 +1483,7 @@ class Session:
1480
1483
 
1481
1484
  # -- the export
1482
1485
 
1483
- def _export(self):
1486
+ def _export(self, depsgraph=None):
1484
1487
  """THE EXPORT IS THE C++ DOOR (`bpy_web_export.cc`).
1485
1488
 
1486
1489
  One call evaluates the scene, walks the depsgraph's own update record
@@ -1497,14 +1500,14 @@ class Session:
1497
1500
  bones and the active vertex group's weights (`_armatures`,
1498
1501
  `_weights`). All per-scene, none of them geometry.
1499
1502
  """
1500
- scene = bpy.context.scene
1503
+ scene = depsgraph.scene if depsgraph is not None else bpy.context.scene
1501
1504
  graphs = material_graphs(scene)
1502
1505
  _mark("export:graphs")
1503
1506
  # An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
1504
1507
  # (`overlay_empty.hh` `image_sync`).
1505
1508
  empty_images = {obj.data.name for obj in scene.objects
1506
1509
  if obj.type == "EMPTY" and obj.empty_display_type == "IMAGE" and obj.data is not None}
1507
- options = {"session": self.session, "evaluate": True, "known": self._known, "instance_sources": True,
1510
+ options = {"session": self.session, "evaluate": depsgraph is None, "known": self._known, "instance_sources": True,
1508
1511
  "graph_materials": sorted(graphs),
1509
1512
  "graph_images": sorted({i for g in graphs.values() for i in g["images"]} | empty_images),
1510
1513
  "graph_generated": sorted(n for n, g in graphs.items() if g["generated"])}
@@ -1563,7 +1566,7 @@ class Session:
1563
1566
  # A MANUAL TEXTURE SPACE, which Generated coordinates map through; the
1564
1567
  # automatic one is the evaluated bounds the presenter already holds.
1565
1568
  if graphs:
1566
- depsgraph = bpy.context.evaluated_depsgraph_get()
1569
+ depsgraph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
1567
1570
  # BY NAME THROUGH ONE MAP: `scene.objects.get` searches the collection linearly, so a
1568
1571
  # lookup per row was quadratic -- measured on the Stoneguard file (11,702 rows): 11,343
1569
1572
  # calls, 14.4 s of every present.
@@ -1598,7 +1601,8 @@ class Session:
1598
1601
  "roughness": float(material.roughness),
1599
1602
  "metallic": float(material.metallic),
1600
1603
  }
1601
- frame["instances"] = _depsgraph_placements(frame, bpy.context.evaluated_depsgraph_get())
1604
+ graph = depsgraph if depsgraph is not None else bpy.context.evaluated_depsgraph_get()
1605
+ frame["instances"] = _depsgraph_placements(frame, graph)
1602
1606
  frame["world"] = draw_world(scene)
1603
1607
  frame["cameras"] = {
1604
1608
  obj.name: draw_camera(obj) for obj in scene.objects if obj.type == "CAMERA"
@@ -1610,7 +1614,7 @@ class Session:
1610
1614
  render = scene.render
1611
1615
  frame["camera_view"] = {
1612
1616
  "scene_camera": scene.camera.name if scene.camera is not None else None,
1613
- "view_layer_cameras": [o.name for o in bpy.context.view_layer.objects if o.type == "CAMERA"],
1617
+ "view_layer_cameras": [o.name for o in graph.view_layer.objects if o.type == "CAMERA"],
1614
1618
  "aspect": (render.resolution_x * render.pixel_aspect_x) / max(render.resolution_y * render.pixel_aspect_y, 1e-6),
1615
1619
  }
1616
1620
  # THE 3D CURSOR, `Scene.cursor`: where Blender's own "to 3D Cursor"
@@ -1620,7 +1624,7 @@ class Session:
1620
1624
  frame["cursor"] = [[float(v) for v in row] for row in scene.cursor.matrix]
1621
1625
  # THE OVERLAYS, after the door's frame stands: `_weights` reads the
1622
1626
  # mesh's own revision out of it (see there), so it cannot run before.
1623
- view_layer = bpy.context.view_layer
1627
+ view_layer = graph.view_layer
1624
1628
  # THE VIEWPORT'S SUBJECT LINE and the units its grid step is named in: Blender composes
1625
1629
  # both from the scene, so they come from here (`_subject_line`).
1626
1630
  frame["subject"] = _subject_line(scene, view_layer)
@@ -1665,12 +1669,12 @@ class Session:
1665
1669
  self._known.clear()
1666
1670
  return self._present(capture)
1667
1671
 
1668
- def _present(self, capture=None):
1672
+ def _present(self, capture=None, depsgraph=None):
1669
1673
  known = dict(self._known)
1670
1674
  _mark("present:before")
1671
- frame = self._export()
1675
+ frame = self._export(depsgraph)
1672
1676
  _mark("present:exported")
1673
- pulled = self._pull(frame)
1677
+ pulled = self._pull(frame, render=depsgraph is not None)
1674
1678
  _mark("present:pulled")
1675
1679
  self._drop_unreachable_textures(frame)
1676
1680
  # WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
@@ -1729,7 +1733,7 @@ class Session:
1729
1733
  self._reconcile_with_presenter(answer, frame)
1730
1734
  return answer
1731
1735
 
1732
- def _pull(self, frame):
1736
+ def _pull(self, frame, render=False):
1733
1737
  """THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
1734
1738
  during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
1735
1739
  the changed meshes and pictures as deferred; the worker first copies the frame's own
@@ -1743,9 +1747,9 @@ class Session:
1743
1747
  anything was pulled; the worker then presents the frame with its pieces."""
1744
1748
  meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
1745
1749
  images = [name for name, image in frame["images"].items() if image.get("deferred")]
1746
- if not meshes and not images:
1750
+ if not meshes and not images and not render:
1747
1751
  return False
1748
- _asked({"hold": frame})
1752
+ _asked({"hold": frame, **({"evaluation": "render"} if render else {})})
1749
1753
  options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
1750
1754
  for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
1751
1755
  ("image", images, _blender_web.export_image)):
@@ -1765,6 +1769,20 @@ class Session:
1765
1769
  _asked({kind: key, "piece": piece[kind]})
1766
1770
  return True
1767
1771
 
1772
+ def photograph(self, depsgraph, capture):
1773
+ """Render evaluation owns different geometry and visibility than a viewport.
1774
+ Keep its native revisions, deferred pulls and presenter separate for the
1775
+ entire blocking capture, and restore the authoring export in finally."""
1776
+ if not hasattr(_blender_web, "begin_render_export"):
1777
+ raise RuntimeError("This Blender artifact has no render-evaluated export; update the artifact and its corresponding source")
1778
+ _blender_web.begin_render_export(depsgraph)
1779
+ try:
1780
+ rendering = Session()
1781
+ rendering.session = "%s:render:%d" % (self.session, time.time_ns())
1782
+ return rendering._present({**capture, "evaluation": "render"}, depsgraph)
1783
+ finally:
1784
+ _blender_web.end_render_export()
1785
+
1768
1786
  def _send_encoded_image(self, key, deferred):
1769
1787
  """A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
1770
1788
 
@@ -2336,8 +2354,8 @@ def _photograph(depsgraph, width, height, linear=False):
2336
2354
  # result is what a render leaves, and it holds scene-linear pixels.
2337
2355
  if linear:
2338
2356
  render["linear"] = True
2339
- answer = SESSION.present(
2340
- {"position": position, "target": target, "up": up, "render": render}
2357
+ answer = SESSION.photograph(
2358
+ depsgraph, {"position": position, "target": target, "up": up, "render": render}
2341
2359
  )
2342
2360
  if not isinstance(answer, dict) or "base64" not in answer:
2343
2361
  raise RuntimeError("The renderer did not answer with a photograph")
@@ -4823,7 +4841,51 @@ def _summary_objects():
4823
4841
  return out
4824
4842
 
4825
4843
 
4826
- def rna_action_clip(object_name=None, bake=True):
4844
+ def rna_armature_actions():
4845
+ """EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT -- what a game picks its clips from.
4846
+
4847
+ An action can play on an armature when it animates at least one of that armature's pose bones
4848
+ (`pose.bones["<name>"]...` on a bone the armature has). Read off each action's F-Curves through
4849
+ `_action_channelbag`, every channelbag, so an action assigned to nothing (a clip library: Idle,
4850
+ Run, Attack beside each other in the file) is offered as well as the one each armature plays now.
4851
+ READ ONLY."""
4852
+ out = []
4853
+ for arm in bpy.data.objects:
4854
+ if arm.type != "ARMATURE" or arm.pose is None:
4855
+ continue
4856
+ bones = {pchan.name for pchan in arm.pose.bones}
4857
+ current = arm.animation_data.action.name if (arm.animation_data and arm.animation_data.action) else None
4858
+ names = []
4859
+ keys, total = 0, 0.0
4860
+ for action in bpy.data.actions:
4861
+ curves = []
4862
+ legacy = getattr(action, "fcurves", None)
4863
+ if legacy is not None:
4864
+ curves = list(legacy)
4865
+ else:
4866
+ for layer in getattr(action, "layers", ()):
4867
+ for strip in getattr(layer, "strips", ()):
4868
+ for bag in (getattr(strip, "channelbags", None) or ()):
4869
+ curves.extend(bag.fcurves)
4870
+ if any((m := _BONE_PATH.match(fc.data_path)) is not None and m.group(1) in bones for fc in curves):
4871
+ names.append(action.name)
4872
+ # A CHEAP SIGNATURE of the keys, so a reader can tell a key edit from no change
4873
+ # without baking: the count and the sum of every key's frame and value.
4874
+ for fc in curves:
4875
+ for key in fc.keyframe_points:
4876
+ keys += 1
4877
+ total += float(key.co[0]) * 7.0 + float(key.co[1])
4878
+ out.append({"armature": arm.name, "current": current, "actions": sorted(names),
4879
+ "keys": "%d:%.6f" % (keys, total)})
4880
+ scene = bpy.context.scene
4881
+ # THE SCENE'S CLOCK TOO: a reader that skips a re-bind on an unchanged listing must still see
4882
+ # the playhead and range move when an agent sets them.
4883
+ return {"armatures": out, "scene": {"frameCurrent": int(scene.frame_current),
4884
+ "frameStart": int(scene.frame_start), "frameEnd": int(scene.frame_end),
4885
+ "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}}
4886
+
4887
+
4888
+ def rna_action_clip(object_name=None, bake=True, action_name=None):
4827
4889
  """ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
4828
4890
  every integer frame of the action's own range.
4829
4891
 
@@ -4892,13 +4954,32 @@ def rna_action_clip(object_name=None, bake=True):
4892
4954
  # when the subject below turns out to be nothing at all.
4893
4955
  "summary": _summary_objects(),
4894
4956
  }
4895
- if arm_obj is None or arm_obj.animation_data is None or arm_obj.animation_data.action is None:
4957
+ adt = arm_obj.animation_data if arm_obj is not None else None
4958
+ assigned = adt.action if adt is not None else None
4959
+ if arm_obj is None or (assigned is None and not action_name):
4896
4960
  header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
4897
4961
  "and an empty summary row, which is Blender's own empty state.")
4898
4962
  return header
4899
- adt = arm_obj.animation_data
4900
- action = adt.action
4901
- slot = getattr(adt, "action_slot", None)
4963
+ action = assigned
4964
+ slot = getattr(adt, "action_slot", None) if adt is not None else None
4965
+ if action_name and (assigned is None or action_name != assigned.name):
4966
+ # A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
4967
+ # baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
4968
+ # are its first channelbag's (no slot of this object chooses among them).
4969
+ action = bpy.data.actions.get(action_name)
4970
+ if action is None:
4971
+ raise ValueError("The engine holds no action named %r" % (action_name,))
4972
+ # THE SLOT FOR THIS ARMATURE, as Blender would pick it on assignment: the one whose
4973
+ # identifier names this object (`OB<name>`), else the only object slot. A layered action
4974
+ # with several object slots and none for this armature has no right answer; its first
4975
+ # object slot is used and said in the reason.
4976
+ slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
4977
+ slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
4978
+ if slot is None and slots:
4979
+ slot = slots[0]
4980
+ if len(slots) > 1:
4981
+ header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
4982
+ % (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
4902
4983
  curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
4903
4984
  header.update({
4904
4985
  "action": action.name,
@@ -5433,7 +5514,9 @@ def _dispatch(request):
5433
5514
  if op == "rig":
5434
5515
  return rna_rig(request.get("object"))
5435
5516
  if op == "action-clip":
5436
- return rna_action_clip(request.get("object"), request.get("bake", True))
5517
+ return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
5518
+ if op == "armature-actions":
5519
+ return rna_armature_actions()
5437
5520
  if op == "outliner":
5438
5521
  return rna_outliner(request.get("selected"))
5439
5522
  if op == "outliner-set":
@@ -14,6 +14,7 @@
14
14
  * built from the mesh's texture space (`orcoAttribute`).
15
15
  */
16
16
  import * as THREE from 'three';
17
+ import {ISLAND_RANDOM_ATTRIBUTE} from './blender-island-random';
17
18
  import {presenterChanged} from './blender-presenter-change';
18
19
  import {attributeVarying, type CompiledGraph, rampTexture, uvVarying} from './blender-node-graph';
19
20
  import {graphAttributeName} from './blender-runtime-geometry';
@@ -458,7 +459,7 @@ varying vec3 vBlenderObjectNormal;
458
459
  varying vec3 vBlenderWorldPosition;
459
460
  varying vec3 vBlenderWorldNormal;
460
461
  varying vec3 vBlenderOrco;
461
- ${compiled.attributes.map(n => `varying vec4 ${attributeVarying(n)};`).join('\n')}
462
+ ${compiled.attributes.map(n => `${n===ISLAND_RANDOM_ATTRIBUTE?'flat ':''}varying vec4 ${attributeVarying(n)};`).join('\n')}
462
463
  ${compiled.uvs.map(n => `varying vec2 ${uvVarying(n)};`).join('\n')}`;
463
464
  const attributes = compiled.attributes.map(n => `attribute vec4 ${graphAttributeName(n)};`).join('\n');
464
465
  shader.vertexShader = `attribute vec3 blenderOrco;\n${attributes}\n${varyings}\n${shader.vertexShader}`.replace(
@@ -0,0 +1,41 @@
1
+ /* SPDX-FileCopyrightText: 2011-2022 Blender Foundation
2
+ * SPDX-License-Identifier: Apache-2.0 */
3
+ /** Cycles' attr_create_random_per_island, util/disjoint_set.h and
4
+ * util/hash.h (Blender Foundation, Apache-2.0). Connectivity uses the
5
+ * evaluated mesh's STORED edge order and union-by-rank representative,
6
+ * including loose edges. It is independent of draw/UV/normal splitting. */
7
+ export const ISLAND_RANDOM_ATTRIBUTE='.cycles.random_per_island';
8
+
9
+ function hashUint(value:number):number {
10
+ let a=(0xdeadbeef+17+value)>>>0,b=(0xdeadbeef+17)>>>0,c=b;
11
+ const rotate=(x:number,n:number)=>(x<<n)|(x>>>(32-n));
12
+ c=((c^b)-rotate(b,14))>>>0;a=((a^c)-rotate(c,11))>>>0;
13
+ b=((b^a)-rotate(a,25))>>>0;c=((c^b)-rotate(b,16))>>>0;
14
+ a=((a^c)-rotate(c,4))>>>0;b=((b^a)-rotate(a,14))>>>0;
15
+ c=((c^b)-rotate(b,24))>>>0;
16
+ // Native converts uint to float BEFORE the float reciprocal/multiply.
17
+ return Math.fround(Math.fround(c)*Math.fround(1/Math.fround(0xffffffff)));
18
+ }
19
+
20
+ export function cyclesIslandRandom(vertexCount:number,edges:Iterable<readonly [number,number]>):Float32Array {
21
+ const parents=Uint32Array.from({length:vertexCount},(_,i)=>i),ranks=new Uint8Array(vertexCount);
22
+ const find=(x:number):number=>{
23
+ let root=x;while(parents[root]!==root)root=parents[root]!;
24
+ while(parents[x]!==root){const parent=parents[x]!;parents[x]=root;x=parent;}
25
+ return root;
26
+ };
27
+ for(const [x,y] of edges) {
28
+ if(x<0 || y<0 || x>=vertexCount || y>=vertexCount || !Number.isInteger(x) || !Number.isInteger(y))
29
+ throw new Error('Island connectivity references a missing Blender vertex');
30
+ let a=find(x),b=find(y);if(a===b)continue;
31
+ if(ranks[a]!<ranks[b]!)[a,b]=[b,a];
32
+ parents[b]=a;if(ranks[a]===ranks[b])ranks[a]=ranks[a]!+1;
33
+ }
34
+ const result=new Float32Array(vertexCount);
35
+ for(let i=0;i<vertexCount;i++)result[i]=hashUint(find(i));
36
+ return result;
37
+ }
38
+
39
+ export function* storedMeshEdges(edges:Uint32Array):Iterable<readonly [number,number]> {
40
+ for(let i=0;i<edges.length;i+=2)yield [edges[i]!,edges[i+1]!];
41
+ }
@@ -25,6 +25,7 @@ import * as THREE from 'three';
25
25
  import {z} from 'zod';
26
26
  import {BLENDER_NODE_GLSL, BLENDER_NODE_GLSL_PRELUDE} from './blender-node-glsl.generated';
27
27
  import {graphAttributeName} from './blender-runtime-geometry';
28
+ import {ISLAND_RANDOM_ATTRIBUTE} from './blender-island-random';
28
29
 
29
30
  const scalar = z.number().finite();
30
31
  const gpuType = z.enum(['float', 'vec2', 'vec3', 'vec4']);
@@ -436,6 +437,13 @@ class Compiler {
436
437
  if (!node) throw new Error(`The graph links to ${key}, which it does not carry`);
437
438
  if (node.type === 'ShaderNodeParticleInfo' && node.outputs[index]?.id !== 'Random')
438
439
  throw new Error(`Particle Info output ${node.outputs[index]?.id ?? index} is not compiled by the presenter`);
440
+ // EEVEE's node_geometry returns zero here; reference rendering uses
441
+ // Cycles' evaluated per-island attribute instead. Keep it face constant.
442
+ if(node.type==='ShaderNodeNewGeometry' && node.outputs[index]?.id==='Random Per Island') {
443
+ this.s.attributes.add(ISLAND_RANDOM_ATTRIBUTE);
444
+ this.s.structure.push(['cycles-island',key,index]);
445
+ return convert(`${attributeVarying(ISLAND_RANDOM_ATTRIBUTE)}.x`,'float',to);
446
+ }
439
447
  const outputs = this.node(key, node);
440
448
  const output = outputs[index];
441
449
  if (!output) throw new Error(`${key} has no output ${index}`);
@@ -19,6 +19,7 @@ import * as THREE from 'three';
19
19
  import { customCornerNormals, type Normal } from './blender-corner-normals';
20
20
  import { ATTRIBUTE_LAYOUT, type MeshColumns } from './blender-frame-columns';
21
21
  import { triangulatePolygon } from './blender-triangulate';
22
+ import {cyclesIslandRandom,ISLAND_RANDOM_ATTRIBUTE,storedMeshEdges} from './blender-island-random';
22
23
 
23
24
  type MeshData = {
24
25
  v: [number, number, number][];
@@ -43,7 +44,8 @@ export interface DrawArrays {
43
44
  uvLayers?: {name: string; data: Float32Array}[] | undefined;
44
45
  /** The mesh's generic attribute layers a material's Attribute or Color
45
46
  * Attribute node can read, four floats per drawn vertex as EEVEE loads them
46
- * (`graphAttributeLayers`). */
47
+ * (`graphAttributeLayers`). The derived Cycles island layer stores one
48
+ * scalar per drawn vertex rather than an authored float4. */
47
49
  attributeLayers?: {name: string; data: Float32Array}[] | undefined;
48
50
  /** Per drawn vertex, a deformed mesh's Generated coordinates (0..1), or
49
51
  * null when the positions are undeformed and orco derives from them. */
@@ -275,7 +277,7 @@ export function graphAttributeName(name: string): string {
275
277
  }
276
278
 
277
279
  export function geometryFromDrawArrays(
278
- arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
280
+ arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array | undefined },
279
281
  ): THREE.BufferGeometry {
280
282
  const geometry = new THREE.BufferGeometry();
281
283
  geometry.setAttribute('position', new THREE.BufferAttribute(arrays.positions, 3));
@@ -294,7 +296,11 @@ export function geometryFromDrawArrays(
294
296
  }
295
297
  geometry.userData['blenderUvChannels'] = channels;
296
298
  for (const layer of arrays.attributeLayers ?? [])
297
- geometry.setAttribute(graphAttributeName(layer.name), new THREE.BufferAttribute(layer.data, 4));
299
+ // Engine-derived island values use one float, while ordinary Blender
300
+ // material attributes retain EEVEE's float4 storage. Snapshots preserve
301
+ // the same name/stride, without inventing an authored mesh attribute.
302
+ geometry.setAttribute(graphAttributeName(layer.name), new THREE.BufferAttribute(layer.data,
303
+ layer.name===ISLAND_RANDOM_ATTRIBUTE?1:4));
298
304
  geometry.userData['blenderAttributes'] = (arrays.attributeLayers ?? []).map(layer => layer.name);
299
305
  if (arrays.orco) {
300
306
  geometry.setAttribute('blenderOrco', new THREE.BufferAttribute(arrays.orco, 3));
@@ -419,7 +425,7 @@ function drawNativeColumns(c: MeshColumns, hash: string): DrawArrays {
419
425
 
420
426
  /** The worker's door: the mesh store's columns, drawn into buffers. */
421
427
  export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays {
422
- if (c.cornerTri && c.cornerNormal) return drawNativeColumns(c, hash);
428
+ if (c.cornerTri && c.cornerNormal) return withIslandRandom(drawNativeColumns(c, hash),c.co.length/3,storedMeshEdges(c.edge));
423
429
  const nv = c.co.length / 3;
424
430
  const maps = c.attributes.filter((a) => a.type === 'FLOAT2' && a.domain === 'CORNER');
425
431
  const mapName = c.renderUv ?? c.activeUv ?? maps[0]?.name;
@@ -482,7 +488,15 @@ export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays
482
488
  attributeLayers: graphAttributeLayers(c.attributes, data => data as ArrayLike<number>),
483
489
  };
484
490
  if (map && ATTRIBUTE_LAYOUT.FLOAT2.size !== 2) throw new Error('FLOAT2 layout');
485
- return { ...drawCore(view), hash };
491
+ return {...withIslandRandom(drawCore(view),nv,edges),hash};
492
+ }
493
+
494
+ function withIslandRandom<T extends Omit<DrawArrays,'hash'>>(arrays:T,vertexCount:number,
495
+ edges:Iterable<readonly [number,number]>):T {
496
+ const native=cyclesIslandRandom(vertexCount,edges);
497
+ const data=Float32Array.from(arrays.sourceVertex,vertex=>native[vertex]!);
498
+ arrays.attributeLayers=[...(arrays.attributeLayers??[]),{name:ISLAND_RANDOM_ATTRIBUTE,data}];
499
+ return arrays;
486
500
  }
487
501
 
488
502
  /** The boundary JSON door. */
@@ -546,5 +560,8 @@ export function drawRuntimeGeometry(data: MeshData): THREE.BufferGeometry {
546
560
  attributeLayers: graphAttributeLayers(data.attributes ?? [],
547
561
  values => (Array.from(values) as (number | number[])[]).flat()),
548
562
  };
549
- return geometryFromDrawArrays(drawCore(view));
563
+ // Native frames always supply stored edges. Legacy JSON without that
564
+ // order retains its old unsupported zero input rather than guessing IDs.
565
+ const arrays=drawCore(view);
566
+ return geometryFromDrawArrays(view.edges?withIslandRandom(arrays,vertices.length,view.edges):arrays);
550
567
  }
@@ -147,6 +147,8 @@ const drawArraysSchema = z
147
147
  })
148
148
  .strict(),
149
149
  ),
150
+ /** The draw's vertex map (`DrawArrays.sourceVertex`), kept by a copy that plays (skins bind over it). */
151
+ sourceVertex: z.instanceof(Uint32Array).optional(),
150
152
  hash: z.string(),
151
153
  })
152
154
  .strict();
@@ -2248,7 +2250,7 @@ export class BlenderRuntimeView {
2248
2250
  /** Everything this view holds, as ONE self-contained frame another view can be built from:
2249
2251
  * every mesh's resident geometry and every resident image, with no reference to what a
2250
2252
  * receiver already holds. */
2251
- private fullFrame(): Frame {
2253
+ private fullFrame(options: { sourceVertices?: boolean } = {}): Frame {
2252
2254
  const source = this.frame;
2253
2255
  if (!source) throw new Error('Blender capture requires a presented frame');
2254
2256
  // Inspection overlays are not render content. In particular weight-paint
@@ -2290,6 +2292,12 @@ export class BlenderRuntimeView {
2290
2292
  count: group.count,
2291
2293
  materialIndex: group.materialIndex ?? 0,
2292
2294
  })),
2295
+ // A COPY THAT PLAYS keeps the draw's vertex map: Play binds skins over it (a Blender
2296
+ // vertex's weights expand onto its drawn vertices through it), where a render snapshot
2297
+ // needs none.
2298
+ ...(options.sourceVertices && geometry.getAttribute('blenderVertex')
2299
+ ? { sourceVertex: new Uint32Array((geometry.getAttribute('blenderVertex') as THREE.BufferAttribute).array) }
2300
+ : {}),
2293
2301
  };
2294
2302
  }
2295
2303
  for (const [id, { signature }] of this.volumes)
@@ -2368,7 +2376,7 @@ export class BlenderRuntimeView {
2368
2376
  */
2369
2377
  detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2370
2378
  const detached = new BlenderRuntimeView();
2371
- if (this.frame) detached.applyFrame(this.fullFrame());
2379
+ if (this.frame) detached.applyFrame(this.fullFrame({ sourceVertices: true }));
2372
2380
  let disposed = false;
2373
2381
  return {
2374
2382
  view: detached,
@@ -2421,6 +2429,40 @@ export class BlenderRuntimeView {
2421
2429
  };
2422
2430
  }
2423
2431
 
2432
+ /** Build a render-evaluated frame without ever publishing it to a viewport. */
2433
+ static forPhotograph(): BlenderRuntimeView {
2434
+ const view = new BlenderRuntimeView();
2435
+ view.photograph = true;
2436
+ return view;
2437
+ }
2438
+
2439
+ /** Transfer this render-only view to its capture owner. Its resources are
2440
+ * already independent, so a second geometry/texture copy is unnecessary. */
2441
+ takeCaptureSnapshot(): ReturnType<BlenderRuntimeView['captureSnapshot']> {
2442
+ if (!this.photograph || !this.frame) throw new Error('A render-evaluated frame is required');
2443
+ const source = this.frame;
2444
+ const effect = this.createWorldVolumePass();
2445
+ let disposed = false;
2446
+ return {
2447
+ effect, root: this.root, session: source.session, revision: source.revision,
2448
+ prepare: camera => {
2449
+ if (disposed) throw new Error('Blender capture snapshot is disposed');
2450
+ return this.setRendered(true, camera);
2451
+ },
2452
+ prepareDraw: (renderer, camera) => {
2453
+ if (disposed) throw new Error('Blender capture snapshot is disposed');
2454
+ return this.prepareDraw(camera, { interactive: false, height: renderer.domElement.height, renderer });
2455
+ },
2456
+ dispose: () => {
2457
+ if (disposed) return;
2458
+ disposed = true;
2459
+ effect?.dispose();
2460
+ this.dispose();
2461
+ this.root.removeFromParent();
2462
+ },
2463
+ };
2464
+ }
2465
+
2424
2466
  createWorldVolumePass(): WorldVolumePass | undefined {
2425
2467
  const medium = worldMedium(this.frame?.world);
2426
2468
  return medium ? new WorldVolumePass(medium) : undefined;
@@ -90,6 +90,22 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
90
90
  frame: unknown,
91
91
  description: unknown,
92
92
  capture?: CaptureRequest,
93
+ ): Promise<PresentAnswer> {
94
+ if (capture?.evaluation === 'render') {
95
+ const detached = BlenderRuntimeView.forPhotograph();
96
+ const captureScene = new THREE.Scene();
97
+ view.root.updateWorldMatrix(true, false);
98
+ detached.root.matrix.copy(view.root.matrixWorld);
99
+ captureScene.add(detached.root);
100
+ try { return await presentFrame(detached, captureScene, frame, description, capture); }
101
+ finally { detached.dispose(); detached.root.removeFromParent(); }
102
+ }
103
+ return presentFrame(view, scene, frame, description, capture);
104
+ }
105
+
106
+ async function presentFrame(
107
+ view: BlenderRuntimeView, scene: THREE.Scene, frame: unknown, description: unknown,
108
+ capture?: CaptureRequest,
93
109
  ): Promise<PresentAnswer> {
94
110
  const applied = view.applyFrame(frame) as { held?: unknown } | null | undefined;
95
111
  const held =
@@ -169,6 +185,7 @@ export function createPresenter(options: PresenterOptions = {}): Presenter {
169
185
  renderer.setClearColor(0, 0);
170
186
  }
171
187
  try {
188
+ view.prepareDraw(camera, { interactive: false, height, renderer });
172
189
  renderer.render(scene, camera);
173
190
  } finally {
174
191
  scene.background = background;
@@ -212,7 +212,7 @@ def _world(scene):
212
212
  }
213
213
 
214
214
 
215
- def _export():
215
+ def _export(depsgraph):
216
216
  """The C++ door's frame, with what the door does not describe merged in.
217
217
 
218
218
  `known` is ALWAYS EMPTY: this process keeps no record of what a presenter
@@ -221,7 +221,7 @@ def _export():
221
221
  """
222
222
  options = {
223
223
  "session": SESSION,
224
- "evaluate": True,
224
+ "evaluate": False,
225
225
  "known": {},
226
226
  "buffer_path": EXPORT_BUFFER_PATH,
227
227
  }
@@ -236,7 +236,7 @@ def _export():
236
236
  _REVISION[0] += 1
237
237
  frame["session"] = SESSION
238
238
  frame["revision"] = _REVISION[0]
239
- scene = bpy.context.scene
239
+ scene = depsgraph.scene
240
240
  frame["world"] = _world(scene)
241
241
  frame["volumes"] = {}
242
242
  # THE INSPECTION OVERLAYS ARE THE EDITOR'S, and a render hides them anyway.
@@ -249,8 +249,14 @@ def _export():
249
249
  return frame
250
250
 
251
251
 
252
- def _present(capture):
253
- answer = ask({"frame": _export(), "capture": capture})
252
+ def _present(depsgraph, capture):
253
+ if not hasattr(_blender_web, "begin_render_export"):
254
+ raise RuntimeError("This Blender artifact has no render-evaluated export")
255
+ _blender_web.begin_render_export(depsgraph)
256
+ try:
257
+ answer = ask({"frame": _export(depsgraph), "capture": {**capture, "evaluation": "render"}})
258
+ finally:
259
+ _blender_web.end_render_export()
254
260
  if isinstance(answer, dict) and answer.get("error"):
255
261
  raise RuntimeError(answer["error"])
256
262
  return answer
@@ -334,7 +340,7 @@ def _photograph(depsgraph, width, height, linear=False):
334
340
  if linear:
335
341
  render["linear"] = True
336
342
  answer = _present(
337
- {"position": position, "target": target, "up": up, "render": render}
343
+ depsgraph, {"position": position, "target": target, "up": up, "render": render}
338
344
  )
339
345
  if not isinstance(answer, dict) or "base64" not in answer:
340
346
  raise RuntimeError("The renderer did not answer with a photograph")
package/browser/worker.ts CHANGED
@@ -116,6 +116,7 @@ interface Copied {
116
116
  description: unknown;
117
117
  }
118
118
  interface PendingFrame {
119
+ evaluation?: 'render';
119
120
  session: string;
120
121
  revision: number;
121
122
  columns: Map<number, Copied>;
@@ -270,7 +271,7 @@ async function startBlender(project: string, document?: string): Promise<unknown
270
271
  const started = await startBlenderEngine({
271
272
  project,
272
273
  log,
273
- ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint }) => {
274
+ ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint, evaluation }) => {
274
275
  if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
275
276
  if (!holder.engine) throw new Error('The Blender session presented before it started');
276
277
  // Save once after the whole command, never during a partial frame.
@@ -286,8 +287,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
286
287
  // gets the whole frame at `present`.
287
288
  if (hold !== undefined) {
288
289
  const identity = hold as { session: string; revision: number };
289
- pending = { session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
290
- await streamToTab({ op: 'stage', session: identity.session, revision: identity.revision });
290
+ pending = { ...(evaluation ? { evaluation } : {}), session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
291
+ await streamToTab({ op: 'stage', evaluation, session: identity.session, revision: identity.revision });
291
292
  return {};
292
293
  }
293
294
  if (mesh !== undefined || image !== undefined) {
@@ -312,7 +313,7 @@ async function startBlender(project: string, document?: string): Promise<unknown
312
313
  description: await describeFrame(arena, piece),
313
314
  };
314
315
  }
315
- await streamToTab({ op: 'stage', session: pending.session, revision: pending.revision,
316
+ await streamToTab({ op: 'stage', evaluation: pending.evaluation, session: pending.session, revision: pending.revision,
316
317
  ...(mesh !== undefined ? { mesh } : { image }), piece: copied.typed });
317
318
  // Keep only the manifest and digest; the presenter already built this
318
319
  // resource, and the next pull may overwrite the engine arena.
@@ -805,7 +806,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
805
806
  op: 'action-clip',
806
807
  ...(request.object === undefined ? {} : { object: request.object }),
807
808
  ...(request.bake === undefined ? {} : { bake: request.bake }),
809
+ ...(request.action === undefined ? {} : { action: request.action }),
808
810
  });
811
+ case 'armature-actions':
812
+ return ask({ op: 'armature-actions' });
809
813
  case 'outliner-set':
810
814
  return ask({
811
815
  op: 'outliner-set',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.131",
5
+ "version": "0.1.133",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "blender": "5.2.0 LTS",
3
- "source": "434072350c0577eb155068d5a1b62bb45daf19f9",
3
+ "source": "5a3cb1250fc1fd7df59e3ff823949c06e52d9422",
4
4
  "upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
5
5
  "sourceRepository": "https://github.com/volter-ai/blender",
6
- "sourceRecipe": "https://github.com/volter-ai/blender/blob/434072350c0577eb155068d5a1b62bb45daf19f9/build_files/emscripten/RELEASE.md",
6
+ "sourceRecipe": "https://github.com/volter-ai/blender/blob/5a3cb1250fc1fd7df59e3ff823949c06e52d9422/build_files/emscripten/RELEASE.md",
7
7
  "factoryEngine": "BLENDER_EEVEE",
8
8
  "files": {
9
9
  "blender_browser.js": "raw",
@@ -16,8 +16,8 @@
16
16
  "sha256": "d818e8d746bc62ebf810206ac564d2dc75dc645b3121f4a3919d13ecef11e419"
17
17
  },
18
18
  "blender_browser.wasm": {
19
- "bytes": 86344416,
20
- "sha256": "f5b988d30a9f29708254bc84600f7b88d9c9eb41af904b84c9f398d5c41595e4"
19
+ "bytes": 86351436,
20
+ "sha256": "d4c3a8002cb1686f3c5299e28bcbb356934bbe910f2485b2572032b115cf67bd"
21
21
  },
22
22
  "blender_browser.data": {
23
23
  "bytes": 52699590,
@@ -30,8 +30,8 @@
30
30
  "sha256": "d818e8d746bc62ebf810206ac564d2dc75dc645b3121f4a3919d13ecef11e419"
31
31
  },
32
32
  "blender_browser.wasm.br": {
33
- "bytes": 18611942,
34
- "sha256": "b415a2fce0f2d2f796a2a3362b38420df063866898cc04c61d4604c5caf13e0d"
33
+ "bytes": 18605311,
34
+ "sha256": "97e3bc2c772e9fd6da5a3e28086c1ee1dfd62b35ebdeb127a413b4bd5904031f"
35
35
  },
36
36
  "blender_browser.data.br": {
37
37
  "bytes": 8056485,
@@ -42,9 +42,9 @@
42
42
  "sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
43
43
  }
44
44
  },
45
- "note": "Stoneguard c7/c14/c15 engine. Emscripten 6.0.5, Release -O3 objects and -O2 link. Portable native arm64 matrix reciprocal plus fused generic OpenSubdiv stencil and patch accumulation; bodice four-stage comparison 28/28 arrays exact. Full-scene comparison and memory scope recorded in provenance/stoneguard-memory.json. Historical battery absent; no full-battery or hosted acceptance claim. Rebuilt from the public instance-source commit with the same Emscripten 6.0.5, Release -O3 objects and -O2 link. All 6287 drawable Lone Monk placements resolve to 26 native source objects and 24 deferred mesh resources. Live raster rendering still differs from native Cycles; no full-scene visual parity claim. Rebuilt from the public evaluated-geometry identity commit with the same Emscripten 6.0.5 Release recipe. Distinct Array/Mirror/deformation outputs no longer borrow another object’s geometry. Native linked-input sharing and every deferred probe vertex are checked independently. Live raster illumination remains unoccluded environment lighting and differs substantially from native Cycles; no visual parity claim.",
45
+ "note": "Emscripten 6.0.5, Release -O3 objects and -O2 link, retained public build recipe and dependency licenses. Scoped render-evaluated export preserves render-only geometry and render-specific modifier results without replacing the viewport export state. Prior geometry identity and memory fixes remain. Full-scene Three versus Cycles rendering parity remains incomplete.",
46
46
  "callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
47
47
  "engineIds": "The session preserves the compiled native CYCLES engine. VOLTER_THREE selects the explicit raster presenter; BLENDER_EEVEE and BLENDER_WORKBENCH remain presentation engines.",
48
- "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces.",
48
+ "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available. Optional instance_sources exports stable evaluated collection and particle source meshes, including excluded library meshes and converted curves. Frame-local instance_geometry tokens verify Python placement metadata against the exported geometry and are consumed before presentation. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces. Scoped begin_render_export/end_render_export reads the render engine depsgraph and isolates viewport revision, undo, deferred resource and image watcher state. Geometry resource identity preserves implicit input storage only for unmodified geometry and separates evaluated modifier/deformation outputs. Object and mesh resource keys have independent namespaces.",
49
49
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
50
50
  }
Binary file