@womp/kakapo-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/api.d.ts +151 -0
- package/dist/api.js +671 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.js +56 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/node-handle.d.ts +32 -0
- package/dist/node-handle.js +69 -0
- package/dist/scene.d.ts +59 -0
- package/dist/scene.js +397 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +317 -0
- package/dist/types.d.ts +359 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +8 -0
- package/dist/validation.js +195 -0
- package/package.json +53 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface KakapoErrorOptions {
|
|
2
|
+
code: string;
|
|
3
|
+
operation?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
received?: unknown;
|
|
6
|
+
expected?: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
details?: Record<string, unknown>;
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare class KakapoError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly operation?: string;
|
|
14
|
+
readonly path?: string;
|
|
15
|
+
readonly received?: unknown;
|
|
16
|
+
readonly expected?: string;
|
|
17
|
+
readonly hint?: string;
|
|
18
|
+
readonly details?: Record<string, unknown>;
|
|
19
|
+
constructor(message: string, options: KakapoErrorOptions);
|
|
20
|
+
toJSON(): Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export declare class KakapoConnectionError extends KakapoError {
|
|
23
|
+
}
|
|
24
|
+
export declare class KakapoTimeoutError extends KakapoError {
|
|
25
|
+
}
|
|
26
|
+
export declare class KakapoRpcError extends KakapoError {
|
|
27
|
+
readonly rpcCode?: number;
|
|
28
|
+
readonly method: string;
|
|
29
|
+
constructor(method: string, message: string, rpcCode?: number, details?: Record<string, unknown>);
|
|
30
|
+
}
|
|
31
|
+
export declare class KakapoValidationError extends KakapoError {
|
|
32
|
+
constructor(message: string, options?: Omit<KakapoErrorOptions, "code"> & {
|
|
33
|
+
code?: string;
|
|
34
|
+
});
|
|
35
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export class KakapoError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
operation;
|
|
4
|
+
path;
|
|
5
|
+
received;
|
|
6
|
+
expected;
|
|
7
|
+
hint;
|
|
8
|
+
details;
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
this.code = options.code;
|
|
13
|
+
this.operation = options.operation;
|
|
14
|
+
this.path = options.path;
|
|
15
|
+
this.received = options.received;
|
|
16
|
+
this.expected = options.expected;
|
|
17
|
+
this.hint = options.hint;
|
|
18
|
+
this.details = options.details;
|
|
19
|
+
}
|
|
20
|
+
toJSON() {
|
|
21
|
+
return Object.fromEntries(Object.entries({
|
|
22
|
+
name: this.name,
|
|
23
|
+
code: this.code,
|
|
24
|
+
message: this.message,
|
|
25
|
+
operation: this.operation,
|
|
26
|
+
path: this.path,
|
|
27
|
+
received: this.received,
|
|
28
|
+
expected: this.expected,
|
|
29
|
+
hint: this.hint,
|
|
30
|
+
details: this.details,
|
|
31
|
+
}).filter(([, value]) => value !== undefined));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class KakapoConnectionError extends KakapoError {
|
|
35
|
+
}
|
|
36
|
+
export class KakapoTimeoutError extends KakapoError {
|
|
37
|
+
}
|
|
38
|
+
export class KakapoRpcError extends KakapoError {
|
|
39
|
+
rpcCode;
|
|
40
|
+
method;
|
|
41
|
+
constructor(method, message, rpcCode, details) {
|
|
42
|
+
super(`Kakapo RPC '${method}' failed: ${message}`, {
|
|
43
|
+
code: "RPC_ERROR",
|
|
44
|
+
operation: method,
|
|
45
|
+
details: { ...details, rpcCode },
|
|
46
|
+
hint: "Correct the parameters or inspect the engine log for the backend failure.",
|
|
47
|
+
});
|
|
48
|
+
this.method = method;
|
|
49
|
+
this.rpcCode = rpcCode;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class KakapoValidationError extends KakapoError {
|
|
53
|
+
constructor(message, options = {}) {
|
|
54
|
+
super(message, { code: options.code ?? "VALIDATION_ERROR", ...options });
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { KakapoAPI } from "./api.js";
|
|
2
|
+
export type { SceneEdit } from "./api.js";
|
|
3
|
+
export { KakapoError, KakapoConnectionError, KakapoTimeoutError, KakapoRpcError, KakapoValidationError, } from "./errors.js";
|
|
4
|
+
export { parseBinaryFrame } from "./transport.js";
|
|
5
|
+
export type { NodeHandle, NodeHandleActions } from "./node-handle.js";
|
|
6
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { KakapoNode, NodeKind, NodeUpdate, Vec3 } from "./types.js";
|
|
2
|
+
type NodeOfKind<K extends NodeKind> = Extract<KakapoNode, {
|
|
3
|
+
kind: K;
|
|
4
|
+
}>;
|
|
5
|
+
export interface NodeHandleActions<N extends KakapoNode> {
|
|
6
|
+
readonly dirty: boolean;
|
|
7
|
+
readonly value: N;
|
|
8
|
+
save(): N;
|
|
9
|
+
reset(): void;
|
|
10
|
+
}
|
|
11
|
+
type NodeHandleAliases<N extends KakapoNode> = {
|
|
12
|
+
position: Vec3;
|
|
13
|
+
rotation: Vec3;
|
|
14
|
+
} & (N extends {
|
|
15
|
+
kind: "light";
|
|
16
|
+
} ? {} : {
|
|
17
|
+
scale: Vec3;
|
|
18
|
+
}) & (N extends {
|
|
19
|
+
hidden: boolean;
|
|
20
|
+
} ? {
|
|
21
|
+
visible: boolean;
|
|
22
|
+
} : {});
|
|
23
|
+
export type NodeHandle<N extends KakapoNode = KakapoNode> = Omit<N, "id" | "kind"> & Readonly<Pick<N, "id" | "kind">> & NodeHandleAliases<N> & NodeHandleActions<N>;
|
|
24
|
+
export interface NodeHandleOwner {
|
|
25
|
+
readNode(id: number): KakapoNode;
|
|
26
|
+
saveNode(id: number, changes: NodeUpdate & {
|
|
27
|
+
name?: string;
|
|
28
|
+
parentId?: number;
|
|
29
|
+
}): KakapoNode;
|
|
30
|
+
}
|
|
31
|
+
export declare function createNodeHandle<K extends NodeKind>(owner: NodeHandleOwner, id: number, expectedKind?: K): NodeHandle<NodeOfKind<K>>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { deepClone } from "./scene.js";
|
|
2
|
+
const RESERVED_KEYS = new Set(["dirty", "value", "save", "reset"]);
|
|
3
|
+
const TRANSFORM_KEYS = new Set(["position", "rotation", "scale"]);
|
|
4
|
+
function same(left, right) {
|
|
5
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
+
}
|
|
7
|
+
export function createNodeHandle(owner, id, expectedKind) {
|
|
8
|
+
let original = owner.readNode(id);
|
|
9
|
+
if (expectedKind !== undefined && original.kind !== expectedKind) {
|
|
10
|
+
throw new TypeError(`Node ${id} is ${original.kind}, not ${expectedKind}.`);
|
|
11
|
+
}
|
|
12
|
+
let draft = deepClone(original);
|
|
13
|
+
const actions = {
|
|
14
|
+
get dirty() { return !same(original, draft); },
|
|
15
|
+
get value() { return deepClone(draft); },
|
|
16
|
+
save() {
|
|
17
|
+
const changes = {};
|
|
18
|
+
for (const key of Object.keys(draft)) {
|
|
19
|
+
if (key === "id" || key === "kind")
|
|
20
|
+
continue;
|
|
21
|
+
if (same(original[key], draft[key]))
|
|
22
|
+
continue;
|
|
23
|
+
changes[key] = deepClone(draft[key]);
|
|
24
|
+
}
|
|
25
|
+
const saved = owner.saveNode(id, changes);
|
|
26
|
+
original = deepClone(saved);
|
|
27
|
+
draft = deepClone(saved);
|
|
28
|
+
return deepClone(saved);
|
|
29
|
+
},
|
|
30
|
+
reset() {
|
|
31
|
+
original = owner.readNode(id);
|
|
32
|
+
draft = deepClone(original);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return new Proxy(actions, {
|
|
36
|
+
get(target, property, receiver) {
|
|
37
|
+
if (typeof property === "string" && TRANSFORM_KEYS.has(property)) {
|
|
38
|
+
return deepClone(draft.transform[property]);
|
|
39
|
+
}
|
|
40
|
+
if (property === "visible" && "hidden" in draft)
|
|
41
|
+
return !draft.hidden;
|
|
42
|
+
if (typeof property === "string" && property in draft) {
|
|
43
|
+
return deepClone(draft[property]);
|
|
44
|
+
}
|
|
45
|
+
return target[property];
|
|
46
|
+
},
|
|
47
|
+
set(_target, property, value) {
|
|
48
|
+
if (property === "id" || property === "kind") {
|
|
49
|
+
throw new TypeError(`Node ${String(property)} is immutable.`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof property === "string" && TRANSFORM_KEYS.has(property)) {
|
|
52
|
+
if (property === "scale" && draft.kind === "light") {
|
|
53
|
+
throw new TypeError("Light nodes use a 2D size instead of 3D scale.");
|
|
54
|
+
}
|
|
55
|
+
draft.transform[property] = deepClone(value);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (property === "visible" && "hidden" in draft) {
|
|
59
|
+
draft.hidden = !value;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (typeof property !== "string" || RESERVED_KEYS.has(property) || !(property in draft)) {
|
|
63
|
+
throw new TypeError(`Unknown or readonly node property '${String(property)}'.`);
|
|
64
|
+
}
|
|
65
|
+
draft[property] = deepClone(value);
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
package/dist/scene.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { KakapoValidationError } from "./errors.js";
|
|
2
|
+
import type { JsonPatchOperation, KakapoNode, Material, MaterialData, NodeKind, Scene, Transform } from "./types.js";
|
|
3
|
+
export interface WireNode {
|
|
4
|
+
type: number;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface WireScene {
|
|
8
|
+
version?: number;
|
|
9
|
+
name?: string;
|
|
10
|
+
nodes: Record<string, WireNode>;
|
|
11
|
+
tree: Record<string, Record<string, number>>;
|
|
12
|
+
ext?: Record<string, unknown>;
|
|
13
|
+
storage: {
|
|
14
|
+
materials?: Record<string, {
|
|
15
|
+
data?: Record<string, unknown>;
|
|
16
|
+
shaders?: number[];
|
|
17
|
+
shader?: number;
|
|
18
|
+
}>;
|
|
19
|
+
shaders?: Record<string, unknown>;
|
|
20
|
+
openscad?: {
|
|
21
|
+
scripts?: Record<string, {
|
|
22
|
+
name?: string;
|
|
23
|
+
source?: string;
|
|
24
|
+
}>;
|
|
25
|
+
};
|
|
26
|
+
scripts?: Record<string, unknown>;
|
|
27
|
+
animations?: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
properties?: Record<string, unknown>;
|
|
30
|
+
camera?: Record<string, unknown>;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
export declare const identityTransform: () => Transform;
|
|
34
|
+
export declare function deepClone<T>(value: T): T;
|
|
35
|
+
export declare function parseJsonResult<T>(value: unknown, operation: string): T;
|
|
36
|
+
export declare function kindFromWire(type: number): NodeKind;
|
|
37
|
+
export declare function kindToWire(kind: NodeKind): number;
|
|
38
|
+
export declare function parentMap(scene: WireScene): Map<number, number>;
|
|
39
|
+
export declare function orderedChildren(scene: WireScene, parentId: number): number[];
|
|
40
|
+
export declare function encodeTree(tree: Record<string, number[]>): WireScene["tree"];
|
|
41
|
+
export declare function wireToNode(scene: WireScene, id: number): KakapoNode;
|
|
42
|
+
export declare function defaultNode(kind: NodeKind, id: number, name?: string): KakapoNode;
|
|
43
|
+
export declare function nodeToWire(node: KakapoNode): WireNode;
|
|
44
|
+
export declare const defaultMaterialData: () => MaterialData;
|
|
45
|
+
export declare function wireToMaterial(id: number, raw: {
|
|
46
|
+
data?: Record<string, unknown>;
|
|
47
|
+
shaders?: number[];
|
|
48
|
+
shader?: number;
|
|
49
|
+
}): Material;
|
|
50
|
+
export declare function materialToWire(material: Material): {
|
|
51
|
+
data: Record<string, unknown>;
|
|
52
|
+
shaders?: number[];
|
|
53
|
+
};
|
|
54
|
+
export declare function parseSafePositiveId(value: string): number | undefined;
|
|
55
|
+
export declare function toPublicScene(raw: WireScene): Scene;
|
|
56
|
+
export declare function missingNode(id: number, operation: string): KakapoValidationError;
|
|
57
|
+
export declare function applyJsonPatch<T>(document: T, operations: readonly JsonPatchOperation[]): T;
|
|
58
|
+
export declare function nextId(record: Record<string, unknown>): number;
|
|
59
|
+
export declare function escapePointer(value: string): string;
|
package/dist/scene.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { KakapoValidationError } from "./errors.js";
|
|
2
|
+
const KINDS = [
|
|
3
|
+
"primitive", "union", "light", "curve", "group", "text", "field", "svg", "mesh", "decal", "openScad", "socket",
|
|
4
|
+
];
|
|
5
|
+
const OPERATIONS = ["subtract", "union", "brush", "intersect"];
|
|
6
|
+
const PRIMITIVES = [
|
|
7
|
+
"sphere", "cylinder", "box", "cone", "torus", "link", "hexagon", "triangular", "octahedron", "pyramid", "glyph", "field", "ellipsoid", "capsule",
|
|
8
|
+
];
|
|
9
|
+
const FIELD_SAMPLING = ["ignore", "average", "override", "overrideMaterial", "multiply"];
|
|
10
|
+
const TEXT_WRAP = ["disabled", "always", "whiteSpace"];
|
|
11
|
+
const TEXT_ALIGN = ["left", "center", "right"];
|
|
12
|
+
const LIGHT_TYPES = ["rect", "sphere", "distant"];
|
|
13
|
+
const v2 = (x = 0, y = 0) => ({ x, y });
|
|
14
|
+
const v3 = (x = 0, y = x, z = x) => ({ x, y, z });
|
|
15
|
+
export const identityTransform = () => ({ position: v3(0), rotation: v3(0), scale: v3(1) });
|
|
16
|
+
export function deepClone(value) {
|
|
17
|
+
return structuredClone(value);
|
|
18
|
+
}
|
|
19
|
+
export function parseJsonResult(value, operation) {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return value;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(value);
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
throw new KakapoValidationError(`Kakapo RPC '${operation}' returned invalid nested JSON.`, {
|
|
27
|
+
code: "INVALID_RPC_JSON",
|
|
28
|
+
operation,
|
|
29
|
+
received: value.slice(0, 300),
|
|
30
|
+
expected: "valid JSON string",
|
|
31
|
+
cause,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function kindFromWire(type) {
|
|
36
|
+
const kind = KINDS[type];
|
|
37
|
+
if (!kind) {
|
|
38
|
+
throw new KakapoValidationError(`Unknown Kakapo node type '${type}'.`, {
|
|
39
|
+
code: "UNKNOWN_NODE_TYPE",
|
|
40
|
+
operation: "decodeScene",
|
|
41
|
+
path: "node.type",
|
|
42
|
+
received: type,
|
|
43
|
+
expected: "integer from 0 through 11",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return kind;
|
|
47
|
+
}
|
|
48
|
+
export function kindToWire(kind) {
|
|
49
|
+
return KINDS.indexOf(kind);
|
|
50
|
+
}
|
|
51
|
+
function num(value, fallback) {
|
|
52
|
+
if (typeof value === "number")
|
|
53
|
+
return value;
|
|
54
|
+
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
|
|
55
|
+
return Number(value);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
function bool(value, fallback) { return typeof value === "boolean" ? value : fallback; }
|
|
59
|
+
function str(value, fallback = "") { return typeof value === "string" ? value : fallback; }
|
|
60
|
+
function vec2(value, fallback = v2()) {
|
|
61
|
+
const o = value && typeof value === "object" ? value : {};
|
|
62
|
+
return { x: num(o.x, fallback.x), y: num(o.y, fallback.y) };
|
|
63
|
+
}
|
|
64
|
+
function vec3(value, fallback = v3()) {
|
|
65
|
+
const o = value && typeof value === "object" ? value : {};
|
|
66
|
+
return { x: num(o.x, fallback.x), y: num(o.y, fallback.y), z: num(o.z, fallback.z) };
|
|
67
|
+
}
|
|
68
|
+
export function parentMap(scene) {
|
|
69
|
+
const result = new Map();
|
|
70
|
+
for (const [parent, children] of Object.entries(scene.tree ?? {})) {
|
|
71
|
+
for (const child of Object.values(children))
|
|
72
|
+
result.set(child, Number(parent));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
export function orderedChildren(scene, parentId) {
|
|
77
|
+
return Object.entries(scene.tree?.[String(parentId)] ?? {})
|
|
78
|
+
.sort(([a], [b]) => Number.parseInt(a, 16) - Number.parseInt(b, 16))
|
|
79
|
+
.map(([, id]) => id);
|
|
80
|
+
}
|
|
81
|
+
export function encodeTree(tree) {
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const [parent, children] of Object.entries(tree)) {
|
|
84
|
+
if (children.length === 0)
|
|
85
|
+
continue;
|
|
86
|
+
result[parent] = Object.fromEntries(children.map((id, index) => [((index + 1) * 0x10000).toString(16).padStart(8, "0"), id]));
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
function nodeName(scene, id) {
|
|
91
|
+
const ext = scene.ext?.[String(id)];
|
|
92
|
+
return ext && typeof ext === "object" ? str(ext.name) : "";
|
|
93
|
+
}
|
|
94
|
+
function record(value) {
|
|
95
|
+
return value && typeof value === "object" ? value : {};
|
|
96
|
+
}
|
|
97
|
+
function mirrorPlane(scene, id, raw, index) {
|
|
98
|
+
const plane = record(raw[`mirror_plane_${index}`]);
|
|
99
|
+
const ext = record(record(scene.ext?.[String(id)])[`mirror_plane_ext_${index}`]);
|
|
100
|
+
const constraint = Math.max(0, Math.min(3, Math.round(num(ext.constraint, 0))));
|
|
101
|
+
return {
|
|
102
|
+
position: vec3(plane.position),
|
|
103
|
+
rotation: vec3(plane.rotation),
|
|
104
|
+
value: num(plane.value, -1),
|
|
105
|
+
hide: num(plane.hide, 0),
|
|
106
|
+
hideMirrorPlane: bool(ext.hideMirrorPlane, false),
|
|
107
|
+
constraint,
|
|
108
|
+
...(ext.customRotation ? { customRotation: vec3(ext.customRotation) } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function mirrorPlanes(scene, id, raw) {
|
|
112
|
+
return [0, 1, 2, 3].map((index) => mirrorPlane(scene, id, raw, index));
|
|
113
|
+
}
|
|
114
|
+
function baseNode(scene, id, raw) {
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
kind: kindFromWire(raw.type),
|
|
118
|
+
name: nodeName(scene, id),
|
|
119
|
+
parentId: parentMap(scene).get(id) ?? null,
|
|
120
|
+
transform: {
|
|
121
|
+
position: vec3(raw.pos),
|
|
122
|
+
rotation: vec3(raw.rot),
|
|
123
|
+
scale: raw.type === 2 ? v3(1) : vec3(raw.size, v3(1)),
|
|
124
|
+
},
|
|
125
|
+
pickable: !bool(raw.pick_disabled, false),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function materialId(value) {
|
|
129
|
+
const id = num(value, 0);
|
|
130
|
+
return id === 0 ? null : id;
|
|
131
|
+
}
|
|
132
|
+
function decodeCurvePoint(value) {
|
|
133
|
+
const p = value && typeof value === "object" ? value : {};
|
|
134
|
+
return {
|
|
135
|
+
position: vec3(p.pos), rotation: vec3(p.rot), scale: vec3(p.size, v3(10)),
|
|
136
|
+
materialId: materialId(p.material_id), round: num(p.round, 0), fixed: bool(p.fixed, false),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function decodeSvgPaths(value) {
|
|
140
|
+
if (!Array.isArray(value))
|
|
141
|
+
return [];
|
|
142
|
+
return value.map((path) => {
|
|
143
|
+
const p = path && typeof path === "object" ? path : {};
|
|
144
|
+
return {
|
|
145
|
+
closed: bool(p.closed, true),
|
|
146
|
+
segments: Array.isArray(p.segments) ? p.segments.map((segment) => {
|
|
147
|
+
const s = segment && typeof segment === "object" ? segment : {};
|
|
148
|
+
return { point: vec2(s.point), handleIn: vec2(s.handle_in), handleOut: vec2(s.handle_out) };
|
|
149
|
+
}) : [],
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export function wireToNode(scene, id) {
|
|
154
|
+
const raw = scene.nodes[String(id)];
|
|
155
|
+
if (!raw)
|
|
156
|
+
throw missingNode(id, "getNode");
|
|
157
|
+
const base = baseNode(scene, id, raw);
|
|
158
|
+
const hidden = num(raw.hide, 0) !== 0;
|
|
159
|
+
const operation = OPERATIONS[num(raw.op, 1)] ?? "union";
|
|
160
|
+
const material = materialId(raw.material_id);
|
|
161
|
+
switch (base.kind) {
|
|
162
|
+
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) };
|
|
164
|
+
case "group": return { ...base, kind: "group", hidden };
|
|
165
|
+
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) };
|
|
167
|
+
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
|
+
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
|
+
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" };
|
|
170
|
+
case "field": return { ...base, kind: "field", hidden, materialId: material, operation, blend: num(raw.blend, 0), field: str(raw.field, "empty"), inflation: num(raw.inflation, 0), colorSampling: FIELD_SAMPLING[num(raw.color_option, 2)] ?? "override", materialNeutralCutout: bool(raw.material_neutral_cutout, false) };
|
|
171
|
+
case "decal": return { ...base, kind: "decal", hidden, materialId: material, image: str(raw.image, "empty"), global: num(raw.global, 1) !== 0, colorSampling: ["texture", "multiply", "material"][num(raw.color_option, 0)] ?? "texture" };
|
|
172
|
+
case "openScad": return { ...base, kind: "openScad", hidden, materialId: material, operation, blend: num(raw.blend, 0), scriptId: num(raw.script_id, 0), params: (raw.params && typeof raw.params === "object" ? raw.params : {}), enabled: bool(raw.enabled, true) };
|
|
173
|
+
case "socket": return { ...base, kind: "socket", hidden, tag: str(raw.tag) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function defaultNode(kind, id, name = "") {
|
|
177
|
+
const base = { id, kind, name, parentId: null, transform: identityTransform(), pickable: true };
|
|
178
|
+
const mirrors = () => [0, 1, 2, 3].map(() => ({
|
|
179
|
+
position: v3(0),
|
|
180
|
+
rotation: v3(0),
|
|
181
|
+
value: -1,
|
|
182
|
+
hide: 0,
|
|
183
|
+
hideMirrorPlane: false,
|
|
184
|
+
constraint: 0,
|
|
185
|
+
}));
|
|
186
|
+
switch (kind) {
|
|
187
|
+
case "primitive": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 0, color: v3(1), primitive: "box", round: 0, thickness: -1, inflation: 0, materialNeutralCutout: false, mirrors: mirrors() };
|
|
188
|
+
case "union": return { ...base, kind, hidden: false, operation: "union", blend: 0, resolution: 0.3, thickness: -1, inflation: 0, materialNeutralCutout: false };
|
|
189
|
+
case "group": return { ...base, kind, hidden: false };
|
|
190
|
+
case "light": return { ...base, kind, color: { x: 1, y: 1, z: 0.9 }, power: 80, collimation: 0, lightType: "sphere", size: v2(1, 1), textureFile: "" };
|
|
191
|
+
case "curve": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 20, primitive: "sphere", density: 100, roundness: 1, smoothing: 0.02, materialNeutralCutout: false, points: [{ position: v3(0), rotation: v3(0), scale: v3(10), materialId: null, round: 0, fixed: false }], mirrors: mirrors() };
|
|
192
|
+
case "text": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 0, text: "Text", fontFamily: "OpenSans", weight: 400, italic: false, round: 0, width: 20, wrap: "disabled", align: "left", spacing: 0, lineHeight: 1, materialNeutralCutout: false };
|
|
193
|
+
case "svg": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 0, paths: [{ closed: true, segments: [{ point: v2(-1, 0), handleIn: v2(0, -1), handleOut: v2(0, 1) }, { point: v2(1, 0), handleIn: v2(0, 1), handleOut: v2(0, -1) }] }], inflate: false, outline: false, outlineSize: 0.1, materialNeutralCutout: false };
|
|
194
|
+
case "mesh": return { ...base, kind, hidden: false, materialId: null, mesh: "", meshIndex: 0, smoothNormals: false, colorSampling: "override" };
|
|
195
|
+
case "field": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 0, field: "empty", inflation: 0, colorSampling: "override", materialNeutralCutout: false };
|
|
196
|
+
case "decal": return { ...base, kind, hidden: false, materialId: null, image: "empty", global: true, colorSampling: "texture" };
|
|
197
|
+
case "openScad": return { ...base, kind, hidden: false, materialId: null, operation: "union", blend: 0, scriptId: 0, params: {}, enabled: true };
|
|
198
|
+
case "socket": return { ...base, kind, hidden: false, tag: "" };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
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 };
|
|
203
|
+
}
|
|
204
|
+
function encodeSvgPaths(paths) {
|
|
205
|
+
return paths.map((path) => ({ closed: path.closed, segments: path.segments.map((s) => ({ point: s.point, handle_in: s.handleIn, handle_out: s.handleOut })) }));
|
|
206
|
+
}
|
|
207
|
+
export function nodeToWire(node) {
|
|
208
|
+
const raw = { type: kindToWire(node.kind), pos: node.transform.position, rot: node.transform.rotation, pick_disabled: !node.pickable };
|
|
209
|
+
if (node.kind !== "light")
|
|
210
|
+
raw.size = node.transform.scale;
|
|
211
|
+
if ("hidden" in node)
|
|
212
|
+
raw.hide = node.hidden ? 1 : 0;
|
|
213
|
+
if ("materialId" in node)
|
|
214
|
+
raw.material_id = node.materialId ?? 0;
|
|
215
|
+
if ("operation" in node)
|
|
216
|
+
raw.op = OPERATIONS.indexOf(node.operation);
|
|
217
|
+
if ("blend" in node)
|
|
218
|
+
raw.blend = node.blend;
|
|
219
|
+
switch (node.kind) {
|
|
220
|
+
case "primitive":
|
|
221
|
+
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
|
+
break;
|
|
223
|
+
case "union":
|
|
224
|
+
Object.assign(raw, { resolution: node.resolution, thickness: node.thickness, inflation: node.inflation, material_neutral_cutout: node.materialNeutralCutout });
|
|
225
|
+
break;
|
|
226
|
+
case "light":
|
|
227
|
+
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
|
+
break;
|
|
229
|
+
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));
|
|
231
|
+
break;
|
|
232
|
+
case "text":
|
|
233
|
+
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 });
|
|
234
|
+
break;
|
|
235
|
+
case "svg":
|
|
236
|
+
Object.assign(raw, { paths: encodeSvgPaths(node.paths), inflate: node.inflate, outline: node.outline, outline_size: node.outlineSize, material_neutral_cutout: node.materialNeutralCutout, flip_mask: 0 });
|
|
237
|
+
break;
|
|
238
|
+
case "mesh":
|
|
239
|
+
Object.assign(raw, { mesh: node.mesh, mesh_index: node.meshIndex, smooth_normals: node.smoothNormals, color_option: FIELD_SAMPLING.indexOf(node.colorSampling), flip_mask: 0 });
|
|
240
|
+
break;
|
|
241
|
+
case "field":
|
|
242
|
+
Object.assign(raw, { field: node.field, inflation: node.inflation, color_option: FIELD_SAMPLING.indexOf(node.colorSampling), material_neutral_cutout: node.materialNeutralCutout, flip_mask: 0 });
|
|
243
|
+
break;
|
|
244
|
+
case "decal":
|
|
245
|
+
Object.assign(raw, { image: node.image, global: node.global ? 1 : 0, color_option: ["texture", "multiply", "material"].indexOf(node.colorSampling), flip_mask: 0 });
|
|
246
|
+
break;
|
|
247
|
+
case "openScad":
|
|
248
|
+
Object.assign(raw, { script_id: node.scriptId, params: node.params, enabled: node.enabled });
|
|
249
|
+
break;
|
|
250
|
+
case "socket":
|
|
251
|
+
raw.tag = node.tag;
|
|
252
|
+
break;
|
|
253
|
+
case "group": break;
|
|
254
|
+
}
|
|
255
|
+
return raw;
|
|
256
|
+
}
|
|
257
|
+
function encodeMirrorPlanes(mirrors) {
|
|
258
|
+
return Object.fromEntries(mirrors.map((plane, index) => [
|
|
259
|
+
`mirror_plane_${index}`,
|
|
260
|
+
{
|
|
261
|
+
position: plane.position,
|
|
262
|
+
rotation: plane.rotation,
|
|
263
|
+
value: plane.value,
|
|
264
|
+
hide: plane.hide,
|
|
265
|
+
},
|
|
266
|
+
]));
|
|
267
|
+
}
|
|
268
|
+
export const defaultMaterialData = () => ({
|
|
269
|
+
color: v3(1), metalness: 0, roughness: 0, transmittance: 0, translucency: 0,
|
|
270
|
+
translucencyWeight: 0, secondaryColor: v3(1), subsurface: v3(0), indexOfRefraction: 1.58,
|
|
271
|
+
iridescence: 0, emission: 0, absorption: 0, sheen: 0, sheenRoughness: 0, specularTint: 0,
|
|
272
|
+
dispersion: 0, surfaceOpacity: 1, volumetricEnabled: false,
|
|
273
|
+
});
|
|
274
|
+
export function wireToMaterial(id, raw) {
|
|
275
|
+
const d = raw.data ?? {};
|
|
276
|
+
return { id, data: { color: { x: num(d["color.x"], 1), y: num(d["color.y"], 1), z: num(d["color.z"], 1) }, metalness: num(d.metalness, 0), roughness: num(d.roughness, 0), transmittance: num(d.transmittance, 0), translucency: num(d.translucency, 0), translucencyWeight: num(d.translucency_weight, 0), secondaryColor: { x: num(d["secondary_color.x"], 1), y: num(d["secondary_color.y"], 1), z: num(d["secondary_color.z"], 1) }, subsurface: { x: num(d["subsurface.x"], 0), y: num(d["subsurface.y"], 0), z: num(d["subsurface.z"], 0) }, indexOfRefraction: num(d.index_of_refraction, 1.58), iridescence: num(d.iridescence, 0), emission: num(d.emission, 0), absorption: num(d.absorption, 0), sheen: num(d.sheen, 0), sheenRoughness: num(d.sheen_roughness, 0), specularTint: num(d.specular_tint, 0), dispersion: num(d.dispersion, 0), surfaceOpacity: num(d.surface_opacity, 1), volumetricEnabled: bool(d.volumetric_enabled, false) }, shaderIds: raw.shaders ?? (raw.shader ? [raw.shader] : []) };
|
|
277
|
+
}
|
|
278
|
+
export function materialToWire(material) {
|
|
279
|
+
const d = material.data;
|
|
280
|
+
const wire = { data: { "color.x": d.color.x, "color.y": d.color.y, "color.z": d.color.z, metalness: d.metalness, roughness: d.roughness, transmittance: d.transmittance, translucency: d.translucency, translucency_weight: d.translucencyWeight, "secondary_color.x": d.secondaryColor.x, "secondary_color.y": d.secondaryColor.y, "secondary_color.z": d.secondaryColor.z, "subsurface.x": d.subsurface.x, "subsurface.y": d.subsurface.y, "subsurface.z": d.subsurface.z, index_of_refraction: d.indexOfRefraction, iridescence: d.iridescence, emission: d.emission, absorption: d.absorption, sheen: d.sheen, sheen_roughness: d.sheenRoughness, specular_tint: d.specularTint, dispersion: d.dispersion, surface_opacity: d.surfaceOpacity, volumetric_enabled: d.volumetricEnabled } };
|
|
281
|
+
return material.shaderIds.length ? { ...wire, shaders: [...material.shaderIds] } : wire;
|
|
282
|
+
}
|
|
283
|
+
export function parseSafePositiveId(value) {
|
|
284
|
+
const id = Number(value);
|
|
285
|
+
return Number.isSafeInteger(id) && id > 0 && String(id) === value ? id : undefined;
|
|
286
|
+
}
|
|
287
|
+
export function toPublicScene(raw) {
|
|
288
|
+
const parents = parentMap(raw);
|
|
289
|
+
void parents;
|
|
290
|
+
const nodes = Object.fromEntries(Object.keys(raw.nodes).map((id) => [id, wireToNode(raw, Number(id))]));
|
|
291
|
+
const tree = Object.fromEntries(Object.keys(raw.tree ?? {}).map((id) => [id, orderedChildren(raw, Number(id))]));
|
|
292
|
+
const materials = Object.fromEntries(Object.entries(raw.storage.materials ?? {}).flatMap(([key, value]) => {
|
|
293
|
+
const id = parseSafePositiveId(key);
|
|
294
|
+
return id === undefined ? [] : [[key, wireToMaterial(id, value)]];
|
|
295
|
+
}));
|
|
296
|
+
const scripts = Object.fromEntries(Object.entries(raw.storage.openscad?.scripts ?? {}).map(([id, value]) => [id, { id: Number(id), name: str(value.name, "OpenSCAD Script"), source: str(value.source) }]));
|
|
297
|
+
return { version: raw.version ?? 0, name: raw.name ?? "", nodes, tree, materials, openScadScripts: scripts, properties: (raw.properties ?? {}), camera: (raw.camera ?? {}) };
|
|
298
|
+
}
|
|
299
|
+
export function missingNode(id, operation) {
|
|
300
|
+
return new KakapoValidationError(`Node ${id} does not exist.`, { code: "NODE_NOT_FOUND", operation, path: "nodeId", received: id, expected: "an existing node ID", hint: "Call listNodes() or refreshScene() and use a current node ID." });
|
|
301
|
+
}
|
|
302
|
+
function decodePointer(path) {
|
|
303
|
+
if (path === "")
|
|
304
|
+
return [];
|
|
305
|
+
if (!path.startsWith("/"))
|
|
306
|
+
throw new KakapoValidationError(`Invalid JSON Pointer '${path}'.`, { code: "INVALID_JSON_POINTER", operation: "applyScenePatch", path: "patch.path", received: path, expected: "RFC 6901 pointer beginning with '/'" });
|
|
307
|
+
return path.slice(1).split("/").map((p) => p.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
308
|
+
}
|
|
309
|
+
function getAt(root, path) {
|
|
310
|
+
let current = root;
|
|
311
|
+
for (const part of decodePointer(path)) {
|
|
312
|
+
if (!current || typeof current !== "object")
|
|
313
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
314
|
+
current = current[part];
|
|
315
|
+
}
|
|
316
|
+
return current;
|
|
317
|
+
}
|
|
318
|
+
function setAt(root, path, value, add) {
|
|
319
|
+
const parts = decodePointer(path);
|
|
320
|
+
const key = parts.pop();
|
|
321
|
+
if (key === undefined)
|
|
322
|
+
throw new Error("Replacing the document root is not supported");
|
|
323
|
+
let parent = root;
|
|
324
|
+
for (const part of parts) {
|
|
325
|
+
const next = parent[part];
|
|
326
|
+
if (!next || typeof next !== "object")
|
|
327
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
328
|
+
parent = next;
|
|
329
|
+
}
|
|
330
|
+
if (!add && !Object.hasOwn(parent, key))
|
|
331
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
332
|
+
if (Array.isArray(parent)) {
|
|
333
|
+
const index = key === "-" ? parent.length : Number(key);
|
|
334
|
+
if (add)
|
|
335
|
+
parent.splice(index, 0, value);
|
|
336
|
+
else
|
|
337
|
+
parent[index] = value;
|
|
338
|
+
}
|
|
339
|
+
else
|
|
340
|
+
parent[key] = value;
|
|
341
|
+
}
|
|
342
|
+
function removeAt(root, path) {
|
|
343
|
+
const parts = decodePointer(path);
|
|
344
|
+
const key = parts.pop();
|
|
345
|
+
if (key === undefined)
|
|
346
|
+
throw new Error("Removing the document root is not supported");
|
|
347
|
+
let parent = root;
|
|
348
|
+
for (const part of parts) {
|
|
349
|
+
const next = parent[part];
|
|
350
|
+
if (!next || typeof next !== "object")
|
|
351
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
352
|
+
parent = next;
|
|
353
|
+
}
|
|
354
|
+
if (!Object.hasOwn(parent, key))
|
|
355
|
+
throw new Error(`Path does not exist: ${path}`);
|
|
356
|
+
if (Array.isArray(parent))
|
|
357
|
+
return parent.splice(Number(key), 1)[0];
|
|
358
|
+
const old = parent[key];
|
|
359
|
+
delete parent[key];
|
|
360
|
+
return old;
|
|
361
|
+
}
|
|
362
|
+
export function applyJsonPatch(document, operations) {
|
|
363
|
+
const result = deepClone(document);
|
|
364
|
+
try {
|
|
365
|
+
for (const operation of operations) {
|
|
366
|
+
switch (operation.op) {
|
|
367
|
+
case "add":
|
|
368
|
+
setAt(result, operation.path, deepClone(operation.value), true);
|
|
369
|
+
break;
|
|
370
|
+
case "replace":
|
|
371
|
+
setAt(result, operation.path, deepClone(operation.value), false);
|
|
372
|
+
break;
|
|
373
|
+
case "remove":
|
|
374
|
+
removeAt(result, operation.path);
|
|
375
|
+
break;
|
|
376
|
+
case "copy":
|
|
377
|
+
setAt(result, operation.path, deepClone(getAt(result, operation.from)), true);
|
|
378
|
+
break;
|
|
379
|
+
case "move":
|
|
380
|
+
setAt(result, operation.path, removeAt(result, operation.from), true);
|
|
381
|
+
break;
|
|
382
|
+
case "test":
|
|
383
|
+
if (JSON.stringify(getAt(result, operation.path)) !== JSON.stringify(operation.value))
|
|
384
|
+
throw new Error(`Test failed at ${operation.path}`);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (cause) {
|
|
390
|
+
throw new KakapoValidationError(`Cannot apply scene patch: ${cause instanceof Error ? cause.message : String(cause)}`, { code: "INVALID_SCENE_PATCH", operation: "applyScenePatch", cause, hint: "Use an existing RFC 6901 path and a valid RFC 6902 operation." });
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
export function nextId(record) {
|
|
395
|
+
return Math.max(0, ...Object.keys(record).map(Number).filter(Number.isSafeInteger)) + 1;
|
|
396
|
+
}
|
|
397
|
+
export function escapePointer(value) { return value.replaceAll("~", "~0").replaceAll("/", "~1"); }
|