@womp/kakapo-sdk 0.2.2 → 0.3.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/README.md CHANGED
@@ -1,87 +1,128 @@
1
- # @womp/kakapo-sdk
2
-
3
- Typed Node.js and browser SDK for manipulating a Kakapo scene through its JSON-RPC WebSocket.
4
-
5
- ```bash
6
- yarn install
7
- yarn build
8
- ```
9
-
10
- ```ts
11
- import { KakapoAPI } from "@womp/kakapo-sdk";
12
-
13
- const kakapo = new KakapoAPI();
14
- await kakapo.connect();
15
-
16
- const union = await kakapo.editScene((scene) => {
17
- const root = scene.createNode({
18
- kind: "union",
19
- parentId: 0,
20
- name: "Agent Model",
21
- });
22
-
23
- const box = scene.createNode({
24
- kind: "primitive",
25
- parentId: root.id,
26
- name: "Body",
27
- properties: {
28
- primitive: "box",
29
- transform: { scale: { x: 10, y: 4, z: 6 } },
30
- },
31
- });
32
-
33
- const red = scene.createMaterial({
34
- color: { x: 1, y: 0.05, z: 0.05 },
35
- roughness: 0.4,
36
- });
37
-
38
- const body = scene.node(box.id, "primitive");
39
- body.materialId = red.id;
40
- body.position = { x: 0, y: 4, z: 0 };
41
- body.round = 0.2;
42
- body.save();
43
- return root;
44
- });
45
-
46
- console.log(await kakapo.getNodeBoundingBox(union.id));
47
-
48
- kakapo.disconnect();
49
- ```
50
-
51
- The transport uses the browser's native `WebSocket` when bundled for the web and loads `ws`
52
- dynamically in Node.js.
53
-
54
- Applications that already own a renderer connection can pass a `KakapoAPITransport` as
55
- `new KakapoAPI({ transport })`. The adapter defines connection lifecycle, RPC, and token responses.
1
+ # @womp/kakapo-sdk
2
+
3
+ Typed Node.js and browser SDK for manipulating a Kakapo scene through its JSON-RPC WebSocket.
4
+
5
+ ```bash
6
+ yarn install
7
+ yarn build
8
+ ```
9
+
10
+ ```ts
11
+ import { KakapoAPI } from "@womp/kakapo-sdk";
12
+
13
+ const kakapo = new KakapoAPI();
14
+ await kakapo.connect();
15
+
16
+ const union = await kakapo.editScene((scene) => {
17
+ const root = scene.createNode({
18
+ kind: "union",
19
+ parentId: 0,
20
+ name: "Agent Model",
21
+ });
22
+
23
+ const box = scene.createNode({
24
+ kind: "primitive",
25
+ parentId: root.id,
26
+ name: "Body",
27
+ properties: {
28
+ primitive: "box",
29
+ transform: { scale: { x: 10, y: 4, z: 6 } },
30
+ },
31
+ });
32
+
33
+ const red = scene.createMaterial({
34
+ color: { x: 1, y: 0.05, z: 0.05 },
35
+ roughness: 0.4,
36
+ });
37
+
38
+ const body = scene.node(box.id, "primitive");
39
+ body.materialId = red.id;
40
+ body.position = { x: 0, y: 4, z: 0 };
41
+ body.round = 0.2;
42
+ body.save();
43
+ return root;
44
+ });
45
+
46
+ console.log(await kakapo.getNodeBoundingBox(union.id));
47
+
48
+ kakapo.disconnect();
49
+ ```
50
+
51
+ The transport uses the browser's native `WebSocket` when bundled for the web and loads `ws`
52
+ dynamically in Node.js.
53
+
54
+ Applications that already own a renderer connection can pass a `KakapoAPITransport` as
55
+ `new KakapoAPI({ transport })`. The adapter defines connection lifecycle, RPC, and token responses.
56
56
  `captureScreenshot({ view: "current" })` requests the renderer's JPEG without changing the camera
57
57
  or requiring a cached scene. Current-view captures cannot include `targetId` or `targetIds`.
58
-
59
- All public inputs are checked at runtime as well as by TypeScript. Invalid calls throw structured
60
- `KakapoValidationError` objects with a stable `code`, offending `path`, expected rule, and a
61
- correction `hint` intended for automated agents.
62
-
63
- `editScene()` refreshes once, exposes synchronous JSON reads and mutations, then validates and sends
64
- one RFC 6902 patch after the callback succeeds. Callback or commit failures discard the draft and
65
- refresh the authoritative scene. `node(id, kind)` returns a typed editable handle; `save()` applies
66
- its staged properties synchronously to the transaction draft.
67
-
68
- Engine-backed reads such as bounding boxes and screenshots remain asynchronous. Run them before the
69
- first draft mutation or after `editScene()` commits.
70
-
58
+ Framed captures restore the camera they replaced after capture. If another client moves the camera
59
+ while capture is pending, the SDK detects that change and preserves the newer camera instead.
60
+
61
+ All public inputs are checked at runtime as well as by TypeScript. Invalid calls throw structured
62
+ `KakapoValidationError` objects with a stable `code`, offending `path`, expected rule, and a
63
+ correction `hint` intended for automated agents.
64
+
65
+ `editScene()` refreshes once, exposes synchronous JSON reads and mutations, then validates and sends
66
+ one RFC 6902 patch after the callback succeeds. Callback or commit failures discard the draft and
67
+ refresh the authoritative scene. `node(id, kind)` returns a typed editable handle; `save()` applies
68
+ its staged properties synchronously to the transaction draft.
69
+
70
+ Use the same SDK edit methods in browser and standalone hosts. Hosts coordinate connection ownership,
71
+ permissions, and local editor activity; the SDK reads the canonical scene and submits the change.
72
+ `getSceneSnapshotRevision(snapshot)` computes the same local fingerprint as `api.getSceneRevision()`;
73
+ neither fingerprint establishes a server-side atomic commit guarantee.
74
+
75
+ For incremental curve edits, use `SceneEdit.updateCurvePoint(id, index, changes)`,
76
+ `cloneCurvePoint(id, sourceIndex, insertionIndex)`, and `deleteCurvePoint(id, index)`.
77
+ These retain native per-point fields unknown to this SDK. Replacing a node's entire
78
+ typed `points` array replaces that data; use the granular operations when editing existing points.
79
+
80
+ Engine-backed reads such as bounding boxes and screenshots remain asynchronous. Run them before the
81
+ first draft mutation or after `editScene()` commits.
82
+
71
83
  Each `editScene()` refreshes before creating its private draft. Top-level edits are serialized per
