@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.
- package/browser/frame-stream.mts +71 -0
- package/browser/protocol.ts +2 -0
- package/browser/runtime.ts +38 -0
- package/browser/session-frame.mts +3 -4
- package/browser/three/blender-frame-columns.ts +3 -1
- package/browser/three/blender-runtime-geometry.ts +105 -0
- package/browser/three/blender-runtime-view.ts +46 -2
- package/browser/worker.ts +32 -32
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/browser/protocol.ts
CHANGED
|
@@ -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 }
|
package/browser/runtime.ts
CHANGED
|
@@ -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
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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;
|