@volter/blender-engine 0.1.54 → 0.1.56

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.
@@ -0,0 +1,71 @@
1
+ /** Bounded, acknowledged frame transport. One value (one mesh/image or the
2
+ * metadata frame) is assembled at a time; SHA-256 covers every chunk, including
3
+ * the metadata. Nothing is applied before all of that value has been verified. */
4
+ export const FRAME_CHUNK_BYTES = 1024 * 1024;
5
+ export type FrameChunk =
6
+ | { kind: 'begin'; lengths: number[] }
7
+ | { kind: 'bytes'; slot: number; offset: number; bytes: Uint8Array; sha256: string }
8
+ | { kind: 'end' };
9
+ const arrays = { Uint8Array, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array };
10
+ const digest = async (bytes: Uint8Array): Promise<string> =>
11
+ Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>)), b => b.toString(16).padStart(2, '0')).join('');
12
+
13
+ export async function sendFrameValue(value: unknown, send: (chunk: FrameChunk) => Promise<unknown>): Promise<unknown> {
14
+ const buffers: Uint8Array[] = [];
15
+ const json = JSON.stringify(value, (_key, item: unknown) => {
16
+ if (!ArrayBuffer.isView(item)) return item;
17
+ const type = item.constructor.name;
18
+ if (!Object.hasOwn(arrays, type)) throw new Error(`Unsupported frame column ${type}`);
19
+ buffers.push(new Uint8Array(item.buffer, item.byteOffset, item.byteLength));
20
+ return { $frameColumn: buffers.length, type };
21
+ });
22
+ buffers.unshift(new TextEncoder().encode(json));
23
+ await send({ kind: 'begin', lengths: buffers.map(b => b.byteLength) });
24
+ for (const [slot, buffer] of buffers.entries()) {
25
+ for (let offset = 0; offset < buffer.byteLength; offset += FRAME_CHUNK_BYTES) {
26
+ const bytes = buffer.slice(offset, offset + FRAME_CHUNK_BYTES);
27
+ const sha256 = await digest(bytes);
28
+ await send({ kind: 'bytes', slot, offset, bytes, sha256 });
29
+ }
30
+ }
31
+ return send({ kind: 'end' });
32
+ }
33
+
34
+ export class FrameStreamReader {
35
+ private buffers: Uint8Array[] | null = null;
36
+ private offsets: number[] = [];
37
+ reset(): void { this.buffers = null; this.offsets = []; }
38
+ async accept(chunk: FrameChunk): Promise<{ value: unknown } | undefined> {
39
+ try {
40
+ if (chunk.kind === 'begin') {
41
+ if (this.buffers) throw new Error('A frame value is already arriving');
42
+ if (!chunk.lengths.length || chunk.lengths.length > 4096 || chunk.lengths.reduce((sum, n) => sum + n, 0) > 2 * 1024 ** 3 || chunk.lengths.some(n => !Number.isSafeInteger(n) || n < 0 || n > 1024 ** 3))
43
+ throw new Error('Invalid frame buffer lengths');
44
+ this.buffers = chunk.lengths.map(n => new Uint8Array(n));
45
+ this.offsets = chunk.lengths.map(() => 0);
46
+ return;
47
+ }
48
+ if (!this.buffers) throw new Error('Frame chunk arrived without its manifest');
49
+ if (chunk.kind === 'bytes') {
50
+ const buffer = this.buffers[chunk.slot];
51
+ if (!buffer || !Number.isSafeInteger(chunk.offset) || chunk.offset !== this.offsets[chunk.slot] || chunk.bytes.byteLength > FRAME_CHUNK_BYTES || !chunk.bytes.byteLength || chunk.offset + chunk.bytes.byteLength > buffer.byteLength)
52
+ throw new Error('Frame chunk is out of order or exceeds its bound');
53
+ if (await digest(chunk.bytes) !== chunk.sha256) throw new Error('Frame chunk digest mismatch');
54
+ buffer.set(chunk.bytes, chunk.offset); this.offsets[chunk.slot] = chunk.offset + chunk.bytes.byteLength;
55
+ return;
56
+ }
57
+ if (chunk.kind !== 'end') throw new Error('Unknown frame chunk');
58
+ const buffers = this.buffers;
59
+ if (buffers.some((b, i) => b.length !== this.offsets[i])) throw new Error('Frame value is incomplete');
60
+ const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffers[0]), (_key, item) => {
61
+ if (!item || typeof item !== 'object' || !('$frameColumn' in item)) return item;
62
+ const bytes = buffers[item.$frameColumn];
63
+ const Type = arrays[item.type as keyof typeof arrays];
64
+ if (!Number.isSafeInteger(item.$frameColumn) || item.$frameColumn < 1 || !bytes || !Object.hasOwn(arrays, item.type) || !Type || bytes.byteLength % Type.BYTES_PER_ELEMENT)
65
+ throw new Error('Invalid frame column descriptor');
66
+ return new Type(bytes.buffer as ArrayBuffer);
67
+ });
68
+ this.reset(); return { value };
69
+ } catch (error) { this.reset(); throw error; }
70
+ }
71
+ }
@@ -1,3 +1,4 @@
1
+ import type { FrameChunk } from './frame-stream.mts';
1
2
  /** Messages between the editor tab and its Blender worker. One session per
2
3
  * worker; the model lives in the worker's Python and nowhere else. */
3
4
 
@@ -97,6 +98,7 @@ export type WorkerRequest =
97
98
  };
98
99
 
99
100
  export type WorkerReply =
101
+ | { op: 'frame-stream'; id: number; chunk: FrameChunk }
100
102
  | { op: 'document-dirty'; dirty: boolean }
101
103
  | { op: 'history'; entries: NativeHistoryEntry[] }
102
104
  | { id: number; result: unknown }
@@ -1,3 +1,4 @@
1
+ import { FrameStreamReader } from './frame-stream.mts';
1
2
  /**
2
3
  * The tab-side handle on a Blender session: spawns the worker, forwards the
3
4
  * four tool calls, and hands every presented frame to whoever displays it.
@@ -27,6 +28,8 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
27
28
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
28
29
 
29
30
  export interface BlenderRuntimeOptions {
31
+ /** Stage one verified mesh/image without replacing the displayed frame. */
32
+ stage?(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
30
33
  /** Operation-only diagnostics, never request payloads. The optional observer
31
34
  * must not affect work, even if it throws. End calls balance overlapping work. */
32
35
  work?(label: string): () => void;
@@ -478,6 +481,7 @@ export class BlenderRuntime {
478
481
 
479
482
  /** Forced teardown for a lost session; explicit user stops must use stop(). */
480
483
  terminate(): void {
484
+ this.#discardStagedFrame();
481
485
  if (this.#terminated) return;
482
486
  this.#terminated = true;
483
487
  globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
@@ -570,8 +574,39 @@ export class BlenderRuntime {
570
574
  } catch { return () => {}; }
571
575
  }
572
576
 
577
+ readonly #frameStream = new FrameStreamReader();
578
+ #stagedIdentity: { session: string; revision: number } | null = null;
579
+ #discardStagedFrame(): void {
580
+ this.#frameStream.reset();
581
+ const part = this.#stagedIdentity; this.#stagedIdentity = null;
582
+ if (part) {
583
+ try { void Promise.resolve(this.#options.stage?.({ ...part, abort: true })).catch(() => {}); }
584
+ catch { /* Cleanup cannot replace the original operation failure. */ }
585
+ }
586
+ }
587
+
573
588
  async #receive(reply: WorkerReply): Promise<void> {
574
589
  if ('op' in reply) {
590
+ if (reply.op === 'frame-stream') {
591
+ try {
592
+ const decoded = await this.#frameStream.accept(reply.chunk);
593
+ if (decoded) {
594
+ const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
595
+ if (value.op === 'present') {
596
+ await this.#receive({ ...value, id: reply.id } as WorkerReply);
597
+ return;
598
+ }
599
+ if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
600
+ this.#stagedIdentity = { session: value.session, revision: value.revision };
601
+ await this.#options.stage(value);
602
+ }
603
+ this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
604
+ } catch (error) {
605
+ this.#discardStagedFrame();
606
+ this.#worker.postMessage({ op: 'present-result', id: reply.id, error: error instanceof Error ? error.message : String(error) } satisfies WorkerRequest);
607
+ }
608
+ return;
609
+ }
575
610
  if (reply.op === 'document-dirty') {
576
611
  this.#dirty = reply.dirty;
577
612
  return;
@@ -600,6 +635,7 @@ export class BlenderRuntime {
600
635
  const end = this.#work('presenting worker frame');
601
636
  try {
602
637
  const answer = await this.#options.present(reply.frame, reply.description, reply.capture);
638
+ this.#stagedIdentity = null;
603
639
  this.#worker.postMessage({
604
640
  op: 'present-result',
605
641
  id: reply.id,
@@ -609,6 +645,7 @@ export class BlenderRuntime {
609
645
  : {}),
610
646
  } satisfies WorkerRequest);
611
647
  } catch (error) {
648
+ this.#discardStagedFrame();
612
649
  this.#worker.postMessage({
613
650
  op: 'present-result',
614
651
  id: reply.id,
@@ -619,6 +656,7 @@ export class BlenderRuntime {
619
656
  }
620
657
  return;
621
658
  }
659
+ if ('error' in reply) this.#discardStagedFrame();
622
660
  const pending = this.#pending.get(reply.id);
623
661
  if (!pending) return;
624
662
  this.#pending.delete(reply.id);
@@ -91,15 +91,14 @@ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescripto
91
91
  * Every `{offset, length, dtype, count, stride}` in the frame, replaced by its
92
92
  * typed array.
93
93
  *
94
- * `co` is widened to `Float64Array` because that is what `MeshColumns`
95
- * declares and `drawArraysFromColumns` is typed against, while Blender's own
96
- * vertex array is single precision.
94
+ * Positions keep Blender's own precision. Widening a float column would
95
+ * double its transfer/storage without adding information.
97
96
  */
98
97
  export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
99
98
  const walk = (value: unknown, key: string): unknown => {
100
99
  if (isColumnDescriptor(value)) {
101
100
  const view = readColumn(arena, key, value);
102
- return key === 'co' ? Float64Array.from(view) : view;
101
+ return view;
103
102
  }
104
103
  if (Array.isArray(value)) return value.map((entry) => walk(entry, key));
105
104
  if (typeof value === 'object' && value !== null) {
@@ -78,7 +78,9 @@ export const ATTRIBUTE_LAYOUT: Record<AttributeType, { size: number }> = {
78
78
  /** Exactly what the draw reads off a presented mesh. */
79
79
  export interface MeshColumns {
80
80
  /** 3 * nv */
81
- co: Float64Array;
81
+ co: Float32Array | Float64Array;
82
+ /** Blender's tessellation, triangle corner indices in face order. */
83
+ cornerTri?: Uint32Array | undefined;
82
84
  /** Blender-evaluated normals, 3 per corner, independent of UV splits. */
83
85
  cornerNormal?: Float32Array | undefined;
84
86
  /** 3 * nv, a deformed mesh's Generated coordinates in Blender's stored
@@ -313,8 +313,113 @@ export function geometryFromDrawArrays(
313
313
  return geometry;
314
314
  }
315
315
 
316
+ /** Blender already supplied corner normals and triangulation. Read those
317
+ * columns directly: no polygon triangulation, adjacency graph, vector objects
318
+ * or stringified floating-point keys. Two passes size final buffers exactly. */
319
+ function drawNativeColumns(c: MeshColumns, hash: string): DrawArrays {
320
+ const triangles = c.cornerTri!, normal = c.cornerNormal!;
321
+ const nv = c.co.length / 3, nc = c.corner.length, nf = c.faceStart.length - 1;
322
+ const maps = c.attributes.filter(a => a.type === 'FLOAT2' && a.domain === 'CORNER');
323
+ const mapName = c.renderUv ?? c.activeUv ?? maps[0]?.name;
324
+ const activeMap = maps.findIndex(a => a.name === mapName);
325
+ if (mapName && activeMap < 0) throw new Error(`Runtime UV map ${mapName} is missing`);
326
+ const attributes = graphAttributeLayers(c.attributes, data => data as ArrayLike<number>);
327
+ const width = 3 + maps.length * 2 + attributes.length * 4;
328
+ const scratch = new Float32Array(width), comparison = new Float32Array(width);
329
+ const bits = new Uint32Array(scratch.buffer), comparisonBits = new Uint32Array(comparison.buffer);
330
+ const faces = new Uint32Array(nc), cornerIndex = new Uint32Array(nc);
331
+ const representative = new Uint32Array(nc), links = new Uint32Array(nc);
332
+ const used = new Uint8Array(nv);
333
+ let tableSize = 1;
334
+ while (tableSize < Math.max(1, nc * 2)) tableSize *= 2;
335
+ const buckets = new Uint32Array(tableSize);
336
+ const read = (corner: number, face: number, vertex: number, target: Float32Array): void => {
337
+ let offset = 0;
338
+ for (let k = 0; k < 3; k++) target[offset++] = normal[corner * 3 + k]!;
339
+ for (const map of maps) for (let k = 0; k < 2; k++) target[offset++] = Number(map.data[corner * 2 + k]);
340
+ for (const layer of attributes) {
341
+ const value = layer.read(vertex, corner, face);
342
+ for (let k = 0; k < 4; k++) target[offset++] = Number(value[k]);
343
+ }
344
+ };
345
+ let count = 0;
346
+ for (let face = 0; face < nf; face++) {
347
+ for (let corner = c.faceStart[face]!; corner < c.faceStart[face + 1]!; corner++) {
348
+ const vertex = c.corner[corner]!;
349
+ if (vertex >= nv) throw new Error('Native mesh corner references a missing vertex');
350
+ faces[corner] = face; used[vertex] = 1;
351
+ read(corner, face, vertex, scratch);
352
+ let digest = Math.imul(vertex ^ 2166136261, 16777619);
353
+ for (const word of bits) digest = Math.imul(digest ^ word, 16777619);
354
+ const bucket = (digest >>> 0) & (tableSize - 1);
355
+ let found = -1;
356
+ for (let entry = buckets[bucket]!; entry; entry = links[entry - 1]!) {
357
+ const index = entry - 1, previous = representative[index]!;
358
+ if (c.corner[previous] !== vertex) continue;
359
+ read(previous, faces[previous]!, vertex, comparison);
360
+ let equal = true;
361
+ for (let k = 0; k < width; k++) if (bits[k] !== comparisonBits[k]) { equal = false; break; }
362
+ if (equal) { found = index; break; }
363
+ }
364
+ if (found < 0) {
365
+ found = count++; representative[found] = corner;
366
+ links[found] = buckets[bucket]!; buckets[bucket] = found + 1;
367
+ }
368
+ cornerIndex[corner] = found;
369
+ }
370
+ }
371
+ let loose = 0; for (const flag of used) if (!flag) loose++;
372
+ const total = count + loose;
373
+ const positions = new Float32Array(total * 3), normals = new Float32Array(total * 3);
374
+ const sourceVertex = new Uint32Array(total);
375
+ const uvLayers = maps.map(map => ({ name: map.name, data: new Float32Array(total * 2) }));
376
+ const attributeLayers = attributes.map(layer => ({ name: layer.name, data: new Float32Array(total * 4) }));
377
+ const orco = c.orco ? new Float32Array(total * 3) : null;
378
+ const position = (index: number, vertex: number): void => {
379
+ sourceVertex[index] = vertex;
380
+ for (let k = 0; k < 3; k++) {
381
+ positions[index * 3 + k] = c.co[vertex * 3 + k]!;
382
+ if (orco) orco[index * 3 + k] = c.orco![vertex * 3 + k]! * 0.5 + 0.5;
383
+ }
384
+ };
385
+ for (let index = 0; index < count; index++) {
386
+ const corner = representative[index]!, vertex = c.corner[corner]!;
387
+ position(index, vertex); read(corner, faces[corner]!, vertex, scratch);
388
+ normals.set(scratch.subarray(0, 3), index * 3);
389
+ let offset = 3;
390
+ for (const layer of uvLayers) { layer.data.set(scratch.subarray(offset, offset + 2), index * 2); offset += 2; }
391
+ for (const layer of attributeLayers) { layer.data.set(scratch.subarray(offset, offset + 4), index * 4); offset += 4; }
392
+ }
393
+ let index = count;
394
+ for (let vertex = 0; vertex < nv; vertex++) if (!used[vertex]) {
395
+ position(index, vertex);
396
+ for (let layer = 0; layer < attributes.length; layer++) attributeLayers[layer]!.data.set(attributes[layer]!.read(vertex, undefined, undefined), index * 4);
397
+ index++;
398
+ }
399
+ const indices = new Uint32Array(triangles.length), groups: DrawArrays['groups'] = [];
400
+ let offset = 0;
401
+ for (let face = 0; face < nf; face++) {
402
+ const start = c.faceStart[face]!, end = c.faceStart[face + 1]!;
403
+ const length = Math.max(0, end - start - 2) * 3;
404
+ if (offset + length > triangles.length) throw new Error('Native mesh triangulation is incomplete');
405
+ for (let j = 0; j < length; j++) {
406
+ const corner = triangles[offset + j]!;
407
+ if (corner < start || corner >= end) throw new Error('Native triangle references a different face');
408
+ indices[offset + j] = cornerIndex[corner]!;
409
+ }
410
+ const materialIndex = c.material[face]!, previous = groups.at(-1);
411
+ if (previous?.materialIndex === materialIndex) previous.count += length;
412
+ else groups.push({ start: offset, count: length, materialIndex });
413
+ offset += length;
414
+ }
415
+ if (offset !== triangles.length) throw new Error('Native mesh triangulation has extra triangles');
416
+ return { positions, normals, sourceVertex, uv: activeMap < 0 ? null : uvLayers[activeMap]!.data,
417
+ uvLayers, attributeLayers, orco, indices, groups, hash };
418
+ }
419
+
316
420
  /** The worker's door: the mesh store's columns, drawn into buffers. */
317
421
  export function drawArraysFromColumns(c: MeshColumns, hash: string): DrawArrays {
422
+ if (c.cornerTri && c.cornerNormal) return drawNativeColumns(c, hash);
318
423
  const nv = c.co.length / 3;
319
424
  const maps = c.attributes.filter((a) => a.type === 'FLOAT2' && a.domain === 'CORNER');
320
425
  const mapName = c.renderUv ?? c.activeUv ?? maps[0]?.name;
@@ -184,8 +184,9 @@ const unchangedMeshSchema = z
184
184
  */
185
185
  const columnsSchema = z
186
186
  .object({
187
- co: z.instanceof(Float64Array),
187
+ co: z.union([z.instanceof(Float32Array), z.instanceof(Float64Array)]),
188
188
  cornerNormal: z.instanceof(Float32Array).optional(),
189
+ cornerTri: z.instanceof(Uint32Array).optional(),
189
190
  orco: z.instanceof(Float32Array).optional(),
190
191
  faceStart: z.instanceof(Uint32Array),
191
192
  corner: z.instanceof(Uint32Array),
@@ -1442,6 +1443,33 @@ export class BlenderRuntimeView {
1442
1443
  group.visible = !this.capturing;
1443
1444
  }
1444
1445
 
1446
+ private staged: { session: string; revision: number; meshes: Map<string, { signature: string; geometry: THREE.BufferGeometry }>; images: Map<string, z.infer<typeof frameImageSchema>> } | null = null;
1447
+
1448
+ /** Prepare one mesh as soon as its bounded transfer completes. Source
1449
+ * columns are released before the next mesh; displayed resources change
1450
+ * only when the final frame manifest is validated. */
1451
+ stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void {
1452
+ if (part.abort || (!part.mesh && !part.image)) {
1453
+ for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
1454
+ this.staged = part.abort ? null : { session: part.session, revision: part.revision, meshes: new Map(), images: new Map() };
1455
+ return;
1456
+ }
1457
+ const staged = this.staged;
1458
+ if (!staged || staged.session !== part.session || staged.revision !== part.revision)
1459
+ throw new Error('Blender frame piece does not belong to the pending revision');
1460
+ if (part.mesh !== undefined) {
1461
+ const data = exportedMeshSchema.parse(part.piece);
1462
+ if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
1463
+ const signature = `revision:${data.revision}`;
1464
+ staged.meshes.set(part.mesh, { signature, geometry: geometryFromDrawArrays(drawArraysFromColumns({
1465
+ ...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
1466
+ }, signature)) });
1467
+ } else if (part.image !== undefined) {
1468
+ if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
1469
+ staged.images.set(part.image, frameImageSchema.parse(part.piece));
1470
+ }
1471
+ }
1472
+
1445
1473
  applyFrame(input: unknown) {
1446
1474
  // WHAT THIS PRESENTER HELD BEFORE THIS FRAME, read before anything is
1447
1475
  // applied, and carried back to the session in the present's answer.
@@ -1459,6 +1487,10 @@ export class BlenderRuntimeView {
1459
1487
  const held =
1460
1488
  this.frame === null ? null : { session: this.frame.session, revision: this.frame.revision };
1461
1489
  const next = frameSchema.parse(input);
1490
+ const staged = this.staged;
1491
+ if (staged && (staged.session !== next.session || staged.revision !== next.revision))
1492
+ throw new Error('Blender frame manifest does not match its staged revision');
1493
+ if (staged) for (const [name, data] of staged.images) next.images[name] = data;
1462
1494
  if (this.retiredSessions.has(next.session))
1463
1495
  throw new Error(
1464
1496
  'The runtime session was replaced; its delayed frame cannot overwrite the active model',
@@ -1503,6 +1535,12 @@ export class BlenderRuntimeView {
1503
1535
  }
1504
1536
  for (const [id, data] of Object.entries(next.meshes)) {
1505
1537
  if ('unchanged' in data) {
1538
+ const incoming = staged?.meshes.get(id);
1539
+ if (incoming) {
1540
+ if (incoming.signature !== `revision:${data.revision}`) throw new Error('Staged Blender mesh revision mismatch');
1541
+ prepared.set(id, incoming);
1542
+ continue;
1543
+ }
1506
1544
  // A REFERENCE, not geometry: the worker says this store has not been
1507
1545
  // written since the frame it last sent, so the resident
1508
1546
  // BufferGeometry stands. If it is not resident the worker's record
@@ -1567,6 +1605,10 @@ export class BlenderRuntimeView {
1567
1605
  }
1568
1606
  throw error;
1569
1607
  }
1608
+ if (staged) {
1609
+ for (const [id, value] of staged.meshes) if (!prepared.has(id)) value.geometry.dispose();
1610
+ this.staged = null;
1611
+ }
1570
1612
  if (this.frame?.session !== next.session) {
1571
1613
  if (this.frame) this.retiredSessions.add(this.frame.session);
1572
1614
  this.clear();
@@ -1929,7 +1971,7 @@ export class BlenderRuntimeView {
1929
1971
  // nested number arrays it arrived as cost ~15x their JSON (the blower's
1930
1972
  // last frame held 850 MB of main-thread heap this way, measured
1931
1973
  // 2026-09-13). Reuse checks compare the per-mesh signatures kept above.
1932
- this.frame = { ...next, meshes: {}, volumes: {} };
1974
+ this.frame = { ...next, meshes: {}, volumes: {}, images: {} };
1933
1975
  this.worldKey = JSON.stringify(next.world ?? null);
1934
1976
  // THE OVERLAYS, after the graph stands: the weight drawing is laid over
1935
1977
  // the presented mesh's own geometry and the bones over the armature
@@ -2182,6 +2224,8 @@ export class BlenderRuntimeView {
2182
2224
  }
2183
2225
 
2184
2226
  private clear() {
2227
+ for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
2228
+ this.staged = null;
2185
2229
  for (const light of this.lights.values()) {
2186
2230
  light.removeFromParent();
2187
2231
  light.dispose();
package/browser/worker.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { sendFrameValue } from './frame-stream.mts';
1
2
  /**
2
3
  * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
3
4
  * 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
@@ -39,7 +40,6 @@
39
40
 
40
41
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
41
42
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
- import { frameTransferBuffers } from './frame-transfer.mts';
43
43
  import { documentChunks } from './document-chunks.mts';
44
44
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
45
45
 
@@ -68,33 +68,20 @@ const pendingPresents = new Map<
68
68
  { resolve: (value: PresentAnswer) => void; reject: (error: Error) => void }
69
69
  >();
70
70
 
71
- function presentToTab(
72
- frame: unknown,
73
- description: unknown,
74
- capture?: CaptureRequest,
75
- ): Promise<PresentAnswer> {
76
- const id = ++presentId;
77
- // A drawn mesh's buffers move to the tab rather than being copied: the
78
- // frame is the tab's from here on.
79
- const transfer = frameTransferBuffers(frame);
80
- return new Promise((resolve, reject) => {
81
- pendingPresents.set(id, { resolve, reject });
82
- try {
83
- (self as unknown as Worker).postMessage(
84
- {
85
- op: 'present',
86
- id,
87
- frame,
88
- description,
89
- ...(capture ? { capture } : {}),
90
- } satisfies WorkerReply,
91
- transfer,
92
- );
93
- } catch (error) {
94
- pendingPresents.delete(id);
95
- reject(error);
96
- }
97
- });
71
+ async function streamToTab(value: unknown): Promise<PresentAnswer> {
72
+ return await sendFrameValue(value, chunk => {
73
+ const id = ++presentId;
74
+ return new Promise<PresentAnswer>((resolve, reject) => {
75
+ pendingPresents.set(id, { resolve, reject });
76
+ try {
77
+ (self as unknown as Worker).postMessage({ op: 'frame-stream', id, chunk } satisfies WorkerReply,
78
+ chunk.kind === 'bytes' ? [chunk.bytes.buffer as ArrayBuffer] : []);
79
+ } catch (error) { pendingPresents.delete(id); reject(error); }
80
+ });
81
+ }) as PresentAnswer;
82
+ }
83
+ function presentToTab(frame: unknown, description: unknown, capture?: CaptureRequest): Promise<PresentAnswer> {
84
+ return streamToTab({ op: 'present', frame, description, ...(capture ? { capture } : {}) });
98
85
  }
99
86
 
100
87
  let engine: BlenderEngine | null = null;
@@ -122,6 +109,8 @@ interface Copied {
122
109
  description: unknown;
123
110
  }
124
111
  interface PendingFrame {
112
+ session: string;
113
+ revision: number;
125
114
  columns: Map<number, Copied>;
126
115
  meshes: Map<string, Copied>;
127
116
  images: Map<string, Copied>;
@@ -166,7 +155,10 @@ function assemblePending(held: PendingFrame, frame: unknown): { typed: unknown;
166
155
  return piece[side];
167
156
  }
168
157
  const out: Record<string, unknown> = {};
169
- for (const [name, entry] of Object.entries(value)) out[name] = walk(entry, [...path, name]);
158
+ for (const [name, entry] of Object.entries(value)) {
159
+ const item = walk(entry, [...path, name]);
160
+ if (item !== undefined) out[name] = item;
161
+ }
170
162
  return out;
171
163
  };
172
164
  return walk(frame, []);
@@ -285,7 +277,9 @@ async function startBlender(project: string, document?: string): Promise<unknown
285
277
  // copied out of the heap as they come, because the next export call frees them; the tab
286
278
  // gets the whole frame at `present`.
287
279
  if (hold !== undefined) {
288
- pending = { columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
280
+ const identity = hold as { session: string; revision: number };
281
+ pending = { session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
282
+ await streamToTab({ op: 'stage', session: identity.session, revision: identity.revision });
289
283
  return {};
290
284
  }
291
285
  if (mesh !== undefined || image !== undefined) {
@@ -310,8 +304,14 @@ async function startBlender(project: string, document?: string): Promise<unknown
310
304
  description: await describeFrame(arena, piece),
311
305
  };
312
306
  }
313
- if (mesh !== undefined) pending.meshes.set(mesh, copied);
314
- else pending.images.set(image as string, copied);
307
+ await streamToTab({ op: 'stage', session: pending.session, revision: pending.revision,
308
+ ...(mesh !== undefined ? { mesh } : { image }), piece: copied.typed });
309
+ // Keep only the manifest and digest; the presenter already built this
310
+ // resource, and the next pull may overwrite the engine arena.
311
+ if (mesh !== undefined) {
312
+ const data = copied.typed as { revision: number };
313
+ pending.meshes.set(mesh, { typed: { revision: data.revision, unchanged: true }, description: copied.description });
314
+ } else pending.images.set(image as string, { typed: undefined, description: copied.description });
315
315
  return {};
316
316
  }
317
317
  let typed: unknown;
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.54",
5
+ "version": "0.1.56",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },