@womp/spark-agent 5.2.3 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-run.d.ts +1 -1
- package/dist/agent-tools.js +30 -12
- package/dist/capabilities/materials.d.ts +45 -0
- package/dist/capabilities/materials.js +89 -0
- package/dist/capabilities/scene.d.ts +21 -0
- package/dist/capabilities/scene.js +13 -13
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +1 -0
- package/dist/error-diagnostics.d.ts +24 -0
- package/dist/error-diagnostics.js +53 -0
- package/dist/generated/materials-declarations.d.ts +1 -0
- package/dist/generated/materials-declarations.js +3 -0
- package/dist/kakapo.d.ts +1 -0
- package/dist/kakapo.js +1 -0
- package/dist/materials/composition.d.ts +64 -0
- package/dist/materials/composition.js +121 -0
- package/dist/materials/graph.d.ts +79 -0
- package/dist/materials/graph.js +336 -0
- package/dist/materials/inspection.d.ts +40 -0
- package/dist/materials/inspection.js +54 -0
- package/dist/materials/recipes.d.ts +12 -0
- package/dist/materials/recipes.js +284 -0
- package/dist/materials/reference.d.ts +2 -0
- package/dist/materials/reference.js +97 -0
- package/dist/materials/shaping.d.ts +294 -0
- package/dist/materials/shaping.js +184 -0
- package/dist/materials/structure.d.ts +2 -0
- package/dist/materials/structure.js +12 -0
- package/dist/materials/surface-recipes.d.ts +78 -0
- package/dist/materials/surface-recipes.js +193 -0
- package/dist/materials/types.d.ts +222 -0
- package/dist/materials/types.js +1 -0
- package/dist/models.d.ts +7 -0
- package/dist/models.js +8 -0
- package/dist/pricing.js +5 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +32 -32
- package/dist/sdk-materials-host.d.ts +9 -0
- package/dist/sdk-materials-host.js +360 -0
- package/dist/server/standalone.js +6 -1
- package/dist/ui.d.ts +50 -6
- package/package.json +2 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { graphSchema, makeMaterialNode, pruneMaterialGraph, validateMaterialGraph } from "./graph.js";
|
|
3
|
+
const weights = z.array(z.number().finite());
|
|
4
|
+
const mixWeights = weights.refine(values => values.every(value => value >= 0) && values.reduce((a, b) => a + b, 0) <= 1 + 1e-9, "Color/roughness weights must be nonnegative and sum to at most 1");
|
|
5
|
+
const unit = z.number().min(0).max(1);
|
|
6
|
+
const color = z.object({ x: unit, y: unit, z: unit }).strict();
|
|
7
|
+
export const materialCompositionSchema = z.object({
|
|
8
|
+
fields: z.array(z.object({ graph: graphSchema, source: z.object({ node: z.number().int().nonnegative(), slot: z.number().int().min(0).max(19) }).strict() }).strict()).min(1),
|
|
9
|
+
color: z.object({ low: color, high: color, weights: mixWeights }).strict().optional(),
|
|
10
|
+
roughness: z.object({ low: unit, high: unit, weights: mixWeights }).strict().optional(),
|
|
11
|
+
metalness: unit.optional(),
|
|
12
|
+
relief: z.object({ mode: z.enum(["height", "bump"]), scale: z.number().finite(), weights, center: z.number().finite().optional() }).strict().optional(),
|
|
13
|
+
}).strict().superRefine((options, context) => {
|
|
14
|
+
for (const channel of ["color", "roughness", "relief"])
|
|
15
|
+
if (options[channel] && options[channel].weights.length !== options.fields.length)
|
|
16
|
+
context.addIssue({ code: "custom", path: [channel, "weights"], message: "Provide one weight per field" });
|
|
17
|
+
options.fields.forEach((field, index) => {
|
|
18
|
+
if (!field.graph.nodes[field.source.node])
|
|
19
|
+
context.addIssue({ code: "custom", path: ["fields", index, "source"], message: "Field output node does not exist" });
|
|
20
|
+
if (field.graph.nodes.some(node => node.op.startsWith("Output")))
|
|
21
|
+
context.addIssue({ code: "custom", path: ["fields", index], message: "Fields must not contain surface output nodes" });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
export function materialCompositionOperations(options) {
|
|
25
|
+
return [...new Set([...options.fields.flatMap(field => field.graph.nodes.map(node => node.op)), "Constant", "Math", "MathLerp", "OutputMaterial",
|
|
26
|
+
...(options.relief ? options.relief.mode === "height" ? ["OutputHeight"] : ["Bump", "OutputSurfaceNormal"] : [])])];
|
|
27
|
+
}
|
|
28
|
+
export function makeMaterialComposition(options, catalogue) {
|
|
29
|
+
const graph = { nodes: [], connections: [] };
|
|
30
|
+
const sources = options.fields.map((field, index) => {
|
|
31
|
+
const offset = graph.nodes.length;
|
|
32
|
+
const output = catalogue.nodes[field.graph.nodes[field.source.node].op]?.outputs[field.source.slot];
|
|
33
|
+
if (!output || !["Float", "Float3", "Color"].includes(output.type))
|
|
34
|
+
throw new Error("Field requires a numeric output port");
|
|
35
|
+
graph.nodes.push(...field.graph.nodes.map(node => {
|
|
36
|
+
const copy = { ...structuredClone(node), name: `field.${index}.${node.name ?? node.op}` };
|
|
37
|
+
// Composition creates independent controls; the host allocates their handles.
|
|
38
|
+
if (copy.op === "Constant")
|
|
39
|
+
delete copy.reference_id;
|
|
40
|
+
return copy;
|
|
41
|
+
}));
|
|
42
|
+
graph.connections.push(...field.graph.connections.map(edge => ({ ...edge, input_node: edge.input_node + offset, output_node: edge.output_node + offset })));
|
|
43
|
+
return { node: field.source.node + offset, slot: field.source.slot };
|
|
44
|
+
});
|
|
45
|
+
const selector = (op, slot, label) => {
|
|
46
|
+
const index = catalogue.nodes[op]?.inputs[slot]?.values?.indexOf(label) ?? -1;
|
|
47
|
+
if (index < 0)
|
|
48
|
+
throw new Error(`${op} requires option ${label}`);
|
|
49
|
+
return index;
|
|
50
|
+
};
|
|
51
|
+
const add = (op, inputs, name) => {
|
|
52
|
+
graph.nodes.push(makeMaterialNode({ op, inputs, name }));
|
|
53
|
+
return { node: graph.nodes.length - 1, slot: 0 };
|
|
54
|
+
};
|
|
55
|
+
const wire = (source, target, port) => graph.connections.push({ input_node: source.node, input_slot: source.slot, output_node: target.node, output_slot: port, flags: 0 });
|
|
56
|
+
const constant = (value, name, isColor = false) => {
|
|
57
|
+
const exposed = isColor || ["roughness.low", "roughness.high", "metalness", "relief.scale"].includes(name);
|
|
58
|
+
const result = add("Constant", isColor ? { 2: value, 3: selector("Constant", 3, "Expose Color") } : { 1: value, 3: selector("Constant", 3, exposed ? "Expose X" : "Do not expose") }, name);
|
|
59
|
+
return { ...result, slot: isColor ? 2 : 1 };
|
|
60
|
+
};
|
|
61
|
+
const combine = (values, name) => {
|
|
62
|
+
let sum;
|
|
63
|
+
values.forEach((value, index) => {
|
|
64
|
+
if (value === 0)
|
|
65
|
+
return;
|
|
66
|
+
const weight = constant(value, `${name}.weight.${index}`);
|
|
67
|
+
const product = add("Math", { 0: selector("Math", 0, "Mulitiply"), 3: 0 }, `${name} contribution`);
|
|
68
|
+
wire(sources[index], product, 1);
|
|
69
|
+
wire(weight, product, 2);
|
|
70
|
+
if (!sum) {
|
|
71
|
+
sum = product;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const combined = add("Math", { 0: selector("Math", 0, "Add"), 3: 0 }, `${name} superposition`);
|
|
75
|
+
wire(sum, combined, 1);
|
|
76
|
+
wire(product, combined, 2);
|
|
77
|
+
sum = combined;
|
|
78
|
+
});
|
|
79
|
+
return sum;
|
|
80
|
+
};
|
|
81
|
+
const output = add("OutputMaterial", {}, "Composed surface");
|
|
82
|
+
for (const channel of ["color", "roughness"]) {
|
|
83
|
+
const settings = options[channel];
|
|
84
|
+
if (!settings)
|
|
85
|
+
continue;
|
|
86
|
+
const field = combine(settings.weights, channel);
|
|
87
|
+
if (!field)
|
|
88
|
+
continue;
|
|
89
|
+
const palette = add("MathLerp", {}, `${channel} range`);
|
|
90
|
+
wire(constant(settings.low, `${channel}.low`, channel === "color"), palette, 0);
|
|
91
|
+
wire(constant(settings.high, `${channel}.high`, channel === "color"), palette, 1);
|
|
92
|
+
wire(field, palette, 2);
|
|
93
|
+
wire(palette, output, channel === "color" ? 0 : 4);
|
|
94
|
+
}
|
|
95
|
+
if (options.metalness !== undefined)
|
|
96
|
+
wire(constant(options.metalness, "metalness"), output, 3);
|
|
97
|
+
if (options.relief) {
|
|
98
|
+
let field = combine(options.relief.weights, "relief");
|
|
99
|
+
if (field) {
|
|
100
|
+
if (options.relief.center !== undefined) {
|
|
101
|
+
const centered = add("Math", { 0: selector("Math", 0, "Subtract"), 3: 0 }, "Centered height");
|
|
102
|
+
wire(field, centered, 1);
|
|
103
|
+
wire(constant(options.relief.center, "relief.center"), centered, 2);
|
|
104
|
+
field = centered;
|
|
105
|
+
}
|
|
106
|
+
const strength = constant(options.relief.scale, "relief.scale");
|
|
107
|
+
if (options.relief.mode === "height") {
|
|
108
|
+
const height = add("OutputHeight", {}, "Combined height");
|
|
109
|
+
wire(field, height, 0);
|
|
110
|
+
wire(strength, height, 2);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const bump = add("Bump", {}, "Combined bump");
|
|
114
|
+
wire(field, bump, 0);
|
|
115
|
+
wire(strength, bump, 1);
|
|
116
|
+
wire(bump, add("OutputSurfaceNormal", {}, "Composed normal"), 0);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return pruneMaterialGraph(validateMaterialGraph(graph, catalogue.nodes));
|
|
121
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { MaterialGraph, MaterialNode, MaterialNodeDescription, MaterialNodeOptions, MaterialVector } from "./types.js";
|
|
3
|
+
export declare const materialId: z.ZodNumber;
|
|
4
|
+
export declare const graphSchema: z.ZodObject<{
|
|
5
|
+
nodes: z.ZodArray<z.ZodObject<{
|
|
6
|
+
op: z.ZodString;
|
|
7
|
+
name: z.ZodOptional<z.ZodString>;
|
|
8
|
+
location: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
x: z.ZodNumber;
|
|
10
|
+
y: z.ZodNumber;
|
|
11
|
+
}, z.core.$strict>>;
|
|
12
|
+
editor_flags: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
reference_id: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
out: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
x: z.ZodNumber;
|
|
16
|
+
y: z.ZodNumber;
|
|
17
|
+
z: z.ZodNumber;
|
|
18
|
+
}, z.core.$strict>>;
|
|
19
|
+
}, z.core.$catchall<z.ZodType<import("./types.js").MaterialJson, unknown, z.core.$ZodTypeInternals<import("./types.js").MaterialJson, unknown>>>>>;
|
|
20
|
+
connections: z.ZodArray<z.ZodObject<{
|
|
21
|
+
input_node: z.ZodNumber;
|
|
22
|
+
input_slot: z.ZodNumber;
|
|
23
|
+
output_node: z.ZodNumber;
|
|
24
|
+
output_slot: z.ZodNumber;
|
|
25
|
+
flags: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<2>]>>;
|
|
26
|
+
}, z.core.$strict>>;
|
|
27
|
+
}, z.core.$catchall<z.ZodType<import("./types.js").MaterialJson, unknown, z.core.$ZodTypeInternals<import("./types.js").MaterialJson, unknown>>>>;
|
|
28
|
+
export declare const catalogOptionsSchema: z.ZodObject<{
|
|
29
|
+
refreshTextures: z.ZodOptional<z.ZodBoolean>;
|
|
30
|
+
includeTextures: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
operations: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
32
|
+
}, z.core.$strict>;
|
|
33
|
+
export declare const writeSchema: z.ZodObject<{
|
|
34
|
+
materialId: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
cloneFrom: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
layers: z.ZodOptional<z.ZodArray<z.ZodNullable<z.ZodObject<{
|
|
37
|
+
nodes: z.ZodArray<z.ZodObject<{
|
|
38
|
+
op: z.ZodString;
|
|
39
|
+
name: z.ZodOptional<z.ZodString>;
|
|
40
|
+
location: z.ZodOptional<z.ZodObject<{
|
|
41
|
+
x: z.ZodNumber;
|
|
42
|
+
y: z.ZodNumber;
|
|
43
|
+
}, z.core.$strict>>;
|
|
44
|
+
editor_flags: z.ZodOptional<z.ZodNumber>;
|
|
45
|
+
reference_id: z.ZodOptional<z.ZodNumber>;
|
|
46
|
+
out: z.ZodOptional<z.ZodObject<{
|
|
47
|
+
x: z.ZodNumber;
|
|
48
|
+
y: z.ZodNumber;
|
|
49
|
+
z: z.ZodNumber;
|
|
50
|
+
}, z.core.$strict>>;
|
|
51
|
+
}, z.core.$catchall<z.ZodType<import("./types.js").MaterialJson, unknown, z.core.$ZodTypeInternals<import("./types.js").MaterialJson, unknown>>>>>;
|
|
52
|
+
connections: z.ZodArray<z.ZodObject<{
|
|
53
|
+
input_node: z.ZodNumber;
|
|
54
|
+
input_slot: z.ZodNumber;
|
|
55
|
+
output_node: z.ZodNumber;
|
|
56
|
+
output_slot: z.ZodNumber;
|
|
57
|
+
flags: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<2>]>>;
|
|
58
|
+
}, z.core.$strict>>;
|
|
59
|
+
}, z.core.$catchall<z.ZodType<import("./types.js").MaterialJson, unknown, z.core.$ZodTypeInternals<import("./types.js").MaterialJson, unknown>>>>>>>;
|
|
60
|
+
constants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
|
|
61
|
+
value: z.ZodObject<{
|
|
62
|
+
x: z.ZodNumber;
|
|
63
|
+
y: z.ZodNumber;
|
|
64
|
+
z: z.ZodNumber;
|
|
65
|
+
}, z.core.$strict>;
|
|
66
|
+
color: z.ZodObject<{
|
|
67
|
+
x: z.ZodNumber;
|
|
68
|
+
y: z.ZodNumber;
|
|
69
|
+
z: z.ZodNumber;
|
|
70
|
+
}, z.core.$strict>;
|
|
71
|
+
}, z.core.$strict>>>>;
|
|
72
|
+
objectIds: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
|
73
|
+
}, z.core.$strict>;
|
|
74
|
+
export declare const value3: (value: number) => MaterialVector;
|
|
75
|
+
export declare function makeMaterialNode(options: MaterialNodeOptions): MaterialNode;
|
|
76
|
+
export declare function parseMaterialCatalog(value: unknown): Record<string, MaterialNodeDescription>;
|
|
77
|
+
export declare function validateMaterialGraph(input: MaterialGraph, catalog: Record<string, MaterialNodeDescription>): MaterialGraph;
|
|
78
|
+
/** Drop nodes that cannot contribute to any surface output after validating the graph. */
|
|
79
|
+
export declare function pruneMaterialGraph(graph: MaterialGraph): MaterialGraph;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const finite = z
|
|
3
|
+
.number()
|
|
4
|
+
.refine(Number.isFinite, "Expected a finite number")
|
|
5
|
+
.refine((v) => Math.abs(v) <= 3.4028234663852886e38, "Exceeds float32 range");
|
|
6
|
+
export const materialId = z.number().int().min(1).max(65535);
|
|
7
|
+
const referenceId = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER);
|
|
8
|
+
const vector = z.object({ x: finite, y: finite, z: finite }).strict();
|
|
9
|
+
const index = z.number().int().nonnegative();
|
|
10
|
+
const json = z.lazy(() => z.union([
|
|
11
|
+
z.null(),
|
|
12
|
+
z.boolean(),
|
|
13
|
+
finite,
|
|
14
|
+
z.string(),
|
|
15
|
+
z.array(json),
|
|
16
|
+
z.record(z.string(), json),
|
|
17
|
+
]));
|
|
18
|
+
const node = z
|
|
19
|
+
.object({
|
|
20
|
+
op: z.string().min(1),
|
|
21
|
+
name: z.string().optional(),
|
|
22
|
+
location: z.object({ x: finite, y: finite }).strict().optional(),
|
|
23
|
+
editor_flags: z.number().int().min(0).max(4294967295).optional(),
|
|
24
|
+
reference_id: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.min(0)
|
|
28
|
+
.max(Number.MAX_SAFE_INTEGER)
|
|
29
|
+
.optional(),
|
|
30
|
+
out: vector.optional(),
|
|
31
|
+
})
|
|
32
|
+
.catchall(json);
|
|
33
|
+
const connection = z
|
|
34
|
+
.object({
|
|
35
|
+
input_node: index,
|
|
36
|
+
input_slot: index.max(19),
|
|
37
|
+
output_node: index,
|
|
38
|
+
output_slot: index.max(19),
|
|
39
|
+
flags: z.union([z.literal(0), z.literal(2)]).optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
export const graphSchema = z
|
|
43
|
+
.object({ nodes: z.array(node).min(1), connections: z.array(connection) })
|
|
44
|
+
.catchall(json);
|
|
45
|
+
export const catalogOptionsSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
refreshTextures: z.boolean().optional(),
|
|
48
|
+
includeTextures: z.boolean().optional(),
|
|
49
|
+
operations: z.array(z.string()).optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
export const writeSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
materialId: materialId.optional(),
|
|
55
|
+
cloneFrom: materialId.optional(),
|
|
56
|
+
layers: z.array(graphSchema.nullable()).max(8).optional(),
|
|
57
|
+
constants: z
|
|
58
|
+
.record(z.string().regex(/^[1-9]\d*$/), z.object({ value: vector, color: vector }).strict().nullable())
|
|
59
|
+
.optional(),
|
|
60
|
+
objectIds: z.array(index).optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict()
|
|
63
|
+
.refine((v) => v.materialId === undefined || v.cloneFrom === undefined, "Use materialId or cloneFrom, not both");
|
|
64
|
+
const nodeOptions = z
|
|
65
|
+
.object({
|
|
66
|
+
op: z.string().min(1),
|
|
67
|
+
name: z.string().optional(),
|
|
68
|
+
reference_id: referenceId.optional(),
|
|
69
|
+
inputs: z
|
|
70
|
+
.record(z.string().regex(/^(0|[1-9]|1[0-9])$/), z.union([finite, vector]))
|
|
71
|
+
.optional(),
|
|
72
|
+
texture: z
|
|
73
|
+
.string()
|
|
74
|
+
.min(1)
|
|
75
|
+
.refine((v) => !v.includes("\0"), "Texture names cannot contain NUL")
|
|
76
|
+
.optional(),
|
|
77
|
+
ramp: z
|
|
78
|
+
.array(z.object({ position: finite, color: vector }).strict())
|
|
79
|
+
.min(2)
|
|
80
|
+
.optional(),
|
|
81
|
+
options: z
|
|
82
|
+
.array(z.object({ name: z.string(), value: finite }).strict())
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(32)
|
|
85
|
+
.optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
export const value3 = (value) => ({
|
|
89
|
+
x: value,
|
|
90
|
+
y: value,
|
|
91
|
+
z: value,
|
|
92
|
+
});
|
|
93
|
+
const readVector = (node, slot) => vector.parse(node[`in${slot}`] ?? value3(0));
|
|
94
|
+
export function makeMaterialNode(options) {
|
|
95
|
+
const spec = nodeOptions.parse(options);
|
|
96
|
+
const result = { op: spec.op };
|
|
97
|
+
if (spec.name !== undefined)
|
|
98
|
+
result.name = spec.name;
|
|
99
|
+
if (spec.reference_id !== undefined)
|
|
100
|
+
result.reference_id = spec.reference_id;
|
|
101
|
+
for (const [slot, input] of Object.entries(spec.inputs ?? {}))
|
|
102
|
+
result[`in${slot}`] = typeof input === "number" ? value3(input) : input;
|
|
103
|
+
const payloads = [spec.texture, spec.ramp, spec.options].filter((v) => v !== undefined);
|
|
104
|
+
if (payloads.length > 1)
|
|
105
|
+
throw new Error("A node accepts only one payload");
|
|
106
|
+
if (spec.texture !== undefined) {
|
|
107
|
+
if (!["Texture", "PredefinedTexture"].includes(spec.op))
|
|
108
|
+
throw new Error("Texture payload requires a texture node");
|
|
109
|
+
result.external_data = [...new TextEncoder().encode(spec.texture), 0];
|
|
110
|
+
}
|
|
111
|
+
if (spec.ramp) {
|
|
112
|
+
if (spec.op !== "ColorRamp")
|
|
113
|
+
throw new Error("Ramp payload requires ColorRamp");
|
|
114
|
+
const bytes = new Uint8Array(spec.ramp.length * 16);
|
|
115
|
+
const view = new DataView(bytes.buffer);
|
|
116
|
+
spec.ramp.forEach(({ position, color }, i) => [position, color.x, color.y, color.z].forEach((v, j) => view.setFloat32(i * 16 + j * 4, v, true)));
|
|
117
|
+
result.external_data = Array.from(bytes);
|
|
118
|
+
result.in1 = value3(spec.ramp.length);
|
|
119
|
+
}
|
|
120
|
+
if (spec.options) {
|
|
121
|
+
if (spec.op !== "OptionSelector")
|
|
122
|
+
throw new Error("Options payload requires OptionSelector");
|
|
123
|
+
const bytes = new Uint8Array(spec.options.length * 36);
|
|
124
|
+
const view = new DataView(bytes.buffer);
|
|
125
|
+
spec.options.forEach(({ name, value }, i) => {
|
|
126
|
+
const text = new TextEncoder().encode(name);
|
|
127
|
+
if (text.length > 31 || name.includes("\0"))
|
|
128
|
+
throw new Error("Option names must fit 31 UTF-8 bytes without NUL");
|
|
129
|
+
view.setFloat32(i * 36, value, true);
|
|
130
|
+
bytes.set(text, i * 36 + 4);
|
|
131
|
+
});
|
|
132
|
+
result.external_data = Array.from(bytes);
|
|
133
|
+
result.in0 = value3(spec.options.length);
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
const port = z.object({
|
|
138
|
+
title: z.string(),
|
|
139
|
+
type: z.string(),
|
|
140
|
+
values: z.array(z.string()).optional(),
|
|
141
|
+
});
|
|
142
|
+
const description = z.object({
|
|
143
|
+
type: z.string(),
|
|
144
|
+
title: z.string(),
|
|
145
|
+
description: z.string(),
|
|
146
|
+
inputs: z.array(port).max(20),
|
|
147
|
+
outputs: z.array(port).max(20),
|
|
148
|
+
});
|
|
149
|
+
export function parseMaterialCatalog(value) {
|
|
150
|
+
const parsed = z
|
|
151
|
+
.record(z.string(), description)
|
|
152
|
+
.parse(typeof value === "string" ? JSON.parse(value) : value);
|
|
153
|
+
if (!parsed.OutputMaterial)
|
|
154
|
+
throw new Error("Kakapo returned no procedural material catalogue");
|
|
155
|
+
return parsed;
|
|
156
|
+
}
|
|
157
|
+
/** Fields omitted by the runtime catalogue, verified against native registrations. */
|
|
158
|
+
function hidden(op, slot) {
|
|
159
|
+
return ((["Texture", "PredefinedTexture"].includes(op) && slot === 1) ||
|
|
160
|
+
(op === "OutputHeight" && slot === 1));
|
|
161
|
+
}
|
|
162
|
+
function payloadBytes(node) {
|
|
163
|
+
return Uint8Array.from(z.array(z.number().int().min(0).max(255)).parse(node.external_data ?? []));
|
|
164
|
+
}
|
|
165
|
+
function checkPayload(node, textures) {
|
|
166
|
+
const bytes = payloadBytes(node);
|
|
167
|
+
const view = new DataView(bytes.buffer);
|
|
168
|
+
const fail = (message) => {
|
|
169
|
+
throw new Error(`${node.op}: ${message}`);
|
|
170
|
+
};
|
|
171
|
+
const length = (count, stride) => {
|
|
172
|
+
if (!Number.isInteger(count) || count < 1 || count * stride > bytes.length)
|
|
173
|
+
fail("invalid payload count/size");
|
|
174
|
+
if (bytes.slice(count * stride).some((v) => v !== 0))
|
|
175
|
+
fail("unexpected trailing payload data");
|
|
176
|
+
node.external_data = Array.from(bytes.slice(0, count * stride));
|
|
177
|
+
};
|
|
178
|
+
if (node.op === "ColorRamp") {
|
|
179
|
+
const count = readVector(node, 1).x;
|
|
180
|
+
length(count, 16);
|
|
181
|
+
if (count < 2)
|
|
182
|
+
fail("at least two ramp stops are required");
|
|
183
|
+
let previous = -Infinity;
|
|
184
|
+
for (let i = 0; i < count; i++) {
|
|
185
|
+
const values = Array.from({ length: 4 }, (_, j) => view.getFloat32(i * 16 + j * 4, true));
|
|
186
|
+
values.forEach((v) => finite.parse(v));
|
|
187
|
+
if (values[0] <= previous || values[0] < 0 || values[0] > 1)
|
|
188
|
+
fail("ramp positions must increase within 0..1");
|
|
189
|
+
previous = values[0];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (node.op === "OptionSelector") {
|
|
193
|
+
const count = readVector(node, 0).x;
|
|
194
|
+
length(count, 36);
|
|
195
|
+
if (count > 32)
|
|
196
|
+
fail("at most 32 options are supported");
|
|
197
|
+
for (let i = 0; i < count; i++) {
|
|
198
|
+
finite.parse(view.getFloat32(i * 36, true));
|
|
199
|
+
if (!bytes.slice(i * 36 + 4, i * 36 + 36).includes(0))
|
|
200
|
+
fail("option name is not NUL terminated");
|
|
201
|
+
}
|
|
202
|
+
const selected = readVector(node, 1).x;
|
|
203
|
+
if (!Number.isInteger(selected) || selected < 0 || selected >= count)
|
|
204
|
+
fail("selected index is outside the options");
|
|
205
|
+
}
|
|
206
|
+
else if (["Texture", "PredefinedTexture"].includes(node.op)) {
|
|
207
|
+
const end = bytes.indexOf(0);
|
|
208
|
+
if (end <= 0 || bytes.slice(end).some((v) => v !== 0))
|
|
209
|
+
fail("expected a NUL-terminated texture resource name");
|
|
210
|
+
const name = new TextDecoder("utf-8", { fatal: true }).decode(bytes.slice(0, end));
|
|
211
|
+
if (!textures.includes(name))
|
|
212
|
+
fail(`texture ${JSON.stringify(name)} is not in the refreshed resource catalogue`);
|
|
213
|
+
node.external_data = Array.from(bytes.slice(0, end + 1));
|
|
214
|
+
node.in1 = value3(0); // Renderer owns the GPU slot and dimensions.
|
|
215
|
+
if (node.op === "PredefinedTexture")
|
|
216
|
+
node.in2 = value3(textures.indexOf(name));
|
|
217
|
+
}
|
|
218
|
+
else if (bytes.length > 0) {
|
|
219
|
+
fail("this node's binary payload is not supported for editing");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export function validateMaterialGraph(input, catalog) {
|
|
223
|
+
const graph = graphSchema.parse(input);
|
|
224
|
+
const textures = catalog.PredefinedTexture?.inputs[2]?.values ?? [];
|
|
225
|
+
const descriptors = graph.nodes.map((node) => {
|
|
226
|
+
node.location ??= { x: 0, y: 0 };
|
|
227
|
+
const desc = catalog[node.op];
|
|
228
|
+
if (!desc || desc.type === "UNDEFINED" || node.op === "Noop")
|
|
229
|
+
throw new Error(`Unknown/unsupported material operation: ${node.op}`);
|
|
230
|
+
if (node.reference_id !== undefined && node.reference_id !== 0)
|
|
231
|
+
referenceId.parse(node.reference_id);
|
|
232
|
+
for (const key of Object.keys(node)) {
|
|
233
|
+
if (key.startsWith("in") && /^in\d+$/.test(key)) {
|
|
234
|
+
const slot = Number(key.slice(2));
|
|
235
|
+
if (slot >= 20)
|
|
236
|
+
throw new Error(`${node.op}: invalid input ${slot}`);
|
|
237
|
+
vector.parse(node[key]);
|
|
238
|
+
if ((!desc.inputs[slot] || desc.inputs[slot].type === "Disabled") &&
|
|
239
|
+
Object.values(readVector(node, slot)).some((v) => v !== 0))
|
|
240
|
+
throw new Error(`${node.op}: input ${slot} is disabled`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
checkPayload(node, textures);
|
|
244
|
+
desc.inputs.forEach((port, slot) => {
|
|
245
|
+
const x = readVector(node, slot).x;
|
|
246
|
+
if (port.type === "Enum" &&
|
|
247
|
+
(!Number.isInteger(x) || x < 0 || x >= (port.values?.length ?? 0)))
|
|
248
|
+
throw new Error(`${node.op}: enum input ${slot} is out of range`);
|
|
249
|
+
if (port.type === "Integer" && !Number.isInteger(x))
|
|
250
|
+
throw new Error(`${node.op}: input ${slot} requires an integer`);
|
|
251
|
+
if (port.type === "Boolean" && x !== 0 && x !== 1)
|
|
252
|
+
throw new Error(`${node.op}: input ${slot} requires 0 or 1`);
|
|
253
|
+
});
|
|
254
|
+
if (["Noise1D", "Noise3D", "NoiseFBM"].includes(node.op)) {
|
|
255
|
+
const detail = readVector(node, 2).x;
|
|
256
|
+
if (detail < 1 || detail > 16)
|
|
257
|
+
throw new Error(`${node.op}: detail must be within 1..16`);
|
|
258
|
+
}
|
|
259
|
+
return desc;
|
|
260
|
+
});
|
|
261
|
+
const incoming = new Set();
|
|
262
|
+
const adjacency = graph.nodes.map(() => []);
|
|
263
|
+
const degree = graph.nodes.map(() => 0);
|
|
264
|
+
graph.connections.forEach((edge) => {
|
|
265
|
+
const source = descriptors[edge.input_node];
|
|
266
|
+
const target = descriptors[edge.output_node];
|
|
267
|
+
const output = source?.outputs[edge.input_slot];
|
|
268
|
+
const inputPort = target?.inputs[edge.output_slot];
|
|
269
|
+
if (!source ||
|
|
270
|
+
!target ||
|
|
271
|
+
!output ||
|
|
272
|
+
!inputPort ||
|
|
273
|
+
graph.nodes[edge.input_node].op.startsWith("Output") ||
|
|
274
|
+
!["Float", "Float3", "Color"].includes(output.type) ||
|
|
275
|
+
!["Float", "Float3", "Color"].includes(inputPort.type))
|
|
276
|
+
throw new Error("Invalid connection: use existing numeric ports; output nodes are terminal and selectors are literals");
|
|
277
|
+
const node = graph.nodes[edge.output_node];
|
|
278
|
+
if (hidden(node.op, edge.output_slot) ||
|
|
279
|
+
(node.op === "OptionSelector" && edge.output_slot === 1))
|
|
280
|
+
throw new Error("This input cannot be connected safely on this Kakapo build");
|
|
281
|
+
const key = `${edge.output_node}:${edge.output_slot}`;
|
|
282
|
+
if (incoming.has(key))
|
|
283
|
+
throw new Error("A material input has more than one source");
|
|
284
|
+
incoming.add(key);
|
|
285
|
+
adjacency[edge.input_node].push(edge.output_node);
|
|
286
|
+
degree[edge.output_node]++;
|
|
287
|
+
});
|
|
288
|
+
const queue = degree.flatMap((n, i) => (n === 0 ? [i] : []));
|
|
289
|
+
for (let cursor = 0; cursor < queue.length; cursor++)
|
|
290
|
+
for (const next of adjacency[queue[cursor]])
|
|
291
|
+
if (--degree[next] === 0)
|
|
292
|
+
queue.push(next);
|
|
293
|
+
if (queue.length !== graph.nodes.length)
|
|
294
|
+
throw new Error("Material graph contains a cycle");
|
|
295
|
+
if (!graph.nodes.some((node) => node.op.startsWith("Output")))
|
|
296
|
+
throw new Error("Material graph needs an output node");
|
|
297
|
+
for (const op of graph.nodes
|
|
298
|
+
.filter((node) => node.op.startsWith("Output"))
|
|
299
|
+
.map((node) => node.op))
|
|
300
|
+
if (graph.nodes.filter((n) => n.op === op).length > 1)
|
|
301
|
+
throw new Error(`Only one ${op} per layer is supported`);
|
|
302
|
+
graph.nodes.forEach((node, i) => {
|
|
303
|
+
if (node.op === "OutputMaterial")
|
|
304
|
+
for (let s = 0; s < descriptors[i].inputs.length; s++)
|
|
305
|
+
if (!incoming.has(`${i}:${s}`) &&
|
|
306
|
+
Object.values(readVector(node, s)).some((v) => v !== 0))
|
|
307
|
+
throw new Error("Unconnected OutputMaterial literals are ignored by Kakapo; connect a Constant/Passthrough node instead");
|
|
308
|
+
});
|
|
309
|
+
return graph;
|
|
310
|
+
}
|
|
311
|
+
/** Drop nodes that cannot contribute to any surface output after validating the graph. */
|
|
312
|
+
export function pruneMaterialGraph(graph) {
|
|
313
|
+
const needed = new Set();
|
|
314
|
+
const incoming = new Map();
|
|
315
|
+
for (const edge of graph.connections) {
|
|
316
|
+
const sources = incoming.get(edge.output_node) ?? [];
|
|
317
|
+
sources.push(edge.input_node);
|
|
318
|
+
incoming.set(edge.output_node, sources);
|
|
319
|
+
}
|
|
320
|
+
const queue = graph.nodes.flatMap((node, index) => node.op.startsWith("Output") ? [index] : []);
|
|
321
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
322
|
+
const node = queue[cursor];
|
|
323
|
+
if (needed.has(node))
|
|
324
|
+
continue;
|
|
325
|
+
needed.add(node);
|
|
326
|
+
queue.push(...(incoming.get(node) ?? []));
|
|
327
|
+
}
|
|
328
|
+
const indices = new Map();
|
|
329
|
+
const nodes = graph.nodes.filter((_, index) => {
|
|
330
|
+
if (!needed.has(index))
|
|
331
|
+
return false;
|
|
332
|
+
indices.set(index, indices.size);
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
return { ...graph, nodes, connections: graph.connections.filter(edge => needed.has(edge.output_node)).map(edge => ({ ...edge, input_node: indices.get(edge.input_node), output_node: indices.get(edge.output_node) })) };
|
|
336
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type SceneHost, type SceneScreenshotOptions } from "../capabilities/scene.js";
|
|
3
|
+
import type { ArtifactRef, SparkInvocationContext } from "../capabilities.js";
|
|
4
|
+
import type { MaterialsHost, MaterialStatus } from "../capabilities/materials.js";
|
|
5
|
+
export type MaterialInspectionOptions = {
|
|
6
|
+
views: SceneScreenshotOptions[];
|
|
7
|
+
/** Maximum compilation/resource wait, milliseconds. Defaults to 60 seconds. */
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
export type MaterialInspectionResult = {
|
|
11
|
+
frames: ArtifactRef[];
|
|
12
|
+
queueSettled: boolean;
|
|
13
|
+
timedOut: boolean;
|
|
14
|
+
status: MaterialStatus;
|
|
15
|
+
};
|
|
16
|
+
export type MaterialInspectionHost = Pick<SceneHost, "screenshot">;
|
|
17
|
+
export declare const materialInspectionSchema: z.ZodObject<{
|
|
18
|
+
views: z.ZodArray<z.ZodObject<{
|
|
19
|
+
camera: z.ZodOptional<z.ZodObject<{
|
|
20
|
+
position: z.ZodObject<{
|
|
21
|
+
x: z.ZodNumber;
|
|
22
|
+
y: z.ZodNumber;
|
|
23
|
+
z: z.ZodNumber;
|
|
24
|
+
}, z.core.$strict>;
|
|
25
|
+
target: z.ZodObject<{
|
|
26
|
+
x: z.ZodNumber;
|
|
27
|
+
y: z.ZodNumber;
|
|
28
|
+
z: z.ZodNumber;
|
|
29
|
+
}, z.core.$strict>;
|
|
30
|
+
projection: z.ZodOptional<z.ZodEnum<{
|
|
31
|
+
perspective: "perspective";
|
|
32
|
+
orthographic: "orthographic";
|
|
33
|
+
}>>;
|
|
34
|
+
horizontalFovDegrees: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
orthographicWidth: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
}, z.core.$strict>>;
|
|
37
|
+
}, z.core.$strict>>;
|
|
38
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
}, z.core.$strict>;
|
|
40
|
+
export declare function inspectMaterial(host: MaterialsHost, captures: MaterialInspectionHost, options: MaterialInspectionOptions, context: SparkInvocationContext): Promise<MaterialInspectionResult>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { screenshotOptions } from "../capabilities/scene.js";
|
|
3
|
+
export const materialInspectionSchema = z.object({ views: z.array(screenshotOptions).min(1), timeoutMs: z.number().int().positive().max(300000).optional() }).strict();
|
|
4
|
+
export async function inspectMaterial(host, captures, options, context) {
|
|
5
|
+
const action = async () => {
|
|
6
|
+
const frames = [];
|
|
7
|
+
const deadline = performance.now() + (options.timeoutMs ?? 60000);
|
|
8
|
+
const settled = (status) => status.remaining === 0 && status.pendingResources !== null && Object.values(status.pendingResources).every(names => names.length === 0) && !status.errors?.length;
|
|
9
|
+
const observe = async () => { context.signal.throwIfAborted(); const status = await host.status(context); context.signal.throwIfAborted(); return status; };
|
|
10
|
+
const wait = async (initial) => {
|
|
11
|
+
let status = initial;
|
|
12
|
+
while (!settled(status) && status.remaining !== null && status.pendingResources !== null && !status.errors?.length && performance.now() < deadline) {
|
|
13
|
+
await new Promise((resolve, reject) => {
|
|
14
|
+
const abort = () => { clearTimeout(timer); reject(context.signal.reason); };
|
|
15
|
+
const timer = setTimeout(() => { context.signal.removeEventListener("abort", abort); resolve(); }, Math.min(200, Math.max(0, deadline - performance.now())));
|
|
16
|
+
context.signal.addEventListener("abort", abort, { once: true });
|
|
17
|
+
if (context.signal.aborted) {
|
|
18
|
+
context.signal.removeEventListener("abort", abort);
|
|
19
|
+
abort();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
status = await observe();
|
|
23
|
+
}
|
|
24
|
+
return status;
|
|
25
|
+
};
|
|
26
|
+
const result = (status) => ({ frames, status, queueSettled: settled(status), timedOut: !settled(status) && performance.now() >= deadline });
|
|
27
|
+
context.signal.throwIfAborted();
|
|
28
|
+
// A first frame registers shaders even when the queue was initially empty.
|
|
29
|
+
await captures.screenshot(options.views[0], context);
|
|
30
|
+
let status = await wait(await observe());
|
|
31
|
+
if (!settled(status))
|
|
32
|
+
return result(status);
|
|
33
|
+
for (const view of options.views) {
|
|
34
|
+
context.signal.throwIfAborted();
|
|
35
|
+
let frame = await captures.screenshot(view, context);
|
|
36
|
+
const next = await observe();
|
|
37
|
+
// Another angle can expose previously hidden shaders; settle and recapture it.
|
|
38
|
+
if (!settled(next)) {
|
|
39
|
+
status = await wait(next);
|
|
40
|
+
if (!settled(status))
|
|
41
|
+
return result(status);
|
|
42
|
+
frame = await captures.screenshot(view, context);
|
|
43
|
+
status = await observe();
|
|
44
|
+
if (!settled(status))
|
|
45
|
+
return result(status);
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
status = next;
|
|
49
|
+
frames.push(frame);
|
|
50
|
+
}
|
|
51
|
+
return result(status);
|
|
52
|
+
};
|
|
53
|
+
return context.waitForService ? context.waitForService("Inspecting material views", action) : action();
|
|
54
|
+
}
|