@volter/blender-engine 0.1.37 → 0.1.38

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.
@@ -114,7 +114,24 @@ export interface BlenderEngineOptions {
114
114
  * `saveDue` rides BESIDE the frame (the frame's own schema is the
115
115
  * presenter's): this present left the session's `.blend` behind the model,
116
116
  * and whoever answers owes it a save once the session goes idle. */
117
- ask(payload: { frame: unknown; capture?: unknown; saveDue?: boolean }): Promise<unknown>;
117
+ ask(payload: SessionAsk): Promise<unknown>;
118
+ }
119
+
120
+ /**
121
+ * WHAT THE SESSION ASKS. A whole frame (`frame`), presented at once; or a frame in pieces
122
+ * (`session.py::_pull`): the frame whose changed meshes and pictures are deferred (`hold`), then
123
+ * each of them (`mesh` or `image`, with its `piece`), then the frame again with `present`, which is
124
+ * answered as a whole frame is.
125
+ */
126
+ export interface SessionAsk {
127
+ frame?: unknown;
128
+ hold?: unknown;
129
+ mesh?: string;
130
+ image?: string;
131
+ piece?: unknown;
132
+ present?: boolean;
133
+ capture?: unknown;
134
+ saveDue?: boolean;
118
135
  }
119
136
 
120
137
  export interface BlenderEngine {
@@ -35,7 +35,7 @@ export interface ColumnDescriptor {
35
35
  stride: number;
36
36
  }
37
37
 
38
- const isDescriptor = (value: unknown): value is ColumnDescriptor =>
38
+ export const isColumnDescriptor = (value: unknown): value is ColumnDescriptor =>
39
39
  typeof value === 'object' &&
40
40
  value !== null &&
41
41
  typeof (value as ColumnDescriptor).offset === 'number' &&
@@ -97,7 +97,7 @@ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescripto
97
97
  */
98
98
  export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
99
99
  const walk = (value: unknown, key: string): unknown => {
100
- if (isDescriptor(value)) {
100
+ if (isColumnDescriptor(value)) {
101
101
  const view = readColumn(arena, key, value);
102
102
  return key === 'co' ? Float64Array.from(view) : view;
103
103
  }
@@ -144,7 +144,7 @@ export interface ColumnDigest {
144
144
  */
145
145
  export async function describeFrame(arena: Uint8Array, frame: unknown): Promise<unknown> {
146
146
  const walk = async (value: unknown, key: string): Promise<unknown> => {
147
- if (isDescriptor(value)) {
147
+ if (isColumnDescriptor(value)) {
148
148
  const { offset, length, dtype } = value;
149
149
  if (offset < 0 || length < 0 || offset + length > arena.byteLength)
150
150
  throw new Error(
@@ -1373,6 +1373,11 @@ class Session:
1373
1373
  # that can see the ask can read the file.
1374
1374
  if EXPORT_BUFFER_PATH:
1375
1375
  options["buffer_path"] = EXPORT_BUFFER_PATH
1376
+ # NOTICE, THEN PULL (`_present`): a changed mesh is named here and its columns are written
1377
+ # one mesh at a time after, so the engine never holds every changed mesh's columns at once.
1378
+ # An engine without the pull door ships them all in this call, as before.
1379
+ if hasattr(_blender_web, "export_mesh"):
1380
+ options["defer"] = True
1376
1381
  frame = json.loads(_blender_web.export_frame(json.dumps(options)))
1377
1382
  error = frame.get("error")
1378
1383
  if error:
@@ -1499,13 +1504,15 @@ class Session:
1499
1504
  def _present(self, capture=None):
1500
1505
  known = dict(self._known)
1501
1506
  frame = self._export()
1507
+ pulled = self._pull(frame)
1502
1508
  self._drop_unreachable_textures(frame)
1503
1509
  # WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
1504
1510
  # whose columns crossed, and the ids that went as a reference. The
1505
1511
  # export's whole revision rule is visible here and nowhere else.
1506
1512
  self.last_shipped = {
1507
1513
  "revision": self.revision,
1508
- "columns": sorted(k for k, v in frame["meshes"].items() if "columns" in v),
1514
+ "columns": sorted(k for k, v in frame["meshes"].items()
1515
+ if "columns" in v or v.get("deferred")),
1509
1516
  "unchanged": sorted(k for k, v in frame["meshes"].items() if v.get("unchanged")),
1510
1517
  "images": sorted(frame["images"].keys()),
1511
1518
  # How many COLUMN BYTES this frame actually shipped: a move ships
@@ -1535,6 +1542,8 @@ class Session:
1535
1542
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
1536
1543
  weights["digest"])
1537
1544
  request = {"frame": frame}
1545
+ if pulled:
1546
+ request["present"] = True
1538
1547
  if capture:
1539
1548
  request["capture"] = capture
1540
1549
  # BESIDE the frame, never inside it: the frame's schema is the
@@ -1553,6 +1562,38 @@ class Session:
1553
1562
  self._reconcile_with_presenter(answer, frame)
1554
1563
  return answer
1555
1564
 
1565
+ def _pull(self, frame):
1566
+ """THE PULL, one datablock at a time, as Hydra's render delegate pulls each prim's data
1567
+ during Sync after the scene index's notice (`HydraSceneIndex::populate`). The door named
1568
+ the changed meshes and pictures as deferred; the worker first copies the frame's own
1569
+ columns out of the heap (`hold`), then each mesh's columns and each picture's pixels in
1570
+ turn, copying each out before the next is written (every pull frees the one before). The
1571
+ engine's peak is the scene plus one datablock's columns, not plus every changed one's.
1572
+
1573
+ The pieces come back into the frame as the door would have written them: a picture with no
1574
+ readable pixels leaves `images` with its warning, as it did when the frame carried it
1575
+ whole, so `_drop_unreachable_textures` reads the same frame either way. Answers whether
1576
+ anything was pulled; the worker then presents the frame with its pieces."""
1577
+ meshes = [key for key, mesh in frame["meshes"].items() if mesh.get("deferred")]
1578
+ images = [name for name, image in frame["images"].items() if image.get("deferred")]
1579
+ if not meshes and not images:
1580
+ return False
1581
+ ask({"hold": frame})
1582
+ options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
1583
+ for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
1584
+ ("image", images, _blender_web.export_image)):
1585
+ for key in keys:
1586
+ options["key"] = key
1587
+ piece = json.loads(door(json.dumps(options)))
1588
+ if piece.get("error"):
1589
+ raise RuntimeError("Blender export door: %s" % piece["error"])
1590
+ frame["warnings"].extend(piece.get("warnings", []))
1591
+ if piece[kind] is None:
1592
+ del frame["images"][key]
1593
+ continue
1594
+ ask({kind: key, "piece": piece[kind]})
1595
+ return True
1596
+
1556
1597
  def _drop_unreachable_textures(self, frame):
1557
1598
  """A picture the export could not read is a WARNING, not a refusal.
1558
1599
 
package/browser/worker.ts CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
40
40
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
41
- import { columnsToTypedArrays, describeFrame } from './session-frame.mts';
41
+ import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
42
42
 
43
43
  const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
44
44
  const log = (level: 'log' | 'warn' | 'error', text: string) => post({ op: 'log', level, text });
@@ -123,6 +123,65 @@ function enqueue<T>(work: () => Promise<T>): Promise<T> {
123
123
  return result;
124
124
  }
125
125
 
126
+ /** A frame arriving in pieces (`session.py::_pull`), held until its `present`: the frame's own
127
+ * columns by their arena offset, and each deferred datablock copied as it came. */
128
+ interface Copied {
129
+ typed: unknown;
130
+ description: unknown;
131
+ }
132
+ interface PendingFrame {
133
+ columns: Map<number, Copied>;
134
+ meshes: Map<string, Copied>;
135
+ images: Map<string, Copied>;
136
+ }
137
+ let pending: PendingFrame | null = null;
138
+
139
+ /** Every column the held frame names, copied now and keyed by its offset (unique in its arena). */
140
+ async function copyColumns(arena: Uint8Array, frame: unknown): Promise<Map<number, Copied>> {
141
+ const columns = new Map<number, Copied>();
142
+ const walk = async (value: unknown, key: string): Promise<void> => {
143
+ if (isColumnDescriptor(value)) {
144
+ columns.set(value.offset, {
145
+ typed: (columnsToTypedArrays(arena, { [key]: value }) as Record<string, unknown>)[key],
146
+ description: (await describeFrame(arena, value)) as unknown,
147
+ });
148
+ return;
149
+ }
150
+ if (Array.isArray(value)) for (const entry of value) await walk(entry, key);
151
+ else if (typeof value === 'object' && value !== null)
152
+ for (const [name, held] of Object.entries(value)) await walk(held, name);
153
+ };
154
+ await walk(frame, '');
155
+ return columns;
156
+ }
157
+
158
+ /** The presented frame with its pieces put back: each column from the held copies, each deferred
159
+ * mesh and picture from its pulled piece. Both the typed frame and its record. */
160
+ function assemblePending(held: PendingFrame, frame: unknown): { typed: unknown; description: unknown } {
161
+ const build = (side: keyof Copied) => {
162
+ const walk = (value: unknown, path: readonly string[]): unknown => {
163
+ if (isColumnDescriptor(value)) {
164
+ const copied = held.columns.get(value.offset);
165
+ if (!copied) throw new Error(`Blender frame column ${path.join('.')}: not in the held frame`);
166
+ return copied[side];
167
+ }
168
+ if (Array.isArray(value)) return value.map((entry) => walk(entry, path));
169
+ if (typeof value !== 'object' || value === null) return value;
170
+ if ((value as { deferred?: unknown }).deferred === true) {
171
+ const [group, name] = path.slice(-2);
172
+ const piece = (group === 'meshes' ? held.meshes : held.images).get(name as string);
173
+ if (!piece) throw new Error(`Blender frame ${group} ${name}: deferred and never pulled`);
174
+ return piece[side];
175
+ }
176
+ const out: Record<string, unknown> = {};
177
+ for (const [name, entry] of Object.entries(value)) out[name] = walk(entry, [...path, name]);
178
+ return out;
179
+ };
180
+ return walk(frame, []);
181
+ };
182
+ return { typed: build('typed'), description: build('description') };
183
+ }
184
+
126
185
  function setDocumentDirty(dirty: boolean): void {
127
186
  documentDirty = dirty;
128
187
  post({ op: 'document-dirty', dirty });
@@ -182,22 +241,44 @@ async function startBlender(project: string, document?: string): Promise<unknown
182
241
  const started = await startBlenderEngine({
183
242
  project,
184
243
  log,
185
- ask: async ({ frame, capture, saveDue }) => {
244
+ ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue }) => {
186
245
  if (!holder.engine) throw new Error('The Blender session presented before it started');
187
246
  // Save once after the whole command, never during a partial frame.
188
247
  if (saveDue && documentPath !== null) setDocumentDirty(true);
189
- // THE ARENA IS READ ONCE, HERE, and both readers share those bytes: the
248
+ // THE ARENA IS READ ONCE PER ASK, and both readers share those bytes: the
190
249
  // typed arrays the tab draws from, and the record of what was sent
191
250
  // (`describeFrame`). After the post the buffers are detached and the
192
- // next `export_frame` overwrites the arena -- on either skew -- so there
251
+ // next export call overwrites the arena -- on either skew -- so there
193
252
  // is no later moment at which either could be taken.
194
253
  const arena = await holder.engine.readArena();
195
- const description = await describeFrame(arena, frame);
196
- const answered = await presentToTab(
197
- columnsToTypedArrays(arena, frame),
198
- description,
199
- capture as CaptureRequest | undefined,
200
- );
254
+ // A FRAME IN PIECES (`session.py::_pull`): its own columns and each deferred datablock are
255
+ // copied out of the heap as they come, because the next export call frees them; the tab
256
+ // gets the whole frame at `present`.
257
+ if (hold !== undefined) {
258
+ pending = { columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
259
+ return {};
260
+ }
261
+ if (mesh !== undefined || image !== undefined) {
262
+ if (!pending) throw new Error(`The Blender session sent ${mesh ?? image} before its frame`);
263
+ const copied = {
264
+ typed: columnsToTypedArrays(arena, piece),
265
+ description: await describeFrame(arena, piece),
266
+ };
267
+ if (mesh !== undefined) pending.meshes.set(mesh, copied);
268
+ else pending.images.set(image as string, copied);
269
+ return {};
270
+ }
271
+ let typed: unknown;
272
+ let description: unknown;
273
+ if (present) {
274
+ if (!pending) throw new Error('The Blender session presented a frame it never sent');
275
+ ({ typed, description } = assemblePending(pending, frame));
276
+ pending = null;
277
+ } else {
278
+ typed = columnsToTypedArrays(arena, frame);
279
+ description = await describeFrame(arena, frame);
280
+ }
281
+ const answered = await presentToTab(typed, description, capture as CaptureRequest | undefined);
201
282
  // THE CAPTURE IS THE ANSWER'S BODY, and `held` rides beside it: the
202
283
  // session reads a photograph's own fields off this object
203
284
  // (`session.py::_photograph`), and reads `held` to judge whether its
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.37",
5
+ "version": "0.1.38",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },