@womp/kakapo-sdk 0.1.0

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/dist/api.js ADDED
@@ -0,0 +1,671 @@
1
+ import { KakapoConnectionError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
2
+ import jsonpatch from "fast-json-patch";
3
+ import { applyJsonPatch, deepClone, defaultMaterialData, defaultNode, encodeTree, materialToWire, missingNode, nextId, nodeToWire, orderedChildren, parseSafePositiveId, parseJsonResult, toPublicScene, wireToMaterial, wireToNode, } from "./scene.js";
4
+ import { KakapoTransport } from "./transport.js";
5
+ import { createNodeHandle } from "./node-handle.js";
6
+ import { assertFont, assertId, assertMaterial, assertScene } from "./validation.js";
7
+ const SCENE_MUTATING_RPCS = new Set([
8
+ "scene_state_patch", "scene_state_set", "load_scene", "replace_scene", "undo", "redo", "convert_scene",
9
+ ]);
10
+ const SCREENSHOT_FRAME_PADDING = 2.4;
11
+ const ISOMETRIC_SCREENSHOT_FRAME_PADDING = 2;
12
+ function isBinaryFrame(message) {
13
+ return "payload" in message &&
14
+ message.payload instanceof Uint8Array &&
15
+ typeof message.width === "number" &&
16
+ typeof message.height === "number";
17
+ }
18
+ export class KakapoAPI {
19
+ transport;
20
+ rawScene;
21
+ cacheValid = false;
22
+ fonts;
23
+ transactionTail = Promise.resolve();
24
+ draftScene;
25
+ draftDirty = false;
26
+ rendererSyncRequired = false;
27
+ constructor(options = {}) {
28
+ this.transport = new KakapoTransport({
29
+ url: options.url ?? "ws://127.0.0.1:5502",
30
+ requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
31
+ connectTimeoutMs: options.connectTimeoutMs ?? 30_000,
32
+ });
33
+ }
34
+ get isConnected() { return this.transport.connected; }
35
+ async connect() {
36
+ await this.transport.connect();
37
+ try {
38
+ await this.ping();
39
+ await this.refreshScene();
40
+ }
41
+ catch (error) {
42
+ this.disconnect();
43
+ throw error;
44
+ }
45
+ }
46
+ disconnect() {
47
+ this.transport.disconnect();
48
+ this.cacheValid = false;
49
+ this.rawScene = undefined;
50
+ this.fonts = undefined;
51
+ }
52
+ async ping() {
53
+ const result = await this.call("ping");
54
+ if (result !== "pong") {
55
+ throw new KakapoConnectionError(`Kakapo ping returned '${String(result)}' instead of 'pong'.`, {
56
+ code: "INVALID_PING_RESPONSE",
57
+ operation: "ping",
58
+ received: result,
59
+ expected: "pong",
60
+ });
61
+ }
62
+ return "pong";
63
+ }
64
+ async rpc(method, params = []) {
65
+ const result = await this.call(method, params);
66
+ if (SCENE_MUTATING_RPCS.has(method)) {
67
+ this.cacheValid = false;
68
+ this.rendererSyncRequired = true;
69
+ }
70
+ return result;
71
+ }
72
+ newToken() { return this.transport.newToken(); }
73
+ waitForToken(token, timeoutMs) { return this.transport.waitForToken(token, timeoutMs); }
74
+ async refreshScene() {
75
+ const result = await this.call("scene_state_get");
76
+ const scene = parseJsonResult(result, "scene_state_get");
77
+ assertScene(scene, "refreshScene");
78
+ this.rawScene = scene;
79
+ this.cacheValid = true;
80
+ return this.getScene();
81
+ }
82
+ getScene() {
83
+ return deepClone(toPublicScene(this.requireScene("getScene")));
84
+ }
85
+ editScene(callback) {
86
+ const run = this.transactionTail.then(async () => {
87
+ await this.refreshScene();
88
+ const baseline = deepClone(this.requireScene("editScene"));
89
+ this.draftScene = deepClone(baseline);
90
+ this.draftDirty = false;
91
+ try {
92
+ const result = await callback(this.createSceneEdit());
93
+ const draft = this.requireDraft("editScene");
94
+ assertScene(draft, "editScene");
95
+ const operations = jsonpatch.compare(baseline, draft);
96
+ if (operations.length) {
97
+ await this.sendPatch(operations);
98
+ this.rendererSyncRequired = true;
99
+ }
100
+ this.rawScene = draft;
101
+ this.cacheValid = true;
102
+ return result;
103
+ }
104
+ catch (error) {
105
+ this.cacheValid = false;
106
+ try {
107
+ await this.refreshScene();
108
+ }
109
+ catch {
110
+ // Preserve the transaction error. The next operation will report stale cache.
111
+ }
112
+ throw error;
113
+ }
114
+ finally {
115
+ this.draftScene = undefined;
116
+ this.draftDirty = false;
117
+ }
118
+ });
119
+ this.transactionTail = run.then(() => undefined, () => undefined);
120
+ return run;
121
+ }
122
+ applyScenePatch(operations) {
123
+ const candidate = applyJsonPatch(this.requireDraft("applyScenePatch"), operations);
124
+ assertScene(candidate, "applyScenePatch");
125
+ this.draftScene = candidate;
126
+ this.draftDirty = true;
127
+ return this.getScene();
128
+ }
129
+ listNodes(options = {}) {
130
+ const scene = this.requireScene("listNodes");
131
+ return Object.keys(scene.nodes).map(Number).sort((a, b) => a - b).map((id) => wireToNode(scene, id)).filter((node) => (options.kind === undefined || node.kind === options.kind) && (options.parentId === undefined || node.parentId === options.parentId)).map(deepClone);
132
+ }
133
+ findNodes(options) {
134
+ const scene = this.requireScene("findNodes");
135
+ const nodes = this.listNodes({ kind: options.kind });
136
+ return nodes.filter((node) => {
137
+ if (options.name !== undefined && node.name !== options.name)
138
+ return false;
139
+ if (options.ancestorId === undefined)
140
+ return true;
141
+ let cursor = node.parentId;
142
+ while (cursor !== null) {
143
+ if (cursor === options.ancestorId)
144
+ return true;
145
+ cursor = wireToNode(scene, cursor).parentId;
146
+ }
147
+ return false;
148
+ });
149
+ }
150
+ getNode(id) {
151
+ assertId(id, "getNode", "nodeId", true);
152
+ return deepClone(wireToNode(this.requireScene("getNode"), id));
153
+ }
154
+ node(id, kind) {
155
+ return createNodeHandle({
156
+ readNode: (nodeId) => this.getNode(nodeId),
157
+ saveNode: (nodeId, changes) => this.saveNode(nodeId, changes),
158
+ }, id, kind);
159
+ }
160
+ getNodeName(id) { return this.getNode(id).name; }
161
+ getNodeParent(id) { const parentId = this.getNode(id).parentId; return parentId === null ? null : this.getNode(parentId); }
162
+ getNodeChildren(id) { const scene = this.requireScene("getNodeChildren"); this.getNode(id); return orderedChildren(scene, id).map((child) => this.getNode(child)); }
163
+ getNodeBoundingBox(id) { return this.getNodesBoundingBox([id]); }
164
+ async getNodesBoundingBox(ids) {
165
+ this.assertEngineReadAllowed("getNodesBoundingBox");
166
+ if (!ids.length)
167
+ throw new KakapoValidationError("getNodesBoundingBox requires at least one node ID.", { code: "EMPTY_NODE_IDS", operation: "getNodesBoundingBox", path: "ids", received: ids, expected: "non-empty node ID array" });
168
+ ids.forEach((id) => this.getNode(id));
169
+ await this.ensureRendererReady();
170
+ const result = parseJsonResult(await this.call("get_bounding_box", ids), "get_bounding_box");
171
+ const bounds = {
172
+ min: result.p0,
173
+ max: result.p1,
174
+ transform: { position: result.pos, rotation: result.rot, scale: result.size },
175
+ };
176
+ const values = [
177
+ bounds.min.x, bounds.min.y, bounds.min.z,
178
+ bounds.max.x, bounds.max.y, bounds.max.z,
179
+ ];
180
+ if (values.some((value) => !Number.isFinite(value)) ||
181
+ bounds.min.x > bounds.max.x || bounds.min.y > bounds.max.y || bounds.min.z > bounds.max.z) {
182
+ throw new KakapoValidationError("Kakapo did not return renderable bounds for the requested nodes.", {
183
+ code: "EMPTY_BOUNDING_BOX",
184
+ operation: "getNodesBoundingBox",
185
+ path: "ids",
186
+ received: ids,
187
+ expected: "visible renderer geometry",
188
+ hint: "Use a visible top-level Area containing Union geometry.",
189
+ });
190
+ }
191
+ return bounds;
192
+ }
193
+ async captureScreenshot(options) {
194
+ this.assertEngineReadAllowed("captureScreenshot");
195
+ const { targetId, timeoutMs } = options;
196
+ if (targetId !== undefined && options.targetIds !== undefined) {
197
+ throw new KakapoValidationError("captureScreenshot accepts targetId or targetIds, not both.", {
198
+ code: "AMBIGUOUS_SCREENSHOT_TARGET",
199
+ operation: "captureScreenshot",
200
+ expected: "one target selector",
201
+ });
202
+ }
203
+ const targetIds = options.targetIds ?? (targetId !== undefined
204
+ ? [targetId]
205
+ : this.listNodes({ parentId: 0 })
206
+ .filter((node) => node.kind !== "light" && (!("hidden" in node) || !node.hidden))
207
+ .map(({ id }) => id));
208
+ if (!targetIds.length) {
209
+ throw new KakapoValidationError("There is no visible scene geometry to capture.", {
210
+ code: "EMPTY_SCREENSHOT_TARGET",
211
+ operation: "captureScreenshot",
212
+ expected: "at least one visible node",
213
+ });
214
+ }
215
+ targetIds.forEach((id) => this.getNode(id));
216
+ const view = options.view ?? "isometric";
217
+ const bounds = await this.getNodesBoundingBox(targetIds);
218
+ const direction = this.screenshotDirection(view);
219
+ const framePadding = view === "isometric" ? ISOMETRIC_SCREENSHOT_FRAME_PADDING : SCREENSHOT_FRAME_PADDING;
220
+ const center = {
221
+ x: (bounds.min.x + bounds.max.x) * 0.5,
222
+ y: (bounds.min.y + bounds.max.y) * 0.5,
223
+ z: (bounds.min.z + bounds.max.z) * 0.5,
224
+ };
225
+ const size = {
226
+ x: (bounds.max.x - bounds.min.x) * framePadding,
227
+ y: (bounds.max.y - bounds.min.y) * framePadding,
228
+ z: (bounds.max.z - bounds.min.z) * framePadding,
229
+ };
230
+ const distance = Math.max(Math.hypot(size.x, size.y, size.z), 1);
231
+ const current = parseJsonResult(await this.call("get_cam"), "get_cam");
232
+ await this.call("set_camera", [{
233
+ ...current,
234
+ cent: center,
235
+ dir: { x: direction.x, y: direction.y, z: direction.z },
236
+ up: view === "top" || view === "bottom" ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 },
237
+ pos: {
238
+ x: center.x - direction.x * distance,
239
+ y: center.y - direction.y * distance,
240
+ z: center.z - direction.z * distance,
241
+ },
242
+ }, true]);
243
+ const framed = await this.call("center_cam", [center, size]);
244
+ if (this.rawScene)
245
+ this.rawScene.camera = { ...this.rawScene.camera, ...framed };
246
+ await new Promise((resolve) => setTimeout(resolve, 500));
247
+ const message = await this.captureRendererFrame(timeoutMs);
248
+ return {
249
+ data: message.payload,
250
+ mediaType: "image/jpeg",
251
+ width: message.width,
252
+ height: message.height,
253
+ view,
254
+ targetId: targetId ?? (targetIds.length === 1 ? targetIds[0] : 0),
255
+ };
256
+ }
257
+ async ensureRendererReady() {
258
+ if (!this.rendererSyncRequired)
259
+ return;
260
+ await new Promise((resolve) => setTimeout(resolve, 500));
261
+ await this.captureRendererFrame();
262
+ this.rendererSyncRequired = false;
263
+ }
264
+ async captureRendererFrame(timeoutMs) {
265
+ const token = this.newToken();
266
+ const waiting = this.waitForToken(token, timeoutMs);
267
+ await this.call("get_color_buffer", [[token]]);
268
+ const message = await waiting;
269
+ if (!isBinaryFrame(message)) {
270
+ const error = "error" in message ? String(message.error) : "Kakapo returned a non-binary renderer frame";
271
+ throw new KakapoRpcError("get_color_buffer", error);
272
+ }
273
+ return message;
274
+ }
275
+ screenshotDirection(view) {
276
+ // Kakapo's center_cam builds its framing basis with world Y as the up
277
+ // vector. Exact +/-Y directions make that basis singular, so match the
278
+ // editor's imperceptible axial offset for stable top and bottom framing.
279
+ const axialOffset = Math.cos(1.57);
280
+ const axialDirection = Math.sin(1.57);
281
+ const directions = {
282
+ front: { x: 1, y: 0, z: 0 },
283
+ back: { x: -1, y: 0, z: 0 },
284
+ left: { x: 0, y: 0, z: -1 },
285
+ right: { x: 0, y: 0, z: 1 },
286
+ top: { x: -axialOffset, y: -axialDirection, z: 0 },
287
+ bottom: { x: -axialOffset, y: axialDirection, z: 0 },
288
+ isometric: { x: -1 / Math.sqrt(3), y: -1 / Math.sqrt(3), z: -1 / Math.sqrt(3) },
289
+ };
290
+ return directions[view];
291
+ }
292
+ createNode(input) {
293
+ const scene = this.requireDraft("createNode");
294
+ assertId(input.parentId, "createNode", "parentId", true);
295
+ if (!scene.nodes[String(input.parentId)])
296
+ throw missingNode(input.parentId, "createNode");
297
+ const id = nextId(scene.nodes);
298
+ const initial = defaultNode(input.kind, id, input.name ?? "");
299
+ const node = this.mergeNode(initial, input.properties ?? {});
300
+ const children = this.treeArrays(scene);
301
+ const siblings = children[String(input.parentId)] ?? [];
302
+ const index = input.index ?? siblings.length;
303
+ if (!Number.isSafeInteger(index) || index < 0 || index > siblings.length)
304
+ throw new KakapoValidationError(`Child index ${index} is outside the valid range.`, { code: "INVALID_CHILD_INDEX", operation: "createNode", path: "index", received: index, expected: `integer from 0 through ${siblings.length}` });
305
+ siblings.splice(index, 0, id);
306
+ children[String(input.parentId)] = siblings;
307
+ scene.nodes[String(id)] = nodeToWire(node);
308
+ scene.tree = encodeTree(children);
309
+ scene.ext = this.extWithNode(scene, node);
310
+ this.draftDirty = true;
311
+ return deepClone(wireToNode(scene, id));
312
+ }
313
+ cloneNode(id, options = {}) {
314
+ const node = this.getNode(id);
315
+ if (id === 0)
316
+ throw this.rootError("cloneNode");
317
+ const { id: _id, kind: _kind, name: _name, parentId: _parentId, ...properties } = deepClone(node);
318
+ return this.createNode({ kind: node.kind, parentId: options.parentId ?? node.parentId, name: options.name ?? (node.name ? `${node.name} Copy` : ""), properties, index: options.index });
319
+ }
320
+ updateNode(id, changes) {
321
+ const scene = this.requireDraft("updateNode");
322
+ if (id === 0 && ("hidden" in changes || "transform" in changes))
323
+ throw this.rootError("updateNode");
324
+ const updated = this.mergeNode(wireToNode(scene, id), changes);
325
+ scene.nodes[String(id)] = nodeToWire(updated);
326
+ scene.ext = this.extWithNode(scene, updated);
327
+ this.draftDirty = true;
328
+ return deepClone(wireToNode(scene, id));
329
+ }
330
+ deleteNode(id, options = {}) {
331
+ const scene = this.requireDraft("deleteNode");
332
+ if (id === 0)
333
+ throw this.rootError("deleteNode");
334
+ const tree = this.treeArrays(scene);
335
+ const node = wireToNode(scene, id);
336
+ const toDelete = new Set([id]);
337
+ if (options.recursive !== false) {
338
+ const visit = (parent) => {
339
+ for (const child of tree[String(parent)] ?? []) {
340
+ toDelete.add(child);
341
+ visit(child);
342
+ }
343
+ };
344
+ visit(id);
345
+ }
346
+ if (node.parentId !== null) {
347
+ const siblings = tree[String(node.parentId)] ?? [];
348
+ const index = siblings.indexOf(id);
349
+ if (options.recursive === false)
350
+ siblings.splice(index, 1, ...(tree[String(id)] ?? []));
351
+ else
352
+ siblings.splice(index, 1);
353
+ tree[String(node.parentId)] = siblings;
354
+ }
355
+ for (const deleted of toDelete) {
356
+ delete scene.nodes[String(deleted)];
357
+ delete tree[String(deleted)];
358
+ if (scene.ext)
359
+ delete scene.ext[String(deleted)];
360
+ }
361
+ scene.tree = encodeTree(tree);
362
+ this.draftDirty = true;
363
+ return [...toDelete].sort((a, b) => a - b);
364
+ }
365
+ setNodeParent(id, parentId, index) {
366
+ const scene = this.requireDraft("setNodeParent");
367
+ if (id === 0)
368
+ throw this.rootError("setNodeParent");
369
+ const node = wireToNode(scene, id);
370
+ if (!scene.nodes[String(parentId)])
371
+ throw missingNode(parentId, "setNodeParent");
372
+ if (id === parentId)
373
+ throw new KakapoValidationError("A node cannot be its own parent.", { code: "TREE_CYCLE", operation: "setNodeParent", path: "parentId", received: parentId, expected: "different non-descendant node" });
374
+ let cursor = parentId;
375
+ while (cursor !== null) {
376
+ if (cursor === id)
377
+ throw new KakapoValidationError("A node cannot be parented below its descendant.", { code: "TREE_CYCLE", operation: "setNodeParent", path: "parentId", received: parentId, expected: "non-descendant node" });
378
+ cursor = wireToNode(scene, cursor).parentId;
379
+ }
380
+ const tree = this.treeArrays(scene);
381
+ if (node.parentId !== null)
382
+ tree[String(node.parentId)] = (tree[String(node.parentId)] ?? []).filter((child) => child !== id);
383
+ const siblings = tree[String(parentId)] ?? [];
384
+ const targetIndex = index ?? siblings.length;
385
+ if (!Number.isSafeInteger(targetIndex) || targetIndex < 0 || targetIndex > siblings.length)
386
+ throw new KakapoValidationError("Parent index is outside the child list.", { code: "INVALID_CHILD_INDEX", operation: "setNodeParent", path: "index", received: targetIndex, expected: `integer from 0 through ${siblings.length}` });
387
+ siblings.splice(targetIndex, 0, id);
388
+ tree[String(parentId)] = siblings;
389
+ scene.tree = encodeTree(tree);
390
+ this.draftDirty = true;
391
+ return deepClone(wireToNode(scene, id));
392
+ }
393
+ setNodeName(id, name) {
394
+ const scene = this.requireDraft("setNodeName");
395
+ wireToNode(scene, id);
396
+ if (typeof name !== "string" || name.includes("\0"))
397
+ throw new KakapoValidationError("Node name must be a string without null bytes.", { code: "INVALID_NODE_NAME", operation: "setNodeName", path: "name", received: name, expected: "string without null bytes" });
398
+ scene.ext = this.extWithName(scene, id, name);
399
+ this.draftDirty = true;
400
+ return deepClone(wireToNode(scene, id));
401
+ }
402
+ setNodeTransform(id, transform) { const current = this.getNode(id); return this.updateNode(id, { transform: { ...current.transform, ...transform } }); }
403
+ setNodePosition(id, position) { return this.setNodeTransform(id, { position }); }
404
+ setNodeRotation(id, rotation) { return this.setNodeTransform(id, { rotation }); }
405
+ setNodeScale(id, scale) { if (this.getNode(id).kind === "light")
406
+ throw new KakapoValidationError("Light nodes use a 2D light size instead of node scale.", { code: "UNSUPPORTED_NODE_PROPERTY", operation: "setNodeScale", path: "scale", received: scale, expected: "non-Light node", hint: "Use updateNode(id, { size: {x, y} }) for a Light." }); return this.setNodeTransform(id, { scale }); }
407
+ setNodeVisible(id, visible) { if (!("hidden" in this.getNode(id)))
408
+ throw this.unsupported("setNodeVisible", id, "visibility"); return this.updateNode(id, { hidden: !visible }); }
409
+ setNodePickable(id, pickable) { return this.updateNode(id, { pickable }); }
410
+ setNodeMaterial(id, materialId) { if (!("materialId" in this.getNode(id)))
411
+ throw this.unsupported("setNodeMaterial", id, "material"); return this.updateNode(id, { materialId }); }
412
+ setNodeOperation(id, operation) { if (!("operation" in this.getNode(id)))
413
+ throw this.unsupported("setNodeOperation", id, "CSG operation"); return this.updateNode(id, { operation }); }
414
+ setTextContent(id, text) { this.expectKind(id, "text", "setTextContent"); return this.updateNode(id, { text }); }
415
+ setTextFont(id, fontFamily, weight = 400, italic = false) { this.expectKind(id, "text", "setTextFont"); if (this.fonts)
416
+ assertFont(this.fonts, fontFamily, weight, italic, "setTextFont"); return this.updateNode(id, { fontFamily, weight, italic }); }
417
+ setSvgPaths(id, paths) { this.expectKind(id, "svg", "setSvgPaths"); return this.updateNode(id, { paths }); }
418
+ setMeshSource(id, mesh, meshIndex = 0) { this.expectKind(id, "mesh", "setMeshSource"); return this.updateNode(id, { mesh, meshIndex }); }
419
+ setFieldSource(id, field) { this.expectKind(id, "field", "setFieldSource"); return this.updateNode(id, { field }); }
420
+ setDecalSource(id, image) { this.expectKind(id, "decal", "setDecalSource"); return this.updateNode(id, { image }); }
421
+ setSocketTag(id, tag) { this.expectKind(id, "socket", "setSocketTag"); return this.updateNode(id, { tag }); }
422
+ setOpenScadScript(id, scriptId, params = {}) { this.expectKind(id, "openScad", "setOpenScadScript"); return this.updateNode(id, { scriptId, params }); }
423
+ listMaterials() {
424
+ const scene = this.requireScene("listMaterials");
425
+ return Object.entries(scene.storage.materials ?? {})
426
+ .flatMap(([key, raw]) => {
427
+ const id = parseSafePositiveId(key);
428
+ return id === undefined ? [] : [wireToMaterial(id, raw)];
429
+ })
430
+ .sort((a, b) => a.id - b.id)
431
+ .map(deepClone);
432
+ }
433
+ getMaterial(id) { assertId(id, "getMaterial", "materialId"); const raw = this.requireScene("getMaterial").storage.materials?.[String(id)]; if (!raw)
434
+ throw new KakapoValidationError(`Material ${id} does not exist.`, { code: "MATERIAL_NOT_FOUND", operation: "getMaterial", path: "materialId", received: id, expected: "existing material ID" }); return deepClone(wireToMaterial(id, raw)); }
435
+ createMaterial(data = {}, shaderIds = []) {
436
+ const scene = this.requireDraft("createMaterial");
437
+ const materials = scene.storage.materials ?? (scene.storage.materials = {});
438
+ const id = nextId(materials);
439
+ const material = { id, data: this.mergeMaterialData(defaultMaterialData(), data), shaderIds: [...shaderIds] };
440
+ assertMaterial(material.data, "createMaterial");
441
+ materials[String(id)] = materialToWire(material);
442
+ this.draftDirty = true;
443
+ return deepClone(material);
444
+ }
445
+ updateMaterial(id, data, shaderIds) {
446
+ const scene = this.requireDraft("updateMaterial");
447
+ const currentRaw = scene.storage.materials?.[String(id)];
448
+ if (!currentRaw)
449
+ throw this.materialMissing(id, "updateMaterial");
450
+ const current = wireToMaterial(id, currentRaw);
451
+ const updated = { id, data: this.mergeMaterialData(current.data, data), shaderIds: shaderIds ? [...shaderIds] : current.shaderIds };
452
+ assertMaterial(updated.data, "updateMaterial");
453
+ const next = materialToWire(updated);
454
+ scene.storage.materials[String(id)] = shaderIds === undefined
455
+ ? { ...currentRaw, data: next.data }
456
+ : next;
457
+ this.draftDirty = true;
458
+ return deepClone(updated);
459
+ }
460
+ deleteMaterial(id) {
461
+ const scene = this.requireDraft("deleteMaterial");
462
+ if (!scene.storage.materials?.[String(id)])
463
+ throw this.materialMissing(id, "deleteMaterial");
464
+ const references = Object.keys(scene.nodes).map(Number).map((nodeId) => wireToNode(scene, nodeId)).filter((node) => "materialId" in node && node.materialId === id).map((node) => node.id);
465
+ if (references.length)
466
+ throw new KakapoValidationError(`Material ${id} is still referenced by nodes ${references.join(", ")}.`, { code: "MATERIAL_IN_USE", operation: "deleteMaterial", path: "materialId", received: id, expected: "unreferenced material", hint: "Reassign or delete the listed nodes first.", details: { nodeIds: references } });
467
+ delete scene.storage.materials[String(id)];
468
+ this.draftDirty = true;
469
+ }
470
+ listOpenScadScripts() { const scripts = this.requireScene("listOpenScadScripts").storage.openscad?.scripts ?? {}; return Object.entries(scripts).map(([id, script]) => ({ id: Number(id), name: script.name ?? "OpenSCAD Script", source: script.source ?? "" })).sort((a, b) => a.id - b.id); }
471
+ getOpenScadScript(id) { assertId(id, "getOpenScadScript", "scriptId"); const script = this.requireScene("getOpenScadScript").storage.openscad?.scripts?.[String(id)]; if (!script)
472
+ throw this.scriptMissing(id, "getOpenScadScript"); return { id, name: script.name ?? "OpenSCAD Script", source: script.source ?? "" }; }
473
+ createOpenScadScript(name, source) {
474
+ const scene = this.requireDraft("createOpenScadScript");
475
+ const scripts = scene.storage.openscad?.scripts;
476
+ if (!scripts)
477
+ throw new KakapoValidationError("Scene has no OpenSCAD script storage.", { code: "MISSING_STORAGE", operation: "createOpenScadScript", path: "storage.openscad.scripts", expected: "OpenSCAD script object" });
478
+ const id = nextId(scripts);
479
+ scripts[String(id)] = { name, source };
480
+ this.draftDirty = true;
481
+ return { id, name, source };
482
+ }
483
+ updateOpenScadScript(id, changes) {
484
+ const scene = this.requireDraft("updateOpenScadScript");
485
+ const current = this.getOpenScadScript(id);
486
+ const updated = { ...current, ...changes };
487
+ if (!scene.storage.openscad?.scripts?.[String(id)])
488
+ throw this.scriptMissing(id, "updateOpenScadScript");
489
+ scene.storage.openscad.scripts[String(id)] = { name: updated.name, source: updated.source };
490
+ this.draftDirty = true;
491
+ return updated;
492
+ }
493
+ deleteOpenScadScript(id) {
494
+ const scene = this.requireDraft("deleteOpenScadScript");
495
+ if (!scene.storage.openscad?.scripts?.[String(id)])
496
+ throw this.scriptMissing(id, "deleteOpenScadScript");
497
+ const references = Object.keys(scene.nodes).map(Number).map((nodeId) => wireToNode(scene, nodeId)).filter((node) => node.kind === "openScad" && node.scriptId === id).map((node) => node.id);
498
+ if (references.length)
499
+ throw new KakapoValidationError(`OpenSCAD script ${id} is still referenced by nodes ${references.join(", ")}.`, { code: "SCRIPT_IN_USE", operation: "deleteOpenScadScript", path: "scriptId", received: id, expected: "unreferenced script", hint: "Reassign or delete the listed OpenSCAD nodes first.", details: { nodeIds: references } });
500
+ delete scene.storage.openscad.scripts[String(id)];
501
+ this.draftDirty = true;
502
+ }
503
+ async validateOpenScad(source, params = {}) {
504
+ if (typeof source !== "string")
505
+ throw new KakapoValidationError("OpenSCAD source must be a string.", { code: "INVALID_OPENSCAD_SOURCE", operation: "validateOpenScad", path: "source", received: source, expected: "string" });
506
+ return parseJsonResult(await this.call("openscad_validate", [{ source, params }]), "openscad_validate");
507
+ }
508
+ async listFonts() { if (!this.fonts)
509
+ this.fonts = parseJsonResult(await this.call("get_fonts"), "get_fonts"); return deepClone(this.fonts); }
510
+ async reloadFonts() { await this.call("reload_fonts"); this.fonts = undefined; }
511
+ async reloadMeshes() { await this.call("reload_meshes"); }
512
+ async reloadFields() { await this.call("reload_fields"); }
513
+ async reloadDecals() { await this.call("reload_decals"); }
514
+ async reloadTextures() { return this.call("reload_textures"); }
515
+ async getPendingResources() { return parseJsonResult(await this.call("get_pending_resources"), "get_pending_resources"); }
516
+ call(method, params = []) { return this.transport.rpc(method, params); }
517
+ async sendPatch(operations) {
518
+ const result = await this.call("scene_state_patch", [JSON.stringify(operations)]);
519
+ if (result !== undefined && result !== null && result !== "") {
520
+ throw new KakapoRpcError("scene_state_patch", String(result));
521
+ }
522
+ }
523
+ saveNode(id, changes) {
524
+ const current = this.getNode(id);
525
+ const { name, parentId, ...properties } = changes;
526
+ if (Object.keys(properties).length)
527
+ this.updateNode(id, properties);
528
+ if (name !== undefined && name !== current.name)
529
+ this.setNodeName(id, name);
530
+ if (parentId !== undefined && parentId !== current.parentId)
531
+ this.setNodeParent(id, parentId);
532
+ return this.getNode(id);
533
+ }
534
+ requireScene(operation) {
535
+ if (this.draftScene)
536
+ return this.draftScene;
537
+ if (!this.rawScene || !this.cacheValid)
538
+ throw new KakapoValidationError("The local scene cache is unavailable or stale.", { code: "STALE_SCENE", operation, expected: "fresh scene cache", hint: "Call connect() or refreshScene() before using high-level scene methods." });
539
+ return this.rawScene;
540
+ }
541
+ requireDraft(operation) {
542
+ if (this.draftScene)
543
+ return this.draftScene;
544
+ throw new KakapoValidationError(`${operation} must run inside editScene().`, {
545
+ code: "SCENE_EDIT_REQUIRED",
546
+ operation,
547
+ expected: "active editScene transaction",
548
+ hint: "Wrap scene mutations in await api.editScene((scene) => { ... }).",
549
+ });
550
+ }
551
+ assertEngineReadAllowed(operation) {
552
+ if (!this.draftDirty)
553
+ return;
554
+ throw new KakapoValidationError(`${operation} cannot run after draft mutations.`, {
555
+ code: "DIRTY_SCENE_ENGINE_READ",
556
+ operation,
557
+ expected: "engine read before the first mutation or after editScene commits",
558
+ hint: "Finish the transaction, then perform the engine-backed read.",
559
+ });
560
+ }
561
+ createSceneEdit() {
562
+ return {
563
+ getScene: () => this.getScene(),
564
+ listNodes: (options) => this.listNodes(options),
565
+ findNodes: (options) => this.findNodes(options),
566
+ getNode: (id) => this.getNode(id),
567
+ node: ((id, kind) => this.node(id, kind)),
568
+ getNodeName: (id) => this.getNodeName(id),
569
+ getNodeParent: (id) => this.getNodeParent(id),
570
+ getNodeChildren: (id) => this.getNodeChildren(id),
571
+ applyScenePatch: (operations) => this.applyScenePatch(operations),
572
+ createNode: (input) => this.createNode(input),
573
+ cloneNode: (id, options) => this.cloneNode(id, options),
574
+ updateNode: (id, changes) => this.updateNode(id, changes),
575
+ deleteNode: (id, options) => this.deleteNode(id, options),
576
+ setNodeParent: (id, parentId, index) => this.setNodeParent(id, parentId, index),
577
+ setNodeName: (id, name) => this.setNodeName(id, name),
578
+ setNodeTransform: (id, transform) => this.setNodeTransform(id, transform),
579
+ setNodePosition: (id, position) => this.setNodePosition(id, position),
580
+ setNodeRotation: (id, rotation) => this.setNodeRotation(id, rotation),
581
+ setNodeScale: (id, scale) => this.setNodeScale(id, scale),
582
+ setNodeVisible: (id, visible) => this.setNodeVisible(id, visible),
583
+ setNodePickable: (id, pickable) => this.setNodePickable(id, pickable),
584
+ setNodeMaterial: (id, materialId) => this.setNodeMaterial(id, materialId),
585
+ setNodeOperation: (id, operation) => this.setNodeOperation(id, operation),
586
+ setTextContent: (id, text) => this.setTextContent(id, text),
587
+ setTextFont: (id, family, weight, italic) => this.setTextFont(id, family, weight, italic),
588
+ setSvgPaths: (id, paths) => this.setSvgPaths(id, paths),
589
+ setMeshSource: (id, mesh, index) => this.setMeshSource(id, mesh, index),
590
+ setFieldSource: (id, field) => this.setFieldSource(id, field),
591
+ setDecalSource: (id, image) => this.setDecalSource(id, image),
592
+ setSocketTag: (id, tag) => this.setSocketTag(id, tag),
593
+ setOpenScadScript: (id, scriptId, params) => this.setOpenScadScript(id, scriptId, params),
594
+ listMaterials: () => this.listMaterials(),
595
+ getMaterial: (id) => this.getMaterial(id),
596
+ createMaterial: (data, shaderIds) => this.createMaterial(data, shaderIds),
597
+ updateMaterial: (id, data, shaderIds) => this.updateMaterial(id, data, shaderIds),
598
+ deleteMaterial: (id) => this.deleteMaterial(id),
599
+ listOpenScadScripts: () => this.listOpenScadScripts(),
600
+ getOpenScadScript: (id) => this.getOpenScadScript(id),
601
+ createOpenScadScript: (name, source) => this.createOpenScadScript(name, source),
602
+ updateOpenScadScript: (id, changes) => this.updateOpenScadScript(id, changes),
603
+ deleteOpenScadScript: (id) => this.deleteOpenScadScript(id),
604
+ };
605
+ }
606
+ treeArrays(scene) { return Object.fromEntries(Object.keys(scene.tree ?? {}).map((id) => [id, orderedChildren(scene, Number(id))])); }
607
+ extWithName(scene, id, name) {
608
+ const ext = deepClone(scene.ext ?? {});
609
+ const current = ext[String(id)];
610
+ ext[String(id)] = { ...(current && typeof current === "object" ? current : {}), name };
611
+ return ext;
612
+ }
613
+ extWithNode(scene, node) {
614
+ const ext = this.extWithName(scene, node.id, node.name);
615
+ if (!("mirrors" in node))
616
+ return ext;
617
+ const current = ext[String(node.id)];
618
+ ext[String(node.id)] = {
619
+ ...current,
620
+ ...Object.fromEntries(node.mirrors.map((plane, index) => [
621
+ `mirror_plane_ext_${index}`,
622
+ {
623
+ hideMirrorPlane: plane.hideMirrorPlane,
624
+ constraint: plane.constraint,
625
+ ...(plane.customRotation ? { customRotation: plane.customRotation } : {}),
626
+ },
627
+ ])),
628
+ };
629
+ return ext;
630
+ }
631
+ mergeNode(node, changes) {
632
+ const unsafe = changes;
633
+ if (unsafe.id !== undefined && unsafe.id !== node.id)
634
+ throw new KakapoValidationError("Node IDs cannot be changed.", { code: "IMMUTABLE_NODE_ID", operation: "updateNode", path: "id", received: unsafe.id, expected: String(node.id) });
635
+ if (unsafe.kind !== undefined && unsafe.kind !== node.kind)
636
+ throw new KakapoValidationError("Node kinds cannot be changed.", { code: "IMMUTABLE_NODE_KIND", operation: "updateNode", path: "kind", received: unsafe.kind, expected: node.kind });
637
+ const common = ["transform", "pickable"];
638
+ const byKind = {
639
+ primitive: ["hidden", "materialId", "operation", "blend", "color", "primitive", "round", "thickness", "inflation", "materialNeutralCutout", "mirrors"],
640
+ union: ["hidden", "operation", "blend", "resolution", "thickness", "inflation", "materialNeutralCutout"],
641
+ group: ["hidden"],
642
+ light: ["color", "power", "collimation", "lightType", "size", "textureFile"],
643
+ curve: ["hidden", "materialId", "operation", "blend", "primitive", "density", "roundness", "smoothing", "points", "materialNeutralCutout", "mirrors"],
644
+ text: ["hidden", "materialId", "operation", "blend", "text", "fontFamily", "weight", "italic", "round", "width", "wrap", "align", "spacing", "lineHeight", "materialNeutralCutout"],
645
+ svg: ["hidden", "materialId", "operation", "blend", "paths", "inflate", "outline", "outlineSize", "materialNeutralCutout"],
646
+ mesh: ["hidden", "materialId", "mesh", "meshIndex", "smoothNormals", "colorSampling"],
647
+ field: ["hidden", "materialId", "operation", "blend", "field", "inflation", "colorSampling", "materialNeutralCutout"],
648
+ decal: ["hidden", "materialId", "image", "global", "colorSampling"],
649
+ openScad: ["hidden", "materialId", "operation", "blend", "scriptId", "params", "enabled"],
650
+ socket: ["hidden", "tag"],
651
+ };
652
+ const allowed = new Set([...common, ...byKind[node.kind]]);
653
+ for (const key of Object.keys(unsafe)) {
654
+ if (!allowed.has(key))
655
+ throw new KakapoValidationError(`${node.kind} nodes do not support '${key}'.`, { code: "UNSUPPORTED_NODE_PROPERTY", operation: "updateNode", path: key, received: unsafe[key], expected: `one of: ${[...allowed].join(", ")}`, hint: "Use a property supported by this node kind or create the correct node kind." });
656
+ }
657
+ const transform = changes.transform ? { ...node.transform, ...changes.transform } : node.transform;
658
+ return { ...node, ...changes, id: node.id, kind: node.kind, parentId: node.parentId, transform };
659
+ }
660
+ mergeMaterialData(current, changes) {
661
+ return { ...current, ...changes, color: { ...current.color, ...changes.color }, secondaryColor: { ...current.secondaryColor, ...changes.secondaryColor }, subsurface: { ...current.subsurface, ...changes.subsurface } };
662
+ }
663
+ expectKind(id, kind, operation) { const node = this.getNode(id); if (node.kind !== kind)
664
+ throw new KakapoValidationError(`${operation} requires a ${kind} node, but node ${id} is ${node.kind}.`, { code: "WRONG_NODE_KIND", operation, path: "nodeId", received: { id, kind: node.kind }, expected: `${kind} node ID` }); }
665
+ unsupported(operation, id, property) { const node = this.getNode(id); return new KakapoValidationError(`${node.kind} node ${id} does not support ${property}.`, { code: "UNSUPPORTED_NODE_PROPERTY", operation, path: "nodeId", received: { id, kind: node.kind }, expected: `node kind supporting ${property}` }); }
666
+ rootError(operation) { return new KakapoValidationError("Scene root node 0 is immutable.", { code: "IMMUTABLE_ROOT", operation, path: "nodeId", received: 0, expected: "non-root node ID" }); }
667
+ materialMissing(id, operation) { return new KakapoValidationError(`Material ${id} does not exist.`, { code: "MATERIAL_NOT_FOUND", operation, path: "materialId", received: id, expected: "existing material ID" }); }
668
+ scriptMissing(id, operation) { return new KakapoValidationError(`OpenSCAD script ${id} does not exist.`, { code: "SCRIPT_NOT_FOUND", operation, path: "scriptId", received: id, expected: "existing OpenSCAD script ID" }); }
669
+ assertOpenScadValid(result, operation) { if (!result.ok)
670
+ throw new KakapoValidationError(`OpenSCAD validation failed: ${result.error}`, { code: "INVALID_OPENSCAD", operation, path: "source", expected: "compilable OpenSCAD source", hint: "Fix the compiler error and validate the source again.", details: { error: result.error, warnings: result.warnings } }); }
671
+ }