72
- client. Calling a scene-mutating method through raw `rpc()` deliberately marks the cache stale.
73
-
74
- Mesh, field, and decal names are syntax-checked, but their existence cannot be enumerated by the
75
- current engine RPC surface. Font family/weight/style combinations are checked against `listFonts()`.
76
-
77
- ## Verification
78
-
79
- ```bash
80
- yarn test # fake WebSocket protocol and API behavior
81
- yarn test:live # launches ../kakapo/kakapo_app.exe and runs named live tests per API method
82
- yarn test:all # runs both suites
83
- ```
84
-
85
- The live suite starts Kakapo once, runs methods sequentially, and reports failures as names such as
86
- `live:setNodeParent` or `live:createNode:mesh`. Mutations refresh the authoritative engine scene
87
- before asserting their result. See [TEST_MATRIX.md](./TEST_MATRIX.md) for the exact unit/live mapping.
84
+ client. Changed drafts reread canonical state before submission and reject a changed baseline;
85
+ camera movement alone does not invalidate an ordinary scene edit. This read does not eliminate
86
+ a cross-client race between validation and submission through a backend wrapper. Commit patches add
87
+ RFC 6902 `test` guards only for the wrapper partitions the draft changes: `/ext` and the remaining
88
+ scene. This avoids turning a geometry-only edit into an unnecessary extension operation. Adding a
89
+ missing `/ext` root is rejected because the existing wrapper cannot guard that cross-partition change;
90
+ refresh or migrate the scene before retrying. Calling a scene-mutating method through raw `rpc()`
91
+ deliberately marks the cache stale.
92
+
93
+ `editSceneAndSave(callback)` submits the standard `scene_state_patch` command and, after
94
+ acknowledgement, calls `save_scene` once on the same connection. No new Kakapo command is required.
95
+ The result preserves the callback value separately from save status: `acknowledged` includes the
96
+ returned filename, `unknown` includes an error, and `not_needed` means the draft did not change.
97
+ A save acknowledgement does not prove a new durable snapshot: some endpoints return an existing
98
+ filename. Verify persistence through the owning service. Never repeat the edit to recover saving.
99
+ The backend wrapper may apply geometry before extension processing fails; patch errors and lost
100
+ replies therefore have an `unknown` outcome unless rejection before execution is proven. SDK state
101
+ is refreshed after submission, and no mutation is automatically replayed.
102
+
103
+ `getSceneRevision()` fingerprints the cached native scene or active draft, including renderer fields
104
+ not exposed by `getScene()`. Camera movement and object-key ordering do not affect it. Refresh before
105
+ inspection; this fingerprint is not a server-issued revision or an atomic commit guarantee through a wrapper.
106
+
107
+ Mesh, field, and decal names are syntax-checked, but their existence cannot be enumerated by the
108
+ current engine RPC surface. Font family/weight/style combinations are checked against `listFonts()`.
109
+
110
+ ## Verification
111
+
112
+ ```bash
113
+ yarn test # fake WebSocket protocol and API behavior
114
+ yarn test:live # launches the sibling Kakapo build and runs named live tests per API method
115
+ yarn test:all # runs both suites
116
+ ```
117
+
118
+ The live suite starts Kakapo once, runs methods sequentially, and reports failures as names such as
119
+ `live:setNodeParent` or `live:createNode:mesh`. Mutations refresh the authoritative engine scene
120
+ before asserting their result. See [TEST_MATRIX.md](./TEST_MATRIX.md) for the exact unit/live mapping.
121
+
122
+ Build the sibling engine before running live tests. On Windows the default executable is
123
+ `../kakapo/build/code/RelWithDebInfo/kakapo_app.exe`; on other platforms it is
124
+ `../kakapo/build/code/kakapo_app`. Set `KAKAPO_BINARY` to test another build explicitly.
125
+
126
+ ### Importing catalog materials
127
+
128
+ Inside `editScene`, `scene.importMaterial({ data, shaders })` imports native renderer material data and shader slots. `data` retains opaque fields and constants. Each non-null shader gets a new scene-local ID; null slots remain zero. The returned material can be assigned with `setNodeMaterial`. Material and shader resources share the draft's commit and rollback boundary. The host supplies the catalog data; the SDK does not fetch account or asset services.
package/dist/api.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import { type NodeHandle } from "./node-handle.js";
2
- import type { BoundingBox, CaptureScreenshotOptions, CreateNodeInput, DeleteNodeOptions, FindNodeOptions, FontFamily, JsonPatchOperation, JsonValue, KakapoAPIOptions, KakapoNode, NodeKind, ListNodeOptions, Material, MaterialData, NodeUpdate, OpenScadScript, OpenScadValidationResult, PendingResources, PrimitiveOperation, Scene, ScreenshotImage, SvgPath, TokenMessage, Transform, Vec3 } from "./types.js";
2
+ import type { BoundingBox, CaptureScreenshotOptions, CreateNodeInput, CurvePoint, DeleteNodeOptions, FindNodeOptions, FontFamily, JsonPatchOperation, JsonValue, KakapoAPIOptions, KakapoNode, NodeKind, ListNodeOptions, Material, MaterialData, MaterialResource, NodeUpdate, OpenScadScript, OpenScadValidationResult, PendingResources, PrimitiveOperation, Scene, ScreenshotImage, SvgPath, TokenMessage, Transform, Vec3 } from "./types.js";
3
+ export type SceneSaveResult = {
4
+ status: "not_needed";
5
+ } | {
6
+ status: "acknowledged";
7
+ filename: string;
8
+ } | {
9
+ status: "unknown";
10
+ error: string;
11
+ } | {
12
+ status: "failed";
13
+ error: string;
14
+ };
3
15
  export interface SceneEdit {
4
16
  getScene(): Scene;
5
17
  listNodes(options?: ListNodeOptions): KakapoNode[];
@@ -15,6 +27,8 @@ export interface SceneEdit {
15
27
  applyScenePatch(operations: JsonPatchOperation[]): Scene;
16
28
  getSceneExt(): Record<string, JsonValue>;
17
29
  patchSceneExt(operations: JsonPatchOperation[]): Scene;
30
+ getSceneStorage(): Record<string, JsonValue>;
31
+ patchSceneStorage(operations: JsonPatchOperation[]): Scene;
18
32
  getSceneProperties(): Record<string, JsonValue>;
19
33
  patchSceneProperties(operations: JsonPatchOperation[]): Scene;
20
34
  createNode(input: CreateNodeInput): KakapoNode;
@@ -37,6 +51,9 @@ export interface SceneEdit {
37
51
  setNodeOperation(id: number, operation: PrimitiveOperation): KakapoNode;
38
52
  setTextContent(id: number, text: string): KakapoNode;
39
53
  setTextFont(id: number, fontFamily: string, weight?: number, italic?: boolean): KakapoNode;
54
+ updateCurvePoint(id: number, index: number, changes: Partial<CurvePoint>): void;
55
+ cloneCurvePoint(id: number, sourceIndex: number, index: number): void;
56
+ deleteCurvePoint(id: number, index: number): void;
40
57
  setSvgPaths(id: number, paths: SvgPath[]): KakapoNode;
41
58
  setMeshSource(id: number, mesh: string, meshIndex?: number): KakapoNode;
42
59
  setFieldSource(id: number, field: string): KakapoNode;
@@ -46,6 +63,7 @@ export interface SceneEdit {
46
63
  listMaterials(): Material[];
47
64
  getMaterial(id: number): Material;
48
65
  createMaterial(data?: Partial<MaterialData>, shaderIds?: number[]): Material;
66
+ importMaterial(resource: MaterialResource): Material;
49
67
  updateMaterial(id: number, data: Partial<MaterialData>, shaderIds?: number[]): Material;
50
68
  deleteMaterial(id: number): void;
51
69
  listOpenScadScripts(): OpenScadScript[];
@@ -76,16 +94,33 @@ export declare class KakapoAPI {
76
94
  waitForToken(token: string, timeoutMs?: number): Promise<TokenMessage>;
77
95
  refreshScene(): Promise<Scene>;
78
96
  getScene(): Scene;
97
+ /** Fingerprint of the cached scene or active draft, including unknown native fields.
98
+ * Camera movement is excluded. This is not a server-issued revision or commit fence.
99
+ */
100
+ getSceneRevision(): Promise<string>;
101
+ /** Wait until the renderer has produced a frame after this API's latest edit. */
102
+ waitForRenderer(): Promise<void>;
79
103
  editScene<T>(callback: (scene: SceneEdit) => T | Promise<T>): Promise<T>;
104
+ editSceneAndSave<T>(callback: (scene: SceneEdit) => T | Promise<T>): Promise<{
105
+ value: T;
106
+ save: SceneSaveResult;
107
+ }>;
108
+ private prepareDraft;
109
+ private runSceneEdit;
80
110
  private applyScenePatch;
81
111
  /** The scene's consumer-owned extension bag. Kakapo does not interpret it. */
82
112
  getSceneExt(): Record<string, JsonValue>;
83
113
  /** Apply JSON Patch operations rooted at the scene's `ext` bag. */
84
114
  patchSceneExt(operations: JsonPatchOperation[]): Scene;
115
+ /** Consumer-owned resources stored in the scene file. */
116
+ getSceneStorage(): Record<string, JsonValue>;
117
+ /** Apply JSON Patch operations rooted at the scene's `storage` bag. */
118
+ patchSceneStorage(operations: JsonPatchOperation[]): Scene;
85
119
  /** The scene's renderer/environment properties (HDR, background, and similar). */
86
120
  getSceneProperties(): Record<string, JsonValue>;
87
121
  /** Apply JSON Patch operations rooted at the scene's `properties` bag. */
88
122
  patchSceneProperties(operations: JsonPatchOperation[]): Scene;
123
+ private patchSceneBag;
89
124
  listNodes(options?: ListNodeOptions): KakapoNode[];
90
125
  findNodes(options: FindNodeOptions): KakapoNode[];
91
126
  getNode(id: number): KakapoNode;
@@ -105,6 +140,10 @@ export declare class KakapoAPI {
105
140
  private createNode;
106
141
  private cloneNode;
107
142
  private updateNode;
143
+ private curvePointDraft;
144
+ private updateCurvePoint;
145
+ private cloneCurvePoint;
146
+ private deleteCurvePoint;
108
147
  private deleteNode;
109
148
  private setNodeParent;
110
149
  private setNodeName;
@@ -127,6 +166,7 @@ export declare class KakapoAPI {
127
166
  listMaterials(): Material[];
128
167
  getMaterial(id: number): Material;
129
168
  private createMaterial;
169
+ private importMaterial;
130
170
  private updateMaterial;
131
171
  private deleteMaterial;
132
172
  listOpenScadScripts(): OpenScadScript[];
package/dist/api.js CHANGED
@@ -1,9 +1,9 @@
1
- import { KakapoConnectionError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
1
+ import { KakapoConnectionError, KakapoRpcError, KakapoSceneCommitError, KakapoValidationError, } from "./errors.js";
2
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";
3
+ import { applyJsonPatch, deepClone, defaultMaterialData, defaultNode, encodeTree, encodeCurvePoint, escapePointer, getSceneSnapshotRevision, materialToWire, missingNode, nextId, nodeToWire, orderedChildren, parseSafePositiveId, parseJsonResult, toPublicScene, wireToMaterial, wireToNode, } from "./scene.js";
4
4
  import { KakapoTransport } from "./transport.js";
5
5
  import { createNodeHandle } from "./node-handle.js";
6
- import { assertFont, assertId, assertMaterial, assertScene } from "./validation.js";
6
+ import { assertFont, assertId, assertMaterial, assertNode, assertScene } from "./validation.js";
7
7
  const SCENE_MUTATING_RPCS = new Set([
8
8
  "scene_state_patch", "scene_state_set", "load_scene", "replace_scene", "undo", "redo", "convert_scene",
9
9
  ]);
@@ -33,12 +33,14 @@ export class KakapoAPI {
33
33
  }
34
34
  get isConnected() { return this.transport.connected; }
35
35
  async connect() {
36
- await this.transport.connect();
37
36
  try {
37
+ await this.transport.connect();
38
38
  await this.ping();
39
39
  await this.refreshScene();
40
40
  }
41
41
  catch (error) {
42
+ if (error instanceof KakapoConnectionError && error.code === "CONNECTION_IN_PROGRESS")
43
+ throw error;
42
44
  this.disconnect();
43
45
  throw error;
44
46
  }
@@ -82,24 +84,107 @@ export class KakapoAPI {
82
84
  getScene() {
83
85
  return deepClone(toPublicScene(this.requireScene("getScene")));
84
86
  }
87
+ /** Fingerprint of the cached scene or active draft, including unknown native fields.
88
+ * Camera movement is excluded. This is not a server-issued revision or commit fence.
89
+ */
90
+ async getSceneRevision() {
91
+ return getSceneSnapshotRevision(this.requireScene("getSceneRevision"));
92
+ }
93
+ /** Wait until the renderer has produced a frame after this API's latest edit. */
94
+ async waitForRenderer() {
95
+ await this.ensureRendererReady();
96
+ }
85
97
  editScene(callback) {
98
+ return this.runSceneEdit(callback, false).then(({ value }) => value);
99
+ }
100
+ editSceneAndSave(callback) {
101
+ return this.runSceneEdit(callback, true);
102
+ }
103
+ async prepareDraft(baseline, callback) {
104
+ this.draftScene = deepClone(baseline);
105
+ this.draftDirty = false;
106
+ let active = true;
107
+ const edit = this.createSceneEdit(() => {
108
+ if (active)
109
+ return;
110
+ throw new KakapoValidationError("The scene draft has expired.", {
111
+ code: "EXPIRED_SCENE_DRAFT",
112
+ operation: "editScene",
113
+ expected: "a draft used only inside its own callback",
114
+ });
115
+ });
116
+ let value;
117
+ try {
118
+ value = await callback(edit);
119
+ }
120
+ finally {
121
+ active = false;
122
+ }
123
+ const scene = this.requireDraft("editScene");
124
+ assertScene(scene, "editScene");
125
+ const operations = jsonpatch.compare(baseline, scene);
126
+ return { value, operations, scene };
127
+ }
128
+ runSceneEdit(callback, saveAfterPatch) {
86
129
  const run = this.transactionTail.then(async () => {
87
130
  await this.refreshScene();
88
131
  const baseline = deepClone(this.requireScene("editScene"));
89
- this.draftScene = deepClone(baseline);
90
- this.draftDirty = false;
91
132
  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);
133
+ const { value, operations, scene } = await this.prepareDraft(baseline, callback);
134
+ let save = { status: "not_needed" };
96
135
  if (operations.length) {
97
- await this.sendPatch(operations);
136
+ const addsRootField = operations.some((operation) => operation.op === "add" && operation.path.split("/").length === 2);
137
+ const addsExtRoot = operations.some((operation) => operation.op === "add" && operation.path === "/ext");
138
+ if (addsExtRoot) {
139
+ throw new KakapoValidationError("Cannot safely add a missing scene extension root through a partitioned endpoint.", {
140
+ code: "UNSUPPORTED_PATCH_GUARD",
141
+ operation: "editScene",
142
+ path: "/ext",
143
+ expected: "an existing ext root",
144
+ });
145
+ }
146
+ const changesCamera = operations.some((operation) => operation.path === "/camera" || operation.path.startsWith("/camera/"));
147
+ const changesExt = operations.some((operation) => operation.path === "/ext" || operation.path.startsWith("/ext/"));
148
+ const changesNonExt = operations.some((operation) => operation.path !== "/ext" && !operation.path.startsWith("/ext/"));
149
+ const latest = parseJsonResult(await this.call("scene_state_get"), "scene_state_get");
150
+ assertScene(latest, "editScene");
151
+ const stale = jsonpatch.compare(baseline, latest).some((operation) => changesCamera || (operation.path !== "/camera" && !operation.path.startsWith("/camera/")));
152
+ if (stale)
153
+ throw new KakapoSceneCommitError("rolled_back", new Error("Scene changed during preparation. Read it again before editing."));
154
+ // Raw renderers evaluate these together; wrappers may partition the patch.
155
+ // A failed response cannot establish that no earlier group was applied.
156
+ const guards = [];
157
+ if (changesNonExt) {
158
+ if (addsRootField)
159
+ guards.push({ op: "test", path: "", value: baseline });
160
+ else
161
+ guards.push(...Object.entries(baseline)
162
+ .filter(([key]) => key !== "ext" && (key !== "camera" || changesCamera))
163
+ .map(([key, value]) => ({
164
+ op: "test",
165
+ path: `/${escapePointer(key)}`,
166
+ value: value,
167
+ })));
168
+ }
169
+ if (changesExt)
170
+ guards.push({ op: "test", path: "/ext", value: baseline.ext });
171
+ save = await this.sendPatch([...guards, ...operations], saveAfterPatch);
98
172
  this.rendererSyncRequired = true;
99
173
  }
100
- this.rawScene = draft;
101
- this.cacheValid = true;
102
- return result;
174
+ if (operations.length) {
175
+ this.cacheValid = false;
176
+ try {
177
+ await this.refreshScene();
178
+ }
179
+ catch {
180
+ // Keep the acknowledged result; the next read must refresh the invalid cache.
181
+ }
182
+ }
183
+ else {
184
+ this.rawScene = scene;
185
+ this.cacheValid = true;
186
+ }
187
+ return { value, save };
103
188
  }
104
189
  catch (error) {
105
190
  this.cacheValid = false;
@@ -133,7 +218,15 @@ export class KakapoAPI {
133
218
  }
134
219
  /** Apply JSON Patch operations rooted at the scene's `ext` bag. */
135
220
  patchSceneExt(operations) {
136
- return this.applyScenePatch(operations.map((op) => ({ ...op, path: `/ext${op.path}` })));
221
+ return this.patchSceneBag("ext", operations);
222
+ }
223
+ /** Consumer-owned resources stored in the scene file. */
224
+ getSceneStorage() {
225
+ return deepClone(this.requireScene("getSceneStorage").storage);
226
+ }
227
+ /** Apply JSON Patch operations rooted at the scene's `storage` bag. */
228
+ patchSceneStorage(operations) {
229
+ return this.patchSceneBag("storage", operations);
137
230
  }
138
231
  /** The scene's renderer/environment properties (HDR, background, and similar). */
139
232
  getSceneProperties() {
@@ -142,7 +235,24 @@ export class KakapoAPI {
142
235
  }
143
236
  /** Apply JSON Patch operations rooted at the scene's `properties` bag. */
144
237
  patchSceneProperties(operations) {
145
- return this.applyScenePatch(operations.map((op) => ({ ...op, path: `/properties${op.path}` })));
238
+ return this.patchSceneBag("properties", operations);
239
+ }
240
+ patchSceneBag(key, operations) {
241
+ const draft = this.requireDraft("patchSceneBag");
242
+ if (operations.length === 0)
243
+ return this.getScene();
244
+ const prefix = `/${key}`;
245
+ const patch = draft[key] === undefined
246
+ ? [{ op: "add", path: prefix, value: {} }]
247
+ : [];
248
+ for (const operation of operations) {
249
+ patch.push({
250
+ ...operation,
251
+ path: `${prefix}${operation.path}`,
252
+ ...("from" in operation ? { from: `${prefix}${operation.from}` } : {}),
253
+ });
254
+ }
255
+ return this.applyScenePatch(patch);
146
256
  }
147
257
  listNodes(options = {}) {
148
258
  const scene = this.requireScene("listNodes");
@@ -257,23 +367,35 @@ export class KakapoAPI {
257
367
  z: (bounds.max.z - bounds.min.z) * framePadding,
258
368
  };
259
369
  const distance = Math.max(Math.hypot(size.x, size.y, size.z), 1);
260
- const current = parseJsonResult(await this.call("get_cam"), "get_cam");
261
- await this.call("set_camera", [{
262
- ...current,
263
- cent: center,
264
- dir: { x: direction.x, y: direction.y, z: direction.z },
265
- up: view === "top" || view === "bottom" ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 },
266
- pos: {
267
- x: center.x - direction.x * distance,
268
- y: center.y - direction.y * distance,
269
- z: center.z - direction.z * distance,
270
- },
271
- }, true]);
272
- const framed = await this.call("center_cam", [center, size]);
273
- if (this.rawScene)
274
- this.rawScene.camera = { ...this.rawScene.camera, ...framed };
275
- await new Promise((resolve) => setTimeout(resolve, 500));
276
- const message = await this.captureRendererFrame(timeoutMs);
370
+ const originalCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
371
+ let framedCamera;
372
+ let message;
373
+ try {
374
+ await this.call("set_camera", [{
375
+ ...originalCamera,
376
+ cent: center,
377
+ dir: { x: direction.x, y: direction.y, z: direction.z },
378
+ up: view === "top" || view === "bottom" ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 },
379
+ pos: {
380
+ x: center.x - direction.x * distance,
381
+ y: center.y - direction.y * distance,
382
+ z: center.z - direction.z * distance,
383
+ },
384
+ }, true]);
385
+ const framed = await this.call("center_cam", [center, size]);
386
+ if (this.rawScene)
387
+ this.rawScene.camera = { ...this.rawScene.camera, ...framed };
388
+ framedCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
389
+ await new Promise((resolve) => setTimeout(resolve, 500));
390
+ message = await this.captureRendererFrame(timeoutMs);
391
+ }
392
+ finally {
393
+ if (framedCamera !== undefined) {
394
+ const latestCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
395
+ if (jsonpatch.compare(framedCamera, latestCamera).length === 0)
396
+ await this.call("set_camera", [originalCamera, true]);
397
+ }
398
+ }
277
399
  return {
278
400
  data: message.payload,
279
401
  mediaType: "image/jpeg",
@@ -349,12 +471,77 @@ export class KakapoAPI {
349
471
  const scene = this.requireDraft("updateNode");
350
472
  if (id === 0 && ("hidden" in changes || "transform" in changes))
351
473
  throw this.rootError("updateNode");
352
- const updated = this.mergeNode(wireToNode(scene, id), changes);
353
- scene.nodes[String(id)] = nodeToWire(updated);
474
+ const current = wireToNode(scene, id);
475
+ const updated = this.mergeNode(current, changes);
476
+ assertNode(updated, scene, "updateNode");
477
+ const before = nodeToWire(current);
478
+ const after = nodeToWire(updated);
479
+ // Only replace fields changed through the typed API. Re-encoding the whole
480
+ // node discards renderer fields this SDK version does not know about.
481
+ const raw = deepClone(scene.nodes[String(id)]);
482
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
483
+ if (JSON.stringify(before[key]) === JSON.stringify(after[key]))
484
+ continue;
485
+ if (Object.hasOwn(after, key))
486
+ raw[key] = after[key];
487
+ else
488
+ delete raw[key];
489
+ }
490
+ scene.nodes[String(id)] = raw;
354
491
  scene.ext = this.extWithNode(scene, updated);
355
492
  this.draftDirty = true;
356
493
  return deepClone(wireToNode(scene, id));
357
494
  }
495
+ curvePointDraft(id, index, operation) {
496
+ const scene = this.requireDraft(operation);
497
+ const node = this.getNode(id);
498
+ if (node.kind !== "curve")
499
+ throw this.unsupported(operation, id, "curve points");
500
+ if (!Number.isInteger(index) || index < 0 || index >= node.points.length)
501
+ throw new KakapoValidationError("Curve point index is out of range.", {
502
+ code: "INVALID_CURVE_POINT_INDEX", operation, received: index,
503
+ expected: `integer from 0 through ${node.points.length - 1}`,
504
+ });
505
+ return { scene, node, points: scene.nodes[String(id)].points };
506
+ }
507
+ updateCurvePoint(id, index, changes) {
508
+ const { scene, node, points } = this.curvePointDraft(id, index, "updateCurvePoint");
509
+ const previous = node.points[index];
510
+ const next = { ...previous, ...deepClone(changes) };
511
+ node.points[index] = next;
512
+ assertNode(node, scene, "updateCurvePoint");
513
+ const before = encodeCurvePoint(previous);
514
+ const after = encodeCurvePoint(next);
515
+ // Edit only requested typed fields; retain opaque native data on this point.
516
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
517
+ if (JSON.stringify(before[key]) === JSON.stringify(after[key]))
518
+ continue;
519
+ if (Object.hasOwn(after, key))
520
+ points[index][key] = after[key];
521
+ else
522
+ delete points[index][key];
523
+ }
524
+ this.draftDirty = true;
525
+ }
526
+ cloneCurvePoint(id, sourceIndex, index) {
527
+ const { points } = this.curvePointDraft(id, sourceIndex, "cloneCurvePoint");
528
+ if (!Number.isInteger(index) || index < 0 || index > points.length)
529
+ throw new KakapoValidationError("Curve insertion index is out of range.", {
530
+ code: "INVALID_CURVE_POINT_INDEX", operation: "cloneCurvePoint", received: index,
531
+ expected: `integer from 0 through ${points.length}`,
532
+ });
533
+ points.splice(index, 0, deepClone(points[sourceIndex]));
534
+ this.draftDirty = true;
535
+ }
536
+ deleteCurvePoint(id, index) {
537
+ const { points } = this.curvePointDraft(id, index, "deleteCurvePoint");
538
+ if (points.length <= 1)
539
+ throw new KakapoValidationError("A curve must keep at least one point.", {
540
+ code: "INVALID_CURVE_POINT_COUNT", operation: "deleteCurvePoint", expected: "at least one point",
541
+ });
542
+ points.splice(index, 1);
543
+ this.draftDirty = true;
544
+ }
358
545
  deleteNode(id, options = {}) {
359
546
  const scene = this.requireDraft("deleteNode");
360
547
  if (id === 0)
@@ -470,6 +657,25 @@ export class KakapoAPI {
470
657
  this.draftDirty = true;
471
658
  return deepClone(material);
472
659
  }
660
+ importMaterial(resource) {
661
+ const scene = this.requireDraft("importMaterial");
662
+ const source = deepClone(resource);
663
+ const materials = scene.storage.materials ?? (scene.storage.materials = {});
664
+ const id = nextId(materials);
665
+ assertMaterial(wireToMaterial(id, { data: source.data }).data, "importMaterial");
666
+ const shaders = scene.storage.shaders ?? (scene.storage.shaders = {});
667
+ const shaderIds = source.shaders.map((shader) => {
668
+ if (shader === null)
669
+ return 0;
670
+ const shaderId = nextId(shaders);
671
+ shaders[String(shaderId)] = shader;
672
+ return shaderId;
673
+ });
674
+ const material = { data: source.data, ...(shaderIds.length ? { shaders: shaderIds } : {}) };
675
+ materials[String(id)] = material;
676
+ this.draftDirty = true;
677
+ return deepClone(wireToMaterial(id, material));
678
+ }
473
679
  updateMaterial(id, data, shaderIds) {
474
680
  const scene = this.requireDraft("updateMaterial");
475
681
  const currentRaw = scene.storage.materials?.[String(id)];
@@ -478,10 +684,21 @@ export class KakapoAPI {
478
684
  const current = wireToMaterial(id, currentRaw);
479
685
  const updated = { id, data: this.mergeMaterialData(current.data, data), shaderIds: shaderIds ? [...shaderIds] : current.shaderIds };
480
686
  assertMaterial(updated.data, "updateMaterial");
687
+ const before = materialToWire(current);
481
688
  const next = materialToWire(updated);
482
- scene.storage.materials[String(id)] = shaderIds === undefined
483
- ? { ...currentRaw, data: next.data }
484
- : next;
689
+ const raw = { ...currentRaw, data: { ...currentRaw.data } };
690
+ for (const [key, value] of Object.entries(next.data)) {
691
+ if (value !== before.data[key])
692
+ raw.data[key] = value;
693
+ }
694
+ if (shaderIds !== undefined) {
695
+ delete raw.shader;
696
+ if (next.shaders)
697
+ raw.shaders = next.shaders;
698
+ else
699
+ delete raw.shaders;
700
+ }
701
+ scene.storage.materials[String(id)] = raw;
485
702
  this.draftDirty = true;
486
703
  return deepClone(updated);
487
704
  }
@@ -542,10 +759,34 @@ export class KakapoAPI {
542
759
  async reloadTextures() { return this.call("reload_textures"); }
543
760
  async getPendingResources() { return parseJsonResult(await this.call("get_pending_resources"), "get_pending_resources"); }
544
761
  call(method, params = []) { return this.transport.rpc(method, params); }
545
- async sendPatch(operations) {
546
- const result = await this.call("scene_state_patch", [JSON.stringify(operations)]);
762
+ async sendPatch(operations, saveAfterPatch) {
763
+ let result;
764
+ try {
765
+ result = await this.call("scene_state_patch", [JSON.stringify(operations)]);
766
+ }
767
+ catch (cause) {
768
+ if (cause instanceof KakapoRpcError && cause.rpcCode === -32601)
769
+ throw new KakapoSceneCommitError("rolled_back", cause);
770
+ throw new KakapoSceneCommitError("unknown", cause);
771
+ }
547
772
  if (result !== undefined && result !== null && result !== "") {
548
- throw new KakapoRpcError("scene_state_patch", String(result));
773
+ const message = typeof result === "object" && "message" in result && typeof result.message === "string"
774
+ ? result.message
775
+ : JSON.stringify(result);
776
+ // The wrapper can apply geometry before a later extension patch fails.
777
+ throw new KakapoSceneCommitError("unknown", new KakapoRpcError("scene_state_patch", message));
778
+ }
779
+ if (!saveAfterPatch)
780
+ return { status: "not_needed" };
781
+ try {
782
+ const saved = await this.call("save_scene");
783
+ // A filename acknowledges the save handler, not a new durable snapshot.
784
+ if (typeof saved === "string" && saved.length > 0 && !saved.startsWith("{") && !saved.startsWith("["))
785
+ return { status: "acknowledged", filename: saved };
786
+ return { status: "unknown", error: `Save was not confirmed: ${JSON.stringify(saved)}` };
787
+ }
788
+ catch (error) {
789
+ return { status: "unknown", error: error instanceof Error ? error.message : String(error) };
549
790
  }
550
791
  }
551
792
  saveNode(id, changes) {
@@ -586,19 +827,24 @@ export class KakapoAPI {
586
827
  hint: "Finish the transaction, then perform the engine-backed read.",
587
828
  });
588
829
  }
589
- createSceneEdit() {
590
- return {
830
+ createSceneEdit(assertActive) {
831
+ const edit = {
591
832
  getScene: () => this.getScene(),
592
833
  listNodes: (options) => this.listNodes(options),
593
834
  findNodes: (options) => this.findNodes(options),
594
835
  getNode: (id) => this.getNode(id),
595
- node: ((id, kind) => this.node(id, kind)),
836
+ node: ((id, kind) => createNodeHandle({
837
+ readNode: (nodeId) => { assertActive(); return this.getNode(nodeId); },
838
+ saveNode: (nodeId, changes) => { assertActive(); return this.saveNode(nodeId, changes); },
839
+ }, id, kind)),
596
840
  getNodeName: (id) => this.getNodeName(id),
597
841
  getNodeParent: (id) => this.getNodeParent(id),
598
842
  getNodeChildren: (id) => this.getNodeChildren(id),
599
843
  applyScenePatch: (operations) => this.applyScenePatch(operations),
600
844
  getSceneExt: () => this.getSceneExt(),
601
845
  patchSceneExt: (operations) => this.patchSceneExt(operations),
846
+ getSceneStorage: () => this.getSceneStorage(),
847
+ patchSceneStorage: (operations) => this.patchSceneStorage(operations),
602
848
  getSceneProperties: () => this.getSceneProperties(),
603
849
  patchSceneProperties: (operations) => this.patchSceneProperties(operations),
604
850
  createNode: (input) => this.createNode(input),
@@ -617,6 +863,9 @@ export class KakapoAPI {
617
863
  setNodeOperation: (id, operation) => this.setNodeOperation(id, operation),
618
864
  setTextContent: (id, text) => this.setTextContent(id, text),
619
865
  setTextFont: (id, family, weight, italic) => this.setTextFont(id, family, weight, italic),
866
+ updateCurvePoint: (id, index, changes) => this.updateCurvePoint(id, index, changes),
867
+ cloneCurvePoint: (id, sourceIndex, index) => this.cloneCurvePoint(id, sourceIndex, index),
868
+ deleteCurvePoint: (id, index) => this.deleteCurvePoint(id, index),
620
869
  setSvgPaths: (id, paths) => this.setSvgPaths(id, paths),
621
870
  setMeshSource: (id, mesh, index) => this.setMeshSource(id, mesh, index),
622
871
  setFieldSource: (id, field) => this.setFieldSource(id, field),
@@ -626,6 +875,7 @@ export class KakapoAPI {
626
875
  listMaterials: () => this.listMaterials(),
627
876
  getMaterial: (id) => this.getMaterial(id),
628
877
  createMaterial: (data, shaderIds) => this.createMaterial(data, shaderIds),
878
+ importMaterial: (resource) => this.importMaterial(resource),
629
879
  updateMaterial: (id, data, shaderIds) => this.updateMaterial(id, data, shaderIds),
630
880
  deleteMaterial: (id) => this.deleteMaterial(id),
631
881
  listOpenScadScripts: () => this.listOpenScadScripts(),
@@ -634,6 +884,18 @@ export class KakapoAPI {
634
884
  updateOpenScadScript: (id, changes) => this.updateOpenScadScript(id, changes),
635
885
  deleteOpenScadScript: (id) => this.deleteOpenScadScript(id),
636
886
  };
887
+ return new Proxy(edit, {
888
+ get(target, property, receiver) {
889
+ assertActive();
890
+ const method = Reflect.get(target, property, receiver);
891
+ if (typeof method !== "function")
892
+ return method;
893
+ return (...args) => {
894
+ assertActive();
895
+ return Reflect.apply(method, target, args);
896
+ };
897
+ },
898
+ });
637
899
  }
638
900
  treeArrays(scene) { return Object.fromEntries(Object.keys(scene.tree ?? {}).map((id) => [id, orderedChildren(scene, Number(id))])); }
639
901
  extWithName(scene, id, name) {
@@ -669,10 +931,10 @@ export class KakapoAPI {
669
931
  const common = ["transform", "pickable"];
670
932
  const byKind = {
671
933
  primitive: ["hidden", "materialId", "operation", "blend", "color", "primitive", "round", "thickness", "inflation", "materialNeutralCutout", "mirrors"],
672
- union: ["hidden", "operation", "blend", "resolution", "thickness", "inflation", "materialNeutralCutout"],
934
+ union: ["hidden", "operation", "blend", "resolution", "thickness", "inflation", "materialNeutralCutout", "scriptInstanceId"],
673
935
  group: ["hidden"],
674
936
  light: ["color", "power", "collimation", "lightType", "size", "textureFile"],
675
- curve: ["hidden", "materialId", "operation", "blend", "primitive", "density", "roundness", "smoothing", "points", "materialNeutralCutout", "mirrors"],
937
+ curve: ["hidden", "materialId", "operation", "blend", "primitive", "density", "roundness", "smoothing", "points", "materialNeutralCutout", "mirrors", "version", "curveType", "closed"],
676
938
  text: ["hidden", "materialId", "operation", "blend", "text", "fontFamily", "weight", "italic", "round", "width", "wrap", "align", "spacing", "lineHeight", "materialNeutralCutout"],
677
939
  svg: ["hidden", "materialId", "operation", "blend", "paths", "inflate", "outline", "outlineSize", "materialNeutralCutout"],
678
940
  mesh: ["hidden", "materialId", "mesh", "meshIndex", "smoothNormals", "colorSampling"],
package/dist/errors.d.ts CHANGED
@@ -23,6 +23,10 @@ export declare class KakapoConnectionError extends KakapoError {
23
23
  }
24
24
  export declare class KakapoTimeoutError extends KakapoError {
25
25
  }
26
+ export declare class KakapoSceneCommitError extends KakapoError {
27
+ readonly outcome: "rolled_back" | "unknown";
28
+ constructor(outcome: "rolled_back" | "unknown", cause: unknown);
29
+ }
26
30
  export declare class KakapoRpcError extends KakapoError {
27
31
  readonly rpcCode?: number;
28
32
  readonly method: string;
package/dist/errors.js CHANGED
@@ -35,6 +35,18 @@ export class KakapoConnectionError extends KakapoError {
35
35
  }
36
36
  export class KakapoTimeoutError extends KakapoError {
37
37
  }
38
+ export class KakapoSceneCommitError extends KakapoError {
39
+ outcome;
40
+ constructor(outcome, cause) {
41
+ super(cause instanceof Error ? cause.message : String(cause), {
42
+ code: "SCENE_COMMIT_FAILED",
43
+ operation: "editScene",
44
+ details: { outcome },
45
+ cause,
46
+ });
47
+ this.outcome = outcome;
48
+ }
49
+ }
38
50
  export class KakapoRpcError extends KakapoError {
39
51
  rpcCode;
40
52
  method;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { KakapoAPI } from "./api.js";
2
- export type { SceneEdit } from "./api.js";
3
- export { KakapoError, KakapoConnectionError, KakapoTimeoutError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
2
+ export type { SceneEdit, SceneSaveResult } from "./api.js";
3
+ export type { WireScene as NativeSceneSnapshot } from "./scene.js";
4
+ export { getSceneSnapshotRevision } from "./scene.js";
5
+ export { KakapoError, KakapoConnectionError, KakapoTimeoutError, KakapoSceneCommitError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
4
6
  export { parseBinaryFrame } from "./transport.js";
5
7
  export type { NodeHandle, NodeHandleActions } from "./node-handle.js";
6
8
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { KakapoAPI } from "./api.js";
2
- export { KakapoError, KakapoConnectionError, KakapoTimeoutError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
2
+ export { getSceneSnapshotRevision } from "./scene.js";
3
+ export { KakapoError, KakapoConnectionError, KakapoTimeoutError, KakapoSceneCommitError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
3
4
  export { parseBinaryFrame } from "./transport.js";
package/dist/scene.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { KakapoValidationError } from "./errors.js";
2
- import type { JsonPatchOperation, KakapoNode, Material, MaterialData, NodeKind, Scene, Transform } from "./types.js";
2
+ import type { CurvePoint, JsonPatchOperation, KakapoNode, Material, MaterialData, NodeKind, Scene, Transform } from "./types.js";
3
3
  export interface WireNode {
4
4
  type: number;
5
5
  [key: string]: unknown;
@@ -8,7 +8,7 @@ export interface WireScene {
8
8
  version?: number;
9
9
  name?: string;
10
10
  nodes: Record<string, WireNode>;
11
- tree: Record<string, Record<string, number>>;
11
+ tree: Record<string, Record<string, number> | number[]>;
12
12
  ext?: Record<string, unknown>;
13
13
  storage: {
14
14
  materials?: Record<string, {
@@ -30,6 +30,10 @@ export interface WireScene {
30
30
  camera?: Record<string, unknown>;
31
31
  [key: string]: unknown;
32
32
  }
33
+ /** Local scene fingerprint; camera navigation does not invalidate an editing baseline.
34
+ * This is not a server-issued revision or an atomic commit fence.
35
+ */
36
+ export declare function getSceneSnapshotRevision(snapshot: WireScene): Promise<string>;
33
37
  export declare const identityTransform: () => Transform;
34
38
  export declare function deepClone<T>(value: T): T;
35
39
  export declare function parseJsonResult<T>(value: unknown, operation: string): T;
@@ -40,6 +44,7 @@ export declare function orderedChildren(scene: WireScene, parentId: number): num
40
44
  export declare function encodeTree(tree: Record<string, number[]>): WireScene["tree"];
41
45
  export declare function wireToNode(scene: WireScene, id: number): KakapoNode;
42
46
  export declare function defaultNode(kind: NodeKind, id: number, name?: string): KakapoNode;
47
+ export declare function encodeCurvePoint(point: CurvePoint): Record<string, unknown>;
43
48
  export declare function nodeToWire(node: KakapoNode): WireNode;
44
49
  export declare const defaultMaterialData: () => MaterialData;
45
50
  export declare function wireToMaterial(id: number, raw: {
package/dist/scene.js CHANGED
@@ -1,4 +1,17 @@
1
1
  import { KakapoValidationError } from "./errors.js";
2
+ /** Local scene fingerprint; camera navigation does not invalidate an editing baseline.
3
+ * This is not a server-issued revision or an atomic commit fence.
4
+ */
5
+ export async function getSceneSnapshotRevision(snapshot) {
6
+ const { camera: _camera, ...state } = snapshot;
7
+ const json = JSON.stringify(state, (_key, value) => {
8
+ if (value === null || typeof value !== "object" || Array.isArray(value))
9
+ return value;
10
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
11
+ });
12
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(json));
13
+ return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
14
+ }
2
15
  const KINDS = [
3
16
  "primitive", "union", "light", "curve", "group", "text", "field", "svg", "mesh", "decal", "openScad", "socket",
4
17
  ];
@@ -134,6 +147,8 @@ function decodeCurvePoint(value) {
134
147
  return {
135
148
  position: vec3(p.pos), rotation: vec3(p.rot), scale: vec3(p.size, v3(10)),
136
149
  materialId: materialId(p.material_id), round: num(p.round, 0), fixed: bool(p.fixed, false),
150
+ ...(p.handle_in !== undefined ? { handleIn: vec3(p.handle_in) } : {}),
151
+ ...(p.handle_out !== undefined ? { handleOut: vec3(p.handle_out) } : {}),
137
152
  };
138
153
  }
139
154
  function decodeSvgPaths(value) {
@@ -160,10 +175,17 @@ export function wireToNode(scene, id) {
160
175
  const material = materialId(raw.material_id);
161
176
  switch (base.kind) {
162
177
  case "primitive": return { ...base, kind: "primitive", hidden, operation, blend: num(raw.blend, 0), color: vec3(raw.color, v3(1)), primitive: PRIMITIVES[num(raw.prim, 2)] ?? "box", round: num(raw.round, 0), thickness: num(raw.thickness, -1), inflation: num(raw.inflation, 0), materialId: material, materialNeutralCutout: bool(raw.material_neutral_cutout, false), mirrors: mirrorPlanes(scene, id, raw) };
163
- case "union": return { ...base, kind: "union", hidden, operation, blend: num(raw.blend, 0), resolution: num(raw.resolution, 0.3), thickness: num(raw.thickness, -1), inflation: num(raw.inflation, 0), materialNeutralCutout: bool(raw.material_neutral_cutout, false) };
178
+ case "union": {
179
+ const scriptInstanceId = num(raw.script_id, 0xffffffff);
180
+ return {
181
+ ...base, kind: "union", hidden, operation, blend: num(raw.blend, 0), resolution: num(raw.resolution, 0.3),
182
+ thickness: num(raw.thickness, -1), inflation: num(raw.inflation, 0), materialNeutralCutout: bool(raw.material_neutral_cutout, false),
183
+ ...(Number.isSafeInteger(scriptInstanceId) && scriptInstanceId >= 0 && scriptInstanceId < 0xffffffff ? { scriptInstanceId } : {}),
184
+ };
185
+ }
164
186
  case "group": return { ...base, kind: "group", hidden };
165
187
  case "light": return { ...base, kind: "light", color: vec3(raw.color, { x: 1, y: 1, z: 0.9 }), power: num(raw.power, 80), collimation: num(raw.collimation, 0), lightType: LIGHT_TYPES[num(raw.lightType, 1)] ?? "sphere", size: vec2(raw.size, v2(1, 1)), textureFile: str(raw.texture_file) };
166
- case "curve": return { ...base, kind: "curve", hidden, materialId: material, operation, blend: num(raw.blend, 20), primitive: PRIMITIVES[num(raw.prim, 0)] ?? "sphere", density: num(raw.density, 100), roundness: num(raw.roundness, 1), smoothing: num(raw.smoothing, 0.02), materialNeutralCutout: bool(raw.material_neutral_cutout, false), points: Array.isArray(raw.points) ? raw.points.map(decodeCurvePoint) : [], mirrors: mirrorPlanes(scene, id, raw) };
188
+ case "curve": return { ...base, kind: "curve", version: num(raw.version, 2), curveType: raw.curve_type === 1 ? "bezier" : "spline", closed: bool(raw.closed, false), hidden, materialId: material, operation, blend: num(raw.blend, 20), primitive: PRIMITIVES[num(raw.prim, 0)] ?? "sphere", density: num(raw.density, 100), roundness: num(raw.roundness, 1), smoothing: num(raw.smoothing, 0.02), materialNeutralCutout: bool(raw.material_neutral_cutout, false), points: Array.isArray(raw.points) ? raw.points.map(decodeCurvePoint) : [], mirrors: mirrorPlanes(scene, id, raw) };
167
189
  case "text": return { ...base, kind: "text", hidden, materialId: material, operation, blend: num(raw.blend, 0), text: str(raw.text, "Text"), fontFamily: str(raw.font, "OpenSans"), weight: num(raw.weight, 400), italic: bool(raw.italic, false), round: num(raw.round, 0), width: num(raw.width, 20), wrap: TEXT_WRAP[num(raw.wrap, 0)] ?? "disabled", align: TEXT_ALIGN[num(raw.align, 0)] ?? "left", spacing: num(raw.spacing, 0), lineHeight: num(raw.line_height, 1), materialNeutralCutout: bool(raw.material_neutral_cutout, false) };
168
190
  case "svg": return { ...base, kind: "svg", hidden, materialId: material, operation, blend: num(raw.blend, 0), paths: decodeSvgPaths(raw.paths), inflate: bool(raw.inflate, false), outline: bool(raw.outline, false), outlineSize: num(raw.outline_size, 0.1), materialNeutralCutout: bool(raw.material_neutral_cutout, false) };
169
191
  case "mesh": return { ...base, kind: "mesh", hidden, materialId: material, mesh: str(raw.mesh), meshIndex: num(raw.mesh_index, 0), smoothNormals: bool(raw.smooth_normals, false), colorSampling: FIELD_SAMPLING[num(raw.color_option, 2)] ?? "override" };
@@ -198,8 +220,13 @@ export function defaultNode(kind, id, name = "") {
198
220
  case "socket": return { ...base, kind, hidden: false, tag: "" };
199
221
  }
200
222
  }
201
- function encodeCurvePoint(point) {
202
- return { pos: point.position, rot: point.rotation, size: point.scale, material_id: point.materialId ?? 0, round: point.round, fixed: point.fixed };
223
+ export function encodeCurvePoint(point) {
224
+ return {
225
+ pos: point.position, rot: point.rotation, size: point.scale,
226
+ material_id: point.materialId ?? 0, round: point.round, fixed: point.fixed,
227
+ ...(point.handleIn !== undefined ? { handle_in: point.handleIn } : {}),
228
+ ...(point.handleOut !== undefined ? { handle_out: point.handleOut } : {}),
229
+ };
203
230
  }
204
231
  function encodeSvgPaths(paths) {
205
232
  return paths.map((path) => ({ closed: path.closed, segments: path.segments.map((s) => ({ point: s.point, handle_in: s.handleIn, handle_out: s.handleOut })) }));
@@ -221,13 +248,17 @@ export function nodeToWire(node) {
221
248
  Object.assign(raw, { color: node.color, prim: PRIMITIVES.indexOf(node.primitive), round: node.round, thickness: node.thickness, inflation: node.inflation, material_neutral_cutout: node.materialNeutralCutout, mirror_x: -1, mirror_y: -1, mirror_z: -1 }, encodeMirrorPlanes(node.mirrors));
222
249
  break;
223
250
  case "union":
224
- Object.assign(raw, { resolution: node.resolution, thickness: node.thickness, inflation: node.inflation, material_neutral_cutout: node.materialNeutralCutout });
251
+ Object.assign(raw, {
252
+ resolution: node.resolution, thickness: node.thickness, inflation: node.inflation,
253
+ material_neutral_cutout: node.materialNeutralCutout,
254
+ ...(node.scriptInstanceId !== undefined ? { script_id: node.scriptInstanceId } : {}),
255
+ });
225
256
  break;
226
257
  case "light":
227
258
  Object.assign(raw, { color: node.color, power: node.power, collimation: node.collimation, lightType: LIGHT_TYPES.indexOf(node.lightType), size: node.size, texture_file: node.textureFile });
228
259
  break;
229
260
  case "curve":
230
- Object.assign(raw, { prim: PRIMITIVES.indexOf(node.primitive), density: node.density, roundness: node.roundness, smoothing: node.smoothing, material_neutral_cutout: node.materialNeutralCutout, points: node.points.map(encodeCurvePoint), version: 2, mirror_x: -1, mirror_y: -1, mirror_z: -1 }, encodeMirrorPlanes(node.mirrors));
261
+ Object.assign(raw, { prim: PRIMITIVES.indexOf(node.primitive), density: node.density, roundness: node.roundness, smoothing: node.smoothing, material_neutral_cutout: node.materialNeutralCutout, points: node.points.map(encodeCurvePoint), version: node.version ?? 2, curve_type: node.curveType === "bezier" ? 1 : 0, closed: node.closed ?? false, mirror_x: -1, mirror_y: -1, mirror_z: -1 }, encodeMirrorPlanes(node.mirrors));
231
262
  break;
232
263
  case "text":
233
264
  Object.assign(raw, { text: node.text, font: node.fontFamily, weight: node.weight, italic: node.italic, round: node.round, width: node.width, wrap: TEXT_WRAP.indexOf(node.wrap), align: TEXT_ALIGN.indexOf(node.align), spacing: node.spacing, line_height: node.lineHeight, material_neutral_cutout: node.materialNeutralCutout });
package/dist/types.d.ts CHANGED
@@ -70,6 +70,7 @@ export interface UnionNode extends TransformNode, OperationNode {
70
70
  thickness: number;
71
71
  inflation: number;
72
72
  materialNeutralCutout: boolean;
73
+ scriptInstanceId?: number;
73
74
  }
74
75
  export interface GroupNode extends TransformNode {
75
76
  kind: "group";
@@ -90,9 +91,14 @@ export interface CurvePoint {
90
91
  materialId: number | null;
91
92
  round: number;
92
93
  fixed: boolean;
94
+ handleIn?: Vec3;
95
+ handleOut?: Vec3;
93
96
  }
94
97
  export interface CurveNode extends NodeBase, MaterialNode, OperationNode, MirrorNode {
95
98
  kind: "curve";
99
+ version?: number;
100
+ curveType?: "spline" | "bezier";
101
+ closed?: boolean;
96
102
  hidden: boolean;
97
103
  primitive: PrimitiveType;
98
104
  density: number;
@@ -186,6 +192,9 @@ export interface NodeUpdate {
186
192
  roundness?: number;
187
193
  smoothing?: number;
188
194
  points?: CurvePoint[];
195
+ version?: number;
196
+ curveType?: "spline" | "bezier";
197
+ closed?: boolean;
189
198
  text?: string;
190
199
  fontFamily?: string;
191
200
  weight?: number;
@@ -211,6 +220,7 @@ export interface NodeUpdate {
211
220
  enabled?: boolean;
212
221
  tag?: string;
213
222
  mirrors?: MirrorPlanes;
223
+ scriptInstanceId?: number;
214
224
  }
215
225
  export interface CreateNodeInput<K extends NodeKind = NodeKind> {
216
226
  kind: K;
@@ -239,6 +249,11 @@ export interface MaterialData {
239
249
  surfaceOpacity: number;
240
250
  volumetricEnabled: boolean;
241
251
  }
252
+ /** Native renderer material data and shader slots supplied by an asset catalog. */
253
+ export interface MaterialResource {
254
+ data: Record<string, JsonValue>;
255
+ shaders: readonly (Record<string, JsonValue> | null)[];
256
+ }
242
257
  export interface Material {
243
258
  id: number;
244
259
  data: MaterialData;
@@ -1,7 +1,7 @@
1
1
  import { KakapoValidationError } from "./errors.js";
2
2
  import { kindFromWire, parentMap, wireToNode } from "./scene.js";
3
3
  const CONTAINERS = new Set(["union", "group"]);
4
- const GEOMETRY = new Set(["primitive", "curve", "text", "field", "svg", "mesh", "decal", "openScad"]);
4
+ const GEOMETRY = new Set(["primitive", "curve", "text", "field", "svg", "mesh", "openScad"]);
5
5
  function fail(message, operation, path, received, expected, hint, code = "VALIDATION_ERROR") {
6
6
  throw new KakapoValidationError(message, { code, operation, path, received, expected, hint });
7
7
  }
@@ -79,6 +79,9 @@ export function assertNode(node, scene, operation) {
79
79
  assertFinite(node.resolution, operation, "resolution", 0.01, 1);
80
80
  assertFinite(node.thickness, operation, "thickness", -1, 10);
81
81
  assertFinite(node.inflation, operation, "inflation", -10, 10);
82
+ if (node.scriptInstanceId !== undefined && (!Number.isSafeInteger(node.scriptInstanceId) || node.scriptInstanceId < 0 || node.scriptInstanceId >= 0xffffffff)) {
83
+ fail("scriptInstanceId must identify a stored script instance.", operation, "scriptInstanceId", node.scriptInstanceId, "safe integer from 0 through 4294967294");
84
+ }
82
85
  break;
83
86
  case "light":
84
87
  assertColor(node.color, operation, "color");
@@ -92,9 +95,26 @@ export function assertNode(node, scene, operation) {
92
95
  assertFinite(node.density, operation, "density", 1);
93
96
  assertFinite(node.roundness, operation, "roundness", 0);
94
97
  assertFinite(node.smoothing, operation, "smoothing", 0, 1);
98
+ if (node.version !== undefined)
99
+ assertId(node.version, operation, "version");
100
+ if (node.curveType !== undefined && node.curveType !== "spline" && node.curveType !== "bezier") {
101
+ fail("Unknown curve type.", operation, "curveType", node.curveType, "spline or bezier");
102
+ }
103
+ if (node.closed !== undefined && typeof node.closed !== "boolean") {
104
+ fail("closed must be a boolean.", operation, "closed", node.closed, "boolean");
105
+ }
95
106
  if (!node.points.length)
96
107
  fail("A curve requires at least one point.", operation, "points", node.points, "non-empty curve points");
97
- node.points.forEach((p, i) => { assertVec3(p.position, operation, `points[${i}].position`); assertVec3(p.rotation, operation, `points[${i}].rotation`); assertVec3(p.scale, operation, `points[${i}].scale`); });
108
+ node.points.forEach((point, index) => {
109
+ assertVec3(point.position, operation, `points[${index}].position`);
110
+ assertVec3(point.rotation, operation, `points[${index}].rotation`);
111
+ assertVec3(point.scale, operation, `points[${index}].scale`);
112
+ assertFinite(point.round, operation, `points[${index}].round`, 0, 1);
113
+ if (point.handleIn !== undefined)
114
+ assertVec3(point.handleIn, operation, `points[${index}].handleIn`);
115
+ if (point.handleOut !== undefined)
116
+ assertVec3(point.handleOut, operation, `points[${index}].handleOut`);
117
+ });
98
118
  break;
99
119
  case "text":
100
120
  if (!node.text.length)
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
- {
2
- "name": "@womp/kakapo-sdk",
3
- "version": "0.2.2",
4
- "description": "Typed Node.js and browser SDK for controlling Kakapo over WebSocket",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js",
12
- "default": "./dist/index.js"
13
- },
14
- "./package.json": "./package.json"
15
- },
16
- "files": [
17
- "dist/**/*.js",
18
- "dist/**/*.d.ts",
19
- "README.md"
20
- ],
21
- "sideEffects": false,
22
- "engines": {
23
- "node": ">=22.0.0"
24
- },
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/wompxyz/kakapo-sdk.git"
28
- },
29
- "publishConfig": {
30
- "access": "public"
31
- },
32
- "scripts": {
33
- "clean": "node -e \"const f=require('node:fs');for(const p of ['dist','dist-test'])f.rmSync(p,{recursive:true,force:true})\"",
34
- "build": "yarn clean && tsc -p tsconfig.json",
35
- "typecheck": "tsc --noEmit -p tsconfig.json",
36
- "lint": "yarn typecheck",
37
- "build:test": "tsc -p tsconfig.test.json",
38
- "test": "yarn build && yarn build:test && node --test dist-test/test/unit.test.js",
39
- "test:live": "yarn build && yarn build:test && node --test dist-test/test/live.test.js",
40
- "test:all": "yarn test && yarn test:live",
41
- "prepack": "yarn build"
42
- },
43
- "dependencies": {
44
- "fast-json-patch": "^3.1.1",
45
- "ws": "^8.18.3"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^24.0.0",
49
- "@types/ws": "^8.18.1",
50
- "typescript": "^5.9.3"
51
- },
52
- "license": "MIT"
53
- }
1
+ {
2
+ "name": "@womp/kakapo-sdk",
3
+ "version": "0.3.0",
4
+ "description": "Typed Node.js and browser SDK for controlling Kakapo over WebSocket",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "dist/**/*.js",
18
+ "dist/**/*.d.ts",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/wompxyz/kakapo-sdk.git"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "clean": "node -e \"const f=require('node:fs');for(const p of ['dist','dist-test'])f.rmSync(p,{recursive:true,force:true})\"",
34
+ "build": "yarn clean && tsc -p tsconfig.json",
35
+ "typecheck": "tsc --noEmit -p tsconfig.json",
36
+ "lint": "yarn typecheck",
37
+ "build:test": "tsc -p tsconfig.test.json",
38
+ "test": "yarn build && yarn build:test && node --test dist-test/test/unit.test.js",
39
+ "test:live": "yarn build && yarn build:test && node --test dist-test/test/live.test.js",
40
+ "test:all": "yarn test && yarn test:live",
41
+ "prepack": "yarn build"
42
+ },
43
+ "dependencies": {
44
+ "fast-json-patch": "^3.1.1",
45
+ "ws": "^8.18.3"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^24.0.0",
49
+ "@types/ws": "^8.18.1",
50
+ "typescript": "^5.9.3"
51
+ },
52
+ "license": "MIT"
53
+ }