@womp/kakapo-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import { type WireScene } from "./scene.js";
2
+ import type { FontFamily, KakapoNode, MaterialData, Transform } from "./types.js";
3
+ export declare function assertId(value: number, operation: string, path?: string, allowZero?: boolean): void;
4
+ export declare function assertTransform(value: Transform, operation: string): void;
5
+ export declare function assertNode(node: KakapoNode, scene: WireScene, operation: string): void;
6
+ export declare function assertScene(scene: WireScene, operation: string): void;
7
+ export declare function assertMaterial(data: MaterialData, operation: string): void;
8
+ export declare function assertFont(fonts: FontFamily[], family: string, weight: number, italic: boolean, operation: string): void;
@@ -0,0 +1,195 @@
1
+ import { KakapoValidationError } from "./errors.js";
2
+ import { kindFromWire, parentMap, wireToNode } from "./scene.js";
3
+ const CONTAINERS = new Set(["union", "group"]);
4
+ const GEOMETRY = new Set(["primitive", "curve", "text", "field", "svg", "mesh", "decal", "openScad"]);
5
+ function fail(message, operation, path, received, expected, hint, code = "VALIDATION_ERROR") {
6
+ throw new KakapoValidationError(message, { code, operation, path, received, expected, hint });
7
+ }
8
+ export function assertId(value, operation, path = "nodeId", allowZero = false) {
9
+ if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) {
10
+ fail(`${path} must be a ${allowZero ? "non-negative" : "positive"} safe integer.`, operation, path, value, allowZero ? "safe integer >= 0" : "safe integer >= 1", "Use an ID returned by a KakapoAPI create or list method.", "INVALID_ID");
11
+ }
12
+ }
13
+ function assertFinite(value, operation, path, min, max) {
14
+ if (!Number.isFinite(value) || (min !== undefined && value < min) || (max !== undefined && value > max)) {
15
+ const range = min !== undefined || max !== undefined ? ` in [${min ?? "-∞"}, ${max ?? "∞"}]` : "";
16
+ fail(`${path} must be a finite number${range}.`, operation, path, value, `finite number${range}`, "Use a numeric value inside Kakapo's supported range.", "INVALID_NUMBER");
17
+ }
18
+ }
19
+ function assertVec2(value, operation, path) {
20
+ assertFinite(value.x, operation, `${path}.x`);
21
+ assertFinite(value.y, operation, `${path}.y`);
22
+ }
23
+ function assertVec3(value, operation, path) {
24
+ assertFinite(value.x, operation, `${path}.x`);
25
+ assertFinite(value.y, operation, `${path}.y`);
26
+ assertFinite(value.z, operation, `${path}.z`);
27
+ }
28
+ function assertColor(value, operation, path) {
29
+ assertFinite(value.x, operation, `${path}.x`, 0, 1);
30
+ assertFinite(value.y, operation, `${path}.y`, 0, 1);
31
+ assertFinite(value.z, operation, `${path}.z`, 0, 1);
32
+ }
33
+ export function assertTransform(value, operation) {
34
+ assertVec3(value.position, operation, "transform.position");
35
+ assertVec3(value.rotation, operation, "transform.rotation");
36
+ assertVec3(value.scale, operation, "transform.scale");
37
+ if (value.scale.x <= 0 || value.scale.y <= 0 || value.scale.z <= 0) {
38
+ fail("Node scale components must be greater than zero.", operation, "transform.scale", value.scale, "positive finite Vec3", "Use scale components greater than zero.", "INVALID_SCALE");
39
+ }
40
+ }
41
+ function assertAssetName(value, operation, path) {
42
+ if (!value.trim() || value.includes("\0") || value.length > 1024) {
43
+ fail(`${path} must be a non-empty asset reference without null bytes.`, operation, path, value, "1-1024 character asset name", "Pass the asset name/path already available to the connected Kakapo instance.", "INVALID_ASSET_REFERENCE");
44
+ }
45
+ }
46
+ function assertSvgPaths(paths, operation) {
47
+ if (!Array.isArray(paths) || paths.length === 0)
48
+ fail("SVG paths cannot be empty.", operation, "paths", paths, "at least one path", "Provide one or more paths with at least two segments.", "INVALID_SVG_PATHS");
49
+ paths.forEach((path, pathIndex) => {
50
+ if (path.segments.length < 2)
51
+ fail("Each SVG path requires at least two segments.", operation, `paths[${pathIndex}].segments`, path.segments, "at least two SVG segments", undefined, "INVALID_SVG_PATHS");
52
+ path.segments.forEach((segment, segmentIndex) => {
53
+ assertVec2(segment.point, operation, `paths[${pathIndex}].segments[${segmentIndex}].point`);
54
+ assertVec2(segment.handleIn, operation, `paths[${pathIndex}].segments[${segmentIndex}].handleIn`);
55
+ assertVec2(segment.handleOut, operation, `paths[${pathIndex}].segments[${segmentIndex}].handleOut`);
56
+ });
57
+ });
58
+ }
59
+ export function assertNode(node, scene, operation) {
60
+ assertId(node.id, operation, "node.id", true);
61
+ assertTransform(node.transform, operation);
62
+ if (node.name.includes("\0"))
63
+ fail("Node names cannot contain null bytes.", operation, "name", node.name, "string without null bytes");
64
+ if ("materialId" in node && node.materialId !== null) {
65
+ assertId(node.materialId, operation, "materialId");
66
+ if (!scene.storage.materials?.[String(node.materialId)])
67
+ fail(`Material ${node.materialId} does not exist.`, operation, "materialId", node.materialId, "existing material ID", "Call createMaterial() or listMaterials() before assigning it.", "MATERIAL_NOT_FOUND");
68
+ }
69
+ if ("blend" in node)
70
+ assertFinite(node.blend, operation, "blend", 0, 500);
71
+ switch (node.kind) {
72
+ case "primitive":
73
+ assertColor(node.color, operation, "color");
74
+ assertFinite(node.round, operation, "round", 0, 1);
75
+ assertFinite(node.thickness, operation, "thickness", -1, 10);
76
+ assertFinite(node.inflation, operation, "inflation", -10, 10);
77
+ break;
78
+ case "union":
79
+ assertFinite(node.resolution, operation, "resolution", 0.01, 1);
80
+ assertFinite(node.thickness, operation, "thickness", -1, 10);
81
+ assertFinite(node.inflation, operation, "inflation", -10, 10);
82
+ break;
83
+ case "light":
84
+ assertColor(node.color, operation, "color");
85
+ assertFinite(node.power, operation, "power", 0, 100);
86
+ assertFinite(node.collimation, operation, "collimation", 0, 1);
87
+ assertVec2(node.size, operation, "size");
88
+ if (node.size.x < 0.01 || node.size.y < 0.01)
89
+ fail("Light size components must be at least 0.01.", operation, "size", node.size, "Vec2 components >= 0.01");
90
+ break;
91
+ case "curve":
92
+ assertFinite(node.density, operation, "density", 1);
93
+ assertFinite(node.roundness, operation, "roundness", 0);
94
+ assertFinite(node.smoothing, operation, "smoothing", 0, 1);
95
+ if (!node.points.length)
96
+ 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`); });
98
+ break;
99
+ case "text":
100
+ if (!node.text.length)
101
+ fail("Text content cannot be empty.", operation, "text", node.text, "non-empty string");
102
+ assertFinite(node.weight, operation, "weight", 1, 1000);
103
+ assertFinite(node.width, operation, "width", 0, 50);
104
+ assertFinite(node.spacing, operation, "spacing", -2, 2);
105
+ assertFinite(node.lineHeight, operation, "lineHeight", 0, 2);
106
+ break;
107
+ case "svg":
108
+ assertSvgPaths(node.paths, operation);
109
+ assertFinite(node.outlineSize, operation, "outlineSize", 0.0000001, 5);
110
+ break;
111
+ case "mesh":
112
+ assertAssetName(node.mesh, operation, "mesh");
113
+ assertId(node.meshIndex, operation, "meshIndex", true);
114
+ break;
115
+ case "field":
116
+ assertAssetName(node.field, operation, "field");
117
+ assertFinite(node.inflation, operation, "inflation", 0, 10);
118
+ break;
119
+ case "decal":
120
+ assertAssetName(node.image, operation, "image");
121
+ break;
122
+ case "openScad":
123
+ assertId(node.scriptId, operation, "scriptId");
124
+ if (!scene.storage.openscad?.scripts?.[String(node.scriptId)])
125
+ fail(`OpenSCAD script ${node.scriptId} does not exist.`, operation, "scriptId", node.scriptId, "existing OpenSCAD script ID", "Create the script before assigning it to a node.", "SCRIPT_NOT_FOUND");
126
+ break;
127
+ case "socket":
128
+ if (node.tag.includes("\0"))
129
+ fail("Socket tags cannot contain null bytes.", operation, "tag", node.tag, "string without null bytes");
130
+ break;
131
+ case "group": break;
132
+ }
133
+ }
134
+ export function assertScene(scene, operation) {
135
+ if (!scene.nodes || !scene.nodes["0"] || kindFromWire(scene.nodes["0"].type) !== "group") {
136
+ fail("Scene root node 0 must exist and be a Group.", operation, "nodes.0", scene.nodes?.["0"], "root Group node", "Refresh the scene or restore node 0.", "INVALID_SCENE_ROOT");
137
+ }
138
+ const parents = parentMap(scene);
139
+ const occurrences = new Map();
140
+ for (const [parentIdText, children] of Object.entries(scene.tree ?? {})) {
141
+ const parentId = Number(parentIdText);
142
+ const parentRaw = scene.nodes[parentIdText];
143
+ if (!parentRaw)
144
+ fail(`Tree parent ${parentId} does not exist.`, operation, `tree.${parentId}`, parentId, "existing parent node", undefined, "MISSING_TREE_NODE");
145
+ const parentKind = kindFromWire(parentRaw.type);
146
+ if (!CONTAINERS.has(parentKind))
147
+ fail(`Node ${parentId} (${parentKind}) cannot contain children.`, operation, `tree.${parentId}`, parentKind, "Union or Group parent", "Reparent the children under a Union or Group.", "LEAF_NODE_HAS_CHILDREN");
148
+ for (const childId of Object.values(children)) {
149
+ if (!scene.nodes[String(childId)])
150
+ fail(`Tree child ${childId} does not exist.`, operation, `tree.${parentId}`, childId, "existing child node", undefined, "MISSING_TREE_NODE");
151
+ occurrences.set(childId, (occurrences.get(childId) ?? 0) + 1);
152
+ }
153
+ }
154
+ for (const idText of Object.keys(scene.nodes)) {
155
+ const id = Number(idText);
156
+ if (id === 0)
157
+ continue;
158
+ const count = occurrences.get(id) ?? 0;
159
+ if (count !== 1)
160
+ fail(`Node ${id} must appear exactly once in the scene tree; found ${count}.`, operation, `tree`, { nodeId: id, occurrences: count }, "exactly one parent", "Use setNodeParent() to repair the tree.", "INVALID_TREE_MEMBERSHIP");
161
+ const visited = new Set([id]);
162
+ let cursor = id;
163
+ let hasUnion = false;
164
+ while (parents.has(cursor)) {
165
+ cursor = parents.get(cursor);
166
+ if (visited.has(cursor))
167
+ fail(`Scene tree contains a cycle through node ${cursor}.`, operation, "tree", cursor, "acyclic hierarchy", undefined, "TREE_CYCLE");
168
+ visited.add(cursor);
169
+ if (scene.nodes[String(cursor)]?.type === 1)
170
+ hasUnion = true;
171
+ }
172
+ const kind = kindFromWire(scene.nodes[idText].type);
173
+ if (GEOMETRY.has(kind) && !hasUnion)
174
+ fail(`Node ${id} (${kind}) must be a child or descendant of a Union.`, operation, `nodes.${id}`, { id, kind }, "Union ancestor", "Create or select a Union and parent this geometry beneath it.", "UNION_ANCESTOR_REQUIRED");
175
+ }
176
+ for (const idText of Object.keys(scene.nodes))
177
+ assertNode(wireToNode(scene, Number(idText)), scene, operation);
178
+ }
179
+ export function assertMaterial(data, operation) {
180
+ assertColor(data.color, operation, "data.color");
181
+ assertColor(data.secondaryColor, operation, "data.secondaryColor");
182
+ assertColor(data.subsurface, operation, "data.subsurface");
183
+ for (const key of ["metalness", "roughness", "transmittance", "translucencyWeight", "iridescence", "emission", "absorption", "sheen", "sheenRoughness", "specularTint", "surfaceOpacity"])
184
+ assertFinite(data[key], operation, `data.${key}`, 0, 1);
185
+ assertFinite(data.translucency, operation, "data.translucency", 0, 100);
186
+ assertFinite(data.indexOfRefraction, operation, "data.indexOfRefraction", 1, 3);
187
+ assertFinite(data.dispersion, operation, "data.dispersion", 0, 11);
188
+ }
189
+ export function assertFont(fonts, family, weight, italic, operation) {
190
+ const group = fonts.find((font) => font.name === family);
191
+ if (!group)
192
+ fail(`Font family '${family}' is unavailable.`, operation, "fontFamily", family, "font returned by listFonts()", `Available families: ${fonts.map((f) => f.name).join(", ")}`, "FONT_NOT_FOUND");
193
+ if (!group.fonts.some((font) => font.weight === weight && font.italic === italic))
194
+ fail(`Font '${family}' has no ${weight}${italic ? " italic" : " regular"} face.`, operation, "font", { family, weight, italic }, "available font face", `Available faces: ${group.fonts.map((f) => `${f.weight}${f.italic ? " italic" : ""}`).join(", ")}`, "FONT_FACE_NOT_FOUND");
195
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@womp/kakapo-sdk",
3
+ "version": "0.1.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": ">=20"
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
+ }