@womp/kakapo-sdk 0.2.2 → 0.3.1

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,135 @@
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
- `captureScreenshot({ view: "current" })` requests the renderer's JPEG without changing the camera
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
-
71
- 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.
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
+ `captureScreenshot({ view: "current" })` requests the renderer's JPEG without changing the camera
57
+ or requiring a cached scene. Current-view captures cannot include `targetId` or `targetIds`.
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
+
83
+ Each `editScene()` refreshes before creating its private draft. Top-level edits are serialized per
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
+ Node IDs allocated by `createNode()` and `cloneNode()` increase for the lifetime of a `KakapoAPI`
94
+ instance. IDs observed in scene snapshots, draft patches, or numeric node-extension keys remain
95
+ reserved after deletion, rollback, and reconnect. This prevents delete-and-create edits from changing
96
+ an existing renderer object's type by reusing its ID. A new API instance starts above IDs in its current
97
+ snapshot; this is not a persistent project-wide allocator. Raw patches supply their own IDs and must
98
+ preserve object identity; concurrent clients remain subject to the scene revision guards.
99
+
100
+ `editSceneAndSave(callback)` submits the standard `scene_state_patch` command and, after
101
+ acknowledgement, calls `save_scene` once on the same connection. No new Kakapo command is required.
102
+ The result preserves the callback value separately from save status: `acknowledged` includes the
103
+ returned filename, `unknown` includes an error, and `not_needed` means the draft did not change.
104
+ A save acknowledgement does not prove a new durable snapshot: some endpoints return an existing
105
+ filename. Verify persistence through the owning service. Never repeat the edit to recover saving.
106
+ The backend wrapper may apply geometry before extension processing fails; patch errors and lost
107
+ replies therefore have an `unknown` outcome unless rejection before execution is proven. SDK state
108
+ is refreshed after submission, and no mutation is automatically replayed.
109
+
110
+ `getSceneRevision()` fingerprints the cached native scene or active draft, including renderer fields
111
+ not exposed by `getScene()`. Camera movement and object-key ordering do not affect it. Refresh before
112
+ inspection; this fingerprint is not a server-issued revision or an atomic commit guarantee through a wrapper.
113
+
114
+ Mesh, field, and decal names are syntax-checked, but their existence cannot be enumerated by the
115
+ current engine RPC surface. Font family/weight/style combinations are checked against `listFonts()`.
116
+
117
+ ## Verification
118
+
119
+ ```bash
120
+ yarn test # fake WebSocket protocol and API behavior
121
+ yarn test:live # launches the sibling Kakapo build and runs named live tests per API method
122
+ yarn test:all # runs both suites
123
+ ```
124
+
125
+ The live suite starts Kakapo once, runs methods sequentially, and reports failures as names such as
126
+ `live:setNodeParent` or `live:createNode:mesh`. Mutations refresh the authoritative engine scene
127
+ before asserting their result. See [TEST_MATRIX.md](./TEST_MATRIX.md) for the exact unit/live mapping.
128
+
129
+ Build the sibling engine before running live tests. On Windows the default executable is
130
+ `../kakapo/build/code/RelWithDebInfo/kakapo_app.exe`; on other platforms it is
131
+ `../kakapo/build/code/kakapo_app`. Set `KAKAPO_BINARY` to test another build explicitly.
132
+
133
+ ### Importing catalog materials
134
+
135
+ 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[];
@@ -66,6 +84,7 @@ export declare class KakapoAPI {
66
84
  private draftScene?;
67
85
  private draftDirty;
68
86
  private rendererSyncRequired;
87
+ private nextNodeId;
69
88
  constructor(options?: KakapoAPIOptions);
70
89
  get isConnected(): boolean;
71
90
  connect(): Promise<void>;
@@ -76,16 +95,33 @@ export declare class KakapoAPI {
76
95
  waitForToken(token: string, timeoutMs?: number): Promise<TokenMessage>;
77
96
  refreshScene(): Promise<Scene>;
78
97
  getScene(): Scene;
98
+ /** Fingerprint of the cached scene or active draft, including unknown native fields.
99
+ * Camera movement is excluded. This is not a server-issued revision or commit fence.
100
+ */
101
+ getSceneRevision(): Promise<string>;
102
+ /** Wait until the renderer has produced a frame after this API's latest edit. */
103
+ waitForRenderer(): Promise<void>;
79
104
  editScene<T>(callback: (scene: SceneEdit) => T | Promise<T>): Promise<T>;
105
+ editSceneAndSave<T>(callback: (scene: SceneEdit) => T | Promise<T>): Promise<{
106
+ value: T;
107
+ save: SceneSaveResult;
108
+ }>;
109
+ private prepareDraft;
110
+ private runSceneEdit;
80
111
  private applyScenePatch;
81
112
  /** The scene's consumer-owned extension bag. Kakapo does not interpret it. */
82
113
  getSceneExt(): Record<string, JsonValue>;
83
114
  /** Apply JSON Patch operations rooted at the scene's `ext` bag. */
84
115
  patchSceneExt(operations: JsonPatchOperation[]): Scene;
116
+ /** Consumer-owned resources stored in the scene file. */
117
+ getSceneStorage(): Record<string, JsonValue>;
118
+ /** Apply JSON Patch operations rooted at the scene's `storage` bag. */
119
+ patchSceneStorage(operations: JsonPatchOperation[]): Scene;
85
120
  /** The scene's renderer/environment properties (HDR, background, and similar). */
86
121
  getSceneProperties(): Record<string, JsonValue>;
87
122
  /** Apply JSON Patch operations rooted at the scene's `properties` bag. */
88
123
  patchSceneProperties(operations: JsonPatchOperation[]): Scene;
124
+ private patchSceneBag;
89
125
  listNodes(options?: ListNodeOptions): KakapoNode[];
90
126
  findNodes(options: FindNodeOptions): KakapoNode[];
91
127
  getNode(id: number): KakapoNode;
@@ -102,9 +138,14 @@ export declare class KakapoAPI {
102
138
  private ensureRendererReady;
103
139
  private captureRendererFrame;
104
140
  private screenshotDirection;
141
+ private reserveNodeIds;
105
142
  private createNode;
106
143
  private cloneNode;
107
144
  private updateNode;
145
+ private curvePointDraft;
146
+ private updateCurvePoint;
147
+ private cloneCurvePoint;
148
+ private deleteCurvePoint;
108
149
  private deleteNode;
109
150
  private setNodeParent;
110
151
  private setNodeName;
@@ -127,6 +168,7 @@ export declare class KakapoAPI {
127
168
  listMaterials(): Material[];
128
169
  getMaterial(id: number): Material;
129
170
  private createMaterial;
171
+ private importMaterial;
130
172
  private updateMaterial;
131
173
  private deleteMaterial;
132
174
  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
  ]);
@@ -24,6 +24,7 @@ export class KakapoAPI {
24
24
  draftScene;
25
25
  draftDirty = false;
26
26
  rendererSyncRequired = false;
27
+ nextNodeId = 1;
27
28
  constructor(options = {}) {
28
29
  this.transport = options.transport ?? new KakapoTransport({
29
30
  url: options.url ?? "ws://127.0.0.1:5502",
@@ -33,12 +34,14 @@ export class KakapoAPI {
33
34
  }
34
35
  get isConnected() { return this.transport.connected; }
35
36
  async connect() {
36
- await this.transport.connect();
37
37
  try {
38
+ await this.transport.connect();
38
39
  await this.ping();
39
40
  await this.refreshScene();
40
41
  }
41
42
  catch (error) {
43
+ if (error instanceof KakapoConnectionError && error.code === "CONNECTION_IN_PROGRESS")
44
+ throw error;
42
45
  this.disconnect();
43
46
  throw error;
44
47
  }
@@ -75,6 +78,7 @@ export class KakapoAPI {
75
78
  const result = await this.call("scene_state_get");
76
79
  const scene = parseJsonResult(result, "scene_state_get");
77
80
  assertScene(scene, "refreshScene");
81
+ this.reserveNodeIds(scene);
78
82
  this.rawScene = scene;
79
83
  this.cacheValid = true;
80
84
  return this.getScene();
@@ -82,24 +86,107 @@ export class KakapoAPI {
82
86
  getScene() {
83
87
  return deepClone(toPublicScene(this.requireScene("getScene")));
84
88
  }
89
+ /** Fingerprint of the cached scene or active draft, including unknown native fields.
90
+ * Camera movement is excluded. This is not a server-issued revision or commit fence.
91
+ */
92
+ async getSceneRevision() {
93
+ return getSceneSnapshotRevision(this.requireScene("getSceneRevision"));
94
+ }
95
+ /** Wait until the renderer has produced a frame after this API's latest edit. */
96
+ async waitForRenderer() {
97
+ await this.ensureRendererReady();
98
+ }
85
99
  editScene(callback) {
100
+ return this.runSceneEdit(callback, false).then(({ value }) => value);
101
+ }
102
+ editSceneAndSave(callback) {
103
+ return this.runSceneEdit(callback, true);
104
+ }
105
+ async prepareDraft(baseline, callback) {
106
+ this.draftScene = deepClone(baseline);
107
+ this.draftDirty = false;
108
+ let active = true;
109
+ const edit = this.createSceneEdit(() => {
110
+ if (active)
111
+ return;
112
+ throw new KakapoValidationError("The scene draft has expired.", {
113
+ code: "EXPIRED_SCENE_DRAFT",
114
+ operation: "editScene",
115
+ expected: "a draft used only inside its own callback",
116
+ });
117
+ });
118
+ let value;
119
+ try {
120
+ value = await callback(edit);
121
+ }
122
+ finally {
123
+ active = false;
124
+ }
125
+ const scene = this.requireDraft("editScene");
126
+ assertScene(scene, "editScene");
127
+ const operations = jsonpatch.compare(baseline, scene);
128
+ return { value, operations, scene };
129
+ }
130
+ runSceneEdit(callback, saveAfterPatch) {
86
131
  const run = this.transactionTail.then(async () => {
87
132
  await this.refreshScene();
88
133
  const baseline = deepClone(this.requireScene("editScene"));
89
- this.draftScene = deepClone(baseline);
90
- this.draftDirty = false;
91
134
  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);
135
+ const { value, operations, scene } = await this.prepareDraft(baseline, callback);
136
+ let save = { status: "not_needed" };
96
137
  if (operations.length) {
97
- await this.sendPatch(operations);
138
+ const addsRootField = operations.some((operation) => operation.op === "add" && operation.path.split("/").length === 2);
139
+ const addsExtRoot = operations.some((operation) => operation.op === "add" && operation.path === "/ext");
140
+ if (addsExtRoot) {
141
+ throw new KakapoValidationError("Cannot safely add a missing scene extension root through a partitioned endpoint.", {
142
+ code: "UNSUPPORTED_PATCH_GUARD",
143
+ operation: "editScene",
144
+ path: "/ext",
145
+ expected: "an existing ext root",
146
+ });
147
+ }
148
+ const changesCamera = operations.some((operation) => operation.path === "/camera" || operation.path.startsWith("/camera/"));
149
+ const changesExt = operations.some((operation) => operation.path === "/ext" || operation.path.startsWith("/ext/"));
150
+ const changesNonExt = operations.some((operation) => operation.path !== "/ext" && !operation.path.startsWith("/ext/"));
151
+ const latest = parseJsonResult(await this.call("scene_state_get"), "scene_state_get");
152
+ assertScene(latest, "editScene");
153
+ const stale = jsonpatch.compare(baseline, latest).some((operation) => changesCamera || (operation.path !== "/camera" && !operation.path.startsWith("/camera/")));
154
+ if (stale)
155
+ throw new KakapoSceneCommitError("rolled_back", new Error("Scene changed during preparation. Read it again before editing."));
156
+ // Raw renderers evaluate these together; wrappers may partition the patch.
157
+ // A failed response cannot establish that no earlier group was applied.
158
+ const guards = [];
159
+ if (changesNonExt) {
160
+ if (addsRootField)
161
+ guards.push({ op: "test", path: "", value: baseline });
162
+ else
163
+ guards.push(...Object.entries(baseline)
164
+ .filter(([key]) => key !== "ext" && (key !== "camera" || changesCamera))
165
+ .map(([key, value]) => ({
166
+ op: "test",
167
+ path: `/${escapePointer(key)}`,
168
+ value: value,
169
+ })));
170
+ }
171
+ if (changesExt)
172
+ guards.push({ op: "test", path: "/ext", value: baseline.ext });
173
+ save = await this.sendPatch([...guards, ...operations], saveAfterPatch);
98
174
  this.rendererSyncRequired = true;
99
175
  }
100
- this.rawScene = draft;
101
- this.cacheValid = true;
102
- return result;
176
+ if (operations.length) {
177
+ this.cacheValid = false;
178
+ try {
179
+ await this.refreshScene();
180
+ }
181
+ catch {
182
+ // Keep the acknowledged result; the next read must refresh the invalid cache.
183
+ }
184
+ }
185
+ else {
186
+ this.rawScene = scene;
187
+ this.cacheValid = true;
188
+ }
189
+ return { value, save };
103
190
  }
104
191
  catch (error) {
105
192
  this.cacheValid = false;
@@ -122,6 +209,7 @@ export class KakapoAPI {
122
209
  applyScenePatch(operations) {
123
210
  const candidate = applyJsonPatch(this.requireDraft("applyScenePatch"), operations);
124
211
  assertScene(candidate, "applyScenePatch");
212
+ this.reserveNodeIds(candidate);
125
213
  this.draftScene = candidate;
126
214
  this.draftDirty = true;
127
215
  return this.getScene();
@@ -133,7 +221,15 @@ export class KakapoAPI {
133
221
  }
134
222
  /** Apply JSON Patch operations rooted at the scene's `ext` bag. */
135
223
  patchSceneExt(operations) {
136
- return this.applyScenePatch(operations.map((op) => ({ ...op, path: `/ext${op.path}` })));
224
+ return this.patchSceneBag("ext", operations);
225
+ }
226
+ /** Consumer-owned resources stored in the scene file. */
227
+ getSceneStorage() {
228
+ return deepClone(this.requireScene("getSceneStorage").storage);
229
+ }
230
+ /** Apply JSON Patch operations rooted at the scene's `storage` bag. */
231
+ patchSceneStorage(operations) {
232
+ return this.patchSceneBag("storage", operations);
137
233
  }
138
234
  /** The scene's renderer/environment properties (HDR, background, and similar). */
139
235
  getSceneProperties() {
@@ -142,7 +238,24 @@ export class KakapoAPI {
142
238
  }
143
239
  /** Apply JSON Patch operations rooted at the scene's `properties` bag. */
144
240
  patchSceneProperties(operations) {
145
- return this.applyScenePatch(operations.map((op) => ({ ...op, path: `/properties${op.path}` })));
241
+ return this.patchSceneBag("properties", operations);
242
+ }
243
+ patchSceneBag(key, operations) {
244
+ const draft = this.requireDraft("patchSceneBag");
245
+ if (operations.length === 0)
246
+ return this.getScene();
247
+ const prefix = `/${key}`;
248
+ const patch = draft[key] === undefined
249
+ ? [{ op: "add", path: prefix, value: {} }]
250
+ : [];
251
+ for (const operation of operations) {
252
+ patch.push({
253
+ ...operation,
254
+ path: `${prefix}${operation.path}`,
255
+ ...("from" in operation ? { from: `${prefix}${operation.from}` } : {}),
256
+ });
257
+ }
258
+ return this.applyScenePatch(patch);
146
259
  }
147
260
  listNodes(options = {}) {
148
261
  const scene = this.requireScene("listNodes");
@@ -257,23 +370,35 @@ export class KakapoAPI {
257
370
  z: (bounds.max.z - bounds.min.z) * framePadding,
258
371
  };
259
372
  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);
373
+ const originalCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
374
+ let framedCamera;
375
+ let message;
376
+ try {
377
+ await this.call("set_camera", [{
378
+ ...originalCamera,
379
+ cent: center,
380
+ dir: { x: direction.x, y: direction.y, z: direction.z },
381
+ up: view === "top" || view === "bottom" ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 },
382
+ pos: {
383
+ x: center.x - direction.x * distance,
384
+ y: center.y - direction.y * distance,
385
+ z: center.z - direction.z * distance,
386
+ },
387
+ }, true]);
388
+ const framed = await this.call("center_cam", [center, size]);
389
+ if (this.rawScene)
390
+ this.rawScene.camera = { ...this.rawScene.camera, ...framed };
391
+ framedCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
392
+ await new Promise((resolve) => setTimeout(resolve, 500));
393
+ message = await this.captureRendererFrame(timeoutMs);
394
+ }
395
+ finally {
396
+ if (framedCamera !== undefined) {
397
+ const latestCamera = parseJsonResult(await this.call("get_cam"), "get_cam");
398
+ if (jsonpatch.compare(framedCamera, latestCamera).length === 0)
399
+ await this.call("set_camera", [originalCamera, true]);
400
+ }
401
+ }
277
402
  return {
278
403
  data: message.payload,
279
404
  mediaType: "image/jpeg",
@@ -317,12 +442,24 @@ export class KakapoAPI {
317
442
  };
318
443
  return directions[view];
319
444
  }
445
+ reserveNodeIds(scene) {
446
+ // Never rewind after deletion, rollback, or reconnect: old IDs may still be referenced.
447
+ for (const record of [scene.nodes, scene.ext ?? {}]) {
448
+ for (const key of Object.keys(record)) {
449
+ const id = Number(key);
450
+ if (Number.isSafeInteger(id) && id >= this.nextNodeId)
451
+ this.nextNodeId = id + 1;
452
+ }
453
+ }
454
+ }
320
455
  createNode(input) {
321
456
  const scene = this.requireDraft("createNode");
322
457
  assertId(input.parentId, "createNode", "parentId", true);
323
458
  if (!scene.nodes[String(input.parentId)])
324
459
  throw missingNode(input.parentId, "createNode");
325
- const id = nextId(scene.nodes);
460
+ const id = this.nextNodeId;
461
+ assertId(id, "createNode", "nodeId");
462
+ this.nextNodeId++;
326
463
  const initial = defaultNode(input.kind, id, input.name ?? "");
327
464
  const node = this.mergeNode(initial, input.properties ?? {});
328
465
  const children = this.treeArrays(scene);
@@ -349,12 +486,77 @@ export class KakapoAPI {
349
486
  const scene = this.requireDraft("updateNode");
350
487
  if (id === 0 && ("hidden" in changes || "transform" in changes))
351
488
  throw this.rootError("updateNode");
352
- const updated = this.mergeNode(wireToNode(scene, id), changes);
353
- scene.nodes[String(id)] = nodeToWire(updated);
489
+ const current = wireToNode(scene, id);
490
+ const updated = this.mergeNode(current, changes);
491
+ assertNode(updated, scene, "updateNode");
492
+ const before = nodeToWire(current);
493
+ const after = nodeToWire(updated);
494
+ // Only replace fields changed through the typed API. Re-encoding the whole
495
+ // node discards renderer fields this SDK version does not know about.
496
+ const raw = deepClone(scene.nodes[String(id)]);
497
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
498
+ if (JSON.stringify(before[key]) === JSON.stringify(after[key]))
499
+ continue;
500
+ if (Object.hasOwn(after, key))
501
+ raw[key] = after[key];
502
+ else
503
+ delete raw[key];
504
+ }
505
+ scene.nodes[String(id)] = raw;
354
506
  scene.ext = this.extWithNode(scene, updated);
355
507
  this.draftDirty = true;
356
508
  return deepClone(wireToNode(scene, id));
357
509
  }
510
+ curvePointDraft(id, index, operation) {
511
+ const scene = this.requireDraft(operation);
512
+ const node = this.getNode(id);
513
+ if (node.kind !== "curve")
514
+ throw this.unsupported(operation, id, "curve points");
515
+ if (!Number.isInteger(index) || index < 0 || index >= node.points.length)
516
+ throw new KakapoValidationError("Curve point index is out of range.", {
517
+ code: "INVALID_CURVE_POINT_INDEX", operation, received: index,
518
+ expected: `integer from 0 through ${node.points.length - 1}`,
519
+ });
520
+ return { scene, node, points: scene.nodes[String(id)].points };
521
+ }
522
+ updateCurvePoint(id, index, changes) {
523
+ const { scene, node, points } = this.curvePointDraft(id, index, "updateCurvePoint");
524
+ const previous = node.points[index];
525
+ const next = { ...previous, ...deepClone(changes) };
526
+ node.points[index] = next;
527
+ assertNode(node, scene, "updateCurvePoint");
528
+ const before = encodeCurvePoint(previous);
529
+ const after = encodeCurvePoint(next);
530
+ // Edit only requested typed fields; retain opaque native data on this point.
531
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
532
+ if (JSON.stringify(before[key]) === JSON.stringify(after[key]))
533
+ continue;
534
+ if (Object.hasOwn(after, key))
535
+ points[index][key] = after[key];
536
+ else
537
+ delete points[index][key];
538
+ }
539
+ this.draftDirty = true;
540
+ }
541
+ cloneCurvePoint(id, sourceIndex, index) {
542
+ const { points } = this.curvePointDraft(id, sourceIndex, "cloneCurvePoint");
543
+ if (!Number.isInteger(index) || index < 0 || index > points.length)
544
+ throw new KakapoValidationError("Curve insertion index is out of range.", {
545
+ code: "INVALID_CURVE_POINT_INDEX", operation: "cloneCurvePoint", received: index,
546
+ expected: `integer from 0 through ${points.length}`,
547
+ });
548
+ points.splice(index, 0, deepClone(points[sourceIndex]));
549
+ this.draftDirty = true;
550
+ }
551
+ deleteCurvePoint(id, index) {
552
+ const { points } = this.curvePointDraft(id, index, "deleteCurvePoint");
553
+ if (points.length <= 1)
554
+ throw new KakapoValidationError("A curve must keep at least one point.", {
555
+ code: "INVALID_CURVE_POINT_COUNT", operation: "deleteCurvePoint", expected: "at least one point",
556
+ });
557
+ points.splice(index, 1);
558
+ this.draftDirty = true;
559
+ }
358
560
  deleteNode(id, options = {}) {
359
561
  const scene = this.requireDraft("deleteNode");
360
562
  if (id === 0)
@@ -470,6 +672,25 @@ export class KakapoAPI {
470
672
  this.draftDirty = true;
471
673
  return deepClone(material);
472
674
  }
675
+ importMaterial(resource) {
676
+ const scene = this.requireDraft("importMaterial");
677
+ const source = deepClone(resource);
678
+ const materials = scene.storage.materials ?? (scene.storage.materials = {});
679
+ const id = nextId(materials);
680
+ assertMaterial(wireToMaterial(id, { data: source.data }).data, "importMaterial");
681
+ const shaders = scene.storage.shaders ?? (scene.storage.shaders = {});
682
+ const shaderIds = source.shaders.map((shader) => {
683
+ if (shader === null)
684
+ return 0;
685
+ const shaderId = nextId(shaders);
686
+ shaders[String(shaderId)] = shader;
687
+ return shaderId;
688
+ });
689
+ const material = { data: source.data, ...(shaderIds.length ? { shaders: shaderIds } : {}) };
690
+ materials[String(id)] = material;
691
+ this.draftDirty = true;
692
+ return deepClone(wireToMaterial(id, material));
693
+ }
473
694
  updateMaterial(id, data, shaderIds) {
474
695
  const scene = this.requireDraft("updateMaterial");
475
696
  const currentRaw = scene.storage.materials?.[String(id)];
@@ -478,10 +699,21 @@ export class KakapoAPI {
478
699
  const current = wireToMaterial(id, currentRaw);
479
700
  const updated = { id, data: this.mergeMaterialData(current.data, data), shaderIds: shaderIds ? [...shaderIds] : current.shaderIds };
480
701
  assertMaterial(updated.data, "updateMaterial");
702
+ const before = materialToWire(current);
481
703
  const next = materialToWire(updated);
482
- scene.storage.materials[String(id)] = shaderIds === undefined
483
- ? { ...currentRaw, data: next.data }
484
- : next;
704
+ const raw = { ...currentRaw, data: { ...currentRaw.data } };
705
+ for (const [key, value] of Object.entries(next.data)) {
706
+ if (value !== before.data[key])
707
+ raw.data[key] = value;
708
+ }
709
+ if (shaderIds !== undefined) {
710
+ delete raw.shader;
711
+ if (next.shaders)
712
+ raw.shaders = next.shaders;
713
+ else
714
+ delete raw.shaders;
715
+ }
716
+ scene.storage.materials[String(id)] = raw;
485
717
  this.draftDirty = true;
486
718
  return deepClone(updated);
487
719
  }
@@ -542,10 +774,34 @@ export class KakapoAPI {
542
774
  async reloadTextures() { return this.call("reload_textures"); }
543
775
  async getPendingResources() { return parseJsonResult(await this.call("get_pending_resources"), "get_pending_resources"); }
544
776
  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)]);
777
+ async sendPatch(operations, saveAfterPatch) {
778
+ let result;
779
+ try {
780
+ result = await this.call("scene_state_patch", [JSON.stringify(operations)]);
781
+ }
782
+ catch (cause) {
783
+ if (cause instanceof KakapoRpcError && cause.rpcCode === -32601)
784
+ throw new KakapoSceneCommitError("rolled_back", cause);
785
+ throw new KakapoSceneCommitError("unknown", cause);
786
+ }
547
787
  if (result !== undefined && result !== null && result !== "") {
548
- throw new KakapoRpcError("scene_state_patch", String(result));
788
+ const message = typeof result === "object" && "message" in result && typeof result.message === "string"
789
+ ? result.message
790
+ : JSON.stringify(result);
791
+ // The wrapper can apply geometry before a later extension patch fails.
792
+ throw new KakapoSceneCommitError("unknown", new KakapoRpcError("scene_state_patch", message));
793
+ }
794
+ if (!saveAfterPatch)
795
+ return { status: "not_needed" };
796
+ try {
797
+ const saved = await this.call("save_scene");
798
+ // A filename acknowledges the save handler, not a new durable snapshot.
799
+ if (typeof saved === "string" && saved.length > 0 && !saved.startsWith("{") && !saved.startsWith("["))
800
+ return { status: "acknowledged", filename: saved };
801
+ return { status: "unknown", error: `Save was not confirmed: ${JSON.stringify(saved)}` };
802
+ }
803
+ catch (error) {
804
+ return { status: "unknown", error: error instanceof Error ? error.message : String(error) };
549
805
  }
550
806
  }
551
807
  saveNode(id, changes) {
@@ -586,19 +842,24 @@ export class KakapoAPI {
586
842
  hint: "Finish the transaction, then perform the engine-backed read.",
587
843
  });
588
844
  }
589
- createSceneEdit() {
590
- return {
845
+ createSceneEdit(assertActive) {
846
+ const edit = {
591
847
  getScene: () => this.getScene(),
592
848
  listNodes: (options) => this.listNodes(options),
593
849
  findNodes: (options) => this.findNodes(options),
594
850
  getNode: (id) => this.getNode(id),
595
- node: ((id, kind) => this.node(id, kind)),
851
+ node: ((id, kind) => createNodeHandle({
852
+ readNode: (nodeId) => { assertActive(); return this.getNode(nodeId); },
853
+ saveNode: (nodeId, changes) => { assertActive(); return this.saveNode(nodeId, changes); },
854
+ }, id, kind)),
596
855
  getNodeName: (id) => this.getNodeName(id),
597
856
  getNodeParent: (id) => this.getNodeParent(id),
598
857
  getNodeChildren: (id) => this.getNodeChildren(id),
599
858
  applyScenePatch: (operations) => this.applyScenePatch(operations),
600
859
  getSceneExt: () => this.getSceneExt(),
601
860
  patchSceneExt: (operations) => this.patchSceneExt(operations),
861
+ getSceneStorage: () => this.getSceneStorage(),
862
+ patchSceneStorage: (operations) => this.patchSceneStorage(operations),
602
863
  getSceneProperties: () => this.getSceneProperties(),
603
864
  patchSceneProperties: (operations) => this.patchSceneProperties(operations),
604
865
  createNode: (input) => this.createNode(input),
@@ -617,6 +878,9 @@ export class KakapoAPI {
617
878
  setNodeOperation: (id, operation) => this.setNodeOperation(id, operation),
618
879
  setTextContent: (id, text) => this.setTextContent(id, text),
619
880
  setTextFont: (id, family, weight, italic) => this.setTextFont(id, family, weight, italic),
881
+ updateCurvePoint: (id, index, changes) => this.updateCurvePoint(id, index, changes),
882
+ cloneCurvePoint: (id, sourceIndex, index) => this.cloneCurvePoint(id, sourceIndex, index),
883
+ deleteCurvePoint: (id, index) => this.deleteCurvePoint(id, index),
620
884
  setSvgPaths: (id, paths) => this.setSvgPaths(id, paths),
621
885
  setMeshSource: (id, mesh, index) => this.setMeshSource(id, mesh, index),
622
886
  setFieldSource: (id, field) => this.setFieldSource(id, field),
@@ -626,6 +890,7 @@ export class KakapoAPI {
626
890
  listMaterials: () => this.listMaterials(),
627
891
  getMaterial: (id) => this.getMaterial(id),
628
892
  createMaterial: (data, shaderIds) => this.createMaterial(data, shaderIds),
893
+ importMaterial: (resource) => this.importMaterial(resource),
629
894
  updateMaterial: (id, data, shaderIds) => this.updateMaterial(id, data, shaderIds),
630
895
  deleteMaterial: (id) => this.deleteMaterial(id),
631
896
  listOpenScadScripts: () => this.listOpenScadScripts(),
@@ -634,6 +899,18 @@ export class KakapoAPI {
634
899
  updateOpenScadScript: (id, changes) => this.updateOpenScadScript(id, changes),
635
900
  deleteOpenScadScript: (id) => this.deleteOpenScadScript(id),
636
901
  };
902
+ return new Proxy(edit, {
903
+ get(target, property, receiver) {
904
+ assertActive();
905
+ const method = Reflect.get(target, property, receiver);
906
+ if (typeof method !== "function")
907
+ return method;
908
+ return (...args) => {
909
+ assertActive();
910
+ return Reflect.apply(method, target, args);
911
+ };
912
+ },
913
+ });
637
914
  }
638
915
  treeArrays(scene) { return Object.fromEntries(Object.keys(scene.tree ?? {}).map((id) => [id, orderedChildren(scene, Number(id))])); }
639
916
  extWithName(scene, id, name) {
@@ -669,10 +946,10 @@ export class KakapoAPI {
669
946
  const common = ["transform", "pickable"];
670
947
  const byKind = {
671
948
  primitive: ["hidden", "materialId", "operation", "blend", "color", "primitive", "round", "thickness", "inflation", "materialNeutralCutout", "mirrors"],
672
- union: ["hidden", "operation", "blend", "resolution", "thickness", "inflation", "materialNeutralCutout"],
949
+ union: ["hidden", "operation", "blend", "resolution", "thickness", "inflation", "materialNeutralCutout", "scriptInstanceId"],
673
950
  group: ["hidden"],
674
951
  light: ["color", "power", "collimation", "lightType", "size", "textureFile"],
675
- curve: ["hidden", "materialId", "operation", "blend", "primitive", "density", "roundness", "smoothing", "points", "materialNeutralCutout", "mirrors"],
952
+ curve: ["hidden", "materialId", "operation", "blend", "primitive", "density", "roundness", "smoothing", "points", "materialNeutralCutout", "mirrors", "version", "curveType", "closed"],
676
953
  text: ["hidden", "materialId", "operation", "blend", "text", "fontFamily", "weight", "italic", "round", "width", "wrap", "align", "spacing", "lineHeight", "materialNeutralCutout"],
677
954
  svg: ["hidden", "materialId", "operation", "blend", "paths", "inflate", "outline", "outlineSize", "materialNeutralCutout"],
678
955
  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.1",
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
+ }