@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.
- package/browser/blender-engine.mts +18 -1
- package/browser/protocol.ts +3 -0
- package/browser/runtime.ts +6 -0
- package/browser/session-frame.mts +3 -3
- package/browser/session.py +61 -1
- package/browser/three/blender-runtime-view.ts +4 -0
- package/browser/worker.ts +93 -10
- package/package.json +1 -1
|
@@ -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:
|
|
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 {
|
package/browser/protocol.ts
CHANGED
|
@@ -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 }
|
package/browser/runtime.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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(
|
package/browser/session.py
CHANGED
|
@@ -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()
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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',
|