@volter/blender-engine 0.1.36 → 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 {
@@ -76,6 +76,9 @@ export type WorkerRequest =
76
76
  /** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
77
77
  * Blender draws on no row of that type is refused by name. */
78
78
  | { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
79
+ /** A person's shading pick, kept in the file's own 3D View (`session.py`'s `_view_shading`);
80
+ * UI state, so it marks nothing changed and rides the next save. */
81
+ | { id: number; op: 'view-shading'; shading: 'WIREFRAME' | 'SOLID' | 'MATERIAL' | 'RENDERED' }
79
82
  | { id: number; op: 'read-file'; path: string }
80
83
  | { id: number; op: 'write-file'; path: string; bytes: Uint8Array }
81
84
  | { id: number; op: 'list-files'; path: string }
@@ -318,6 +318,12 @@ export class BlenderRuntime {
318
318
  })) as BlenderRnaWrite;
319
319
  }
320
320
 
321
+ /** Keep a person's shading pick in the file's own 3D View, as UI state (no save of its own). */
322
+ async viewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATERIAL' | 'RENDERED'): Promise<void> {
323
+ await this.#ready();
324
+ await this.#request({ op: 'view-shading', shading });
325
+ }
326
+
321
327
  /** BLENDER'S VIEW LAYER TREE for the scene — what its Outliner shows, as
322
328
  * rows (`./rna.ts`, `BlenderOutlinerTree`). `selected` is our viewport's
323
329
  * selection by object name; reading it never writes the engine's. */
@@ -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(
@@ -789,6 +789,23 @@ def _saved_view():
789
789
  return None
790
790
 
791
791
 
792
+ def _view_shading(shading):
793
+ """A person's shading pick, written where Blender keeps it: the `View3DShading.type` of the 3D
794
+ View `_saved_view` reads. UI STATE, NOT THE MODEL: nothing is marked changed, so it rides the
795
+ next save the model's own edits make, as a shading change in Blender is kept only by a save."""
796
+ screens = [window.screen for wm in bpy.data.window_managers for window in wm.windows
797
+ if window.screen is not None]
798
+ workspace = bpy.data.workspaces.get("Modeling")
799
+ screens.extend(workspace.screens if workspace is not None else [])
800
+ screens.extend(bpy.data.screens)
801
+ for screen in screens:
802
+ for area in screen.areas:
803
+ if area.type == "VIEW_3D":
804
+ area.spaces[0].shading.type = shading
805
+ return {"shading": area.spaces[0].shading.type}
806
+ return {"shading": None}
807
+
808
+
792
809
  def _subject_line(scene, view_layer):
793
810
  """The 3D Viewport's second overlay line, as `draw_selected_name` (`view3d_draw.cc`) writes it,
794
811
  less the two parts that follow the playhead: the tab draws `(frame)` and the marker on it
@@ -1356,6 +1373,11 @@ class Session:
1356
1373
  # that can see the ask can read the file.
1357
1374
  if EXPORT_BUFFER_PATH:
1358
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
1359
1381
  frame = json.loads(_blender_web.export_frame(json.dumps(options)))
1360
1382
  error = frame.get("error")
1361
1383
  if error:
@@ -1482,13 +1504,15 @@ class Session:
1482
1504
  def _present(self, capture=None):
1483
1505
  known = dict(self._known)
1484
1506
  frame = self._export()
1507
+ pulled = self._pull(frame)
1485
1508
  self._drop_unreachable_textures(frame)
1486
1509
  # WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
1487
1510
  # whose columns crossed, and the ids that went as a reference. The
1488
1511
  # export's whole revision rule is visible here and nowhere else.
1489
1512
  self.last_shipped = {
1490
1513
  "revision": self.revision,
1491
- "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")),
1492
1516
  "unchanged": sorted(k for k, v in frame["meshes"].items() if v.get("unchanged")),
1493
1517
  "images": sorted(frame["images"].keys()),
1494
1518
  # How many COLUMN BYTES this frame actually shipped: a move ships
@@ -1518,6 +1542,8 @@ class Session:
1518
1542
  self._known["weights:%s:%s" % (weights["object"], weights["group"])] = (
1519
1543
  weights["digest"])
1520
1544
  request = {"frame": frame}
1545
+ if pulled:
1546
+ request["present"] = True
1521
1547
  if capture:
1522
1548
  request["capture"] = capture
1523
1549
  # BESIDE the frame, never inside it: the frame's schema is the
@@ -1536,6 +1562,38 @@ class Session:
1536
1562
  self._reconcile_with_presenter(answer, frame)
1537
1563
  return answer
1538
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
+
1539
1597
  def _drop_unreachable_textures(self, frame):
1540
1598
  """A picture the export could not read is a WARNING, not a refusal.
1541
1599
 
@@ -5002,6 +5060,8 @@ def _dispatch(request):
5002
5060
  _say("@@VOLTER-ERROR the present after this call failed, so the Model document is "
5003
5061
  "showing the state before it: " + repr(thrown))
5004
5062
  return answer
5063
+ if op == "view-shading":
5064
+ return _view_shading(request["shading"])
5005
5065
  if op == "rna-set":
5006
5066
  SESSION.mark_changed()
5007
5067
  answer = rna_set(request["path"], request["property"], request["value"],
@@ -843,6 +843,10 @@ export class BlenderRuntimeView {
843
843
  * draw mode Blender reopens it in (null from a file whose view carried none). Null before a
844
844
  * frame or when the file saved no 3D View.
845
845
  */
846
+ noteSavedShading(shading: keyof typeof SAVED_SHADING): void {
847
+ if (this.frame?.view) this.frame.view.shading = shading;
848
+ }
849
+
846
850
  savedView(): {
847
851
  readonly target: readonly [number, number, number];
848
852
  readonly direction: readonly [number, number, number];
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
@@ -508,6 +589,8 @@ async function handle(request: WorkerRequest): Promise<unknown> {
508
589
  ...(request.object === undefined ? {} : { object: request.object }),
509
590
  ...(request.collection === undefined ? {} : { collection: request.collection }),
510
591
  });
592
+ case 'view-shading':
593
+ return ask({ op: 'view-shading', shading: request.shading });
511
594
  case 'rna-set':
512
595
  return ask({
513
596
  op: 'rna-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.36",
5
+ "version": "0.1.38",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },