@womp/spark-agent 8.0.0 → 9.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/CHANGELOG.md +58 -0
- package/README.md +3 -2
- package/dist/capabilities/materials.d.ts +10 -1
- package/dist/capabilities/materials.js +30 -10
- package/dist/generated/materials-declarations.d.ts +1 -1
- package/dist/generated/materials-declarations.js +1 -1
- package/dist/generated/materials-flowir-declarations.d.ts +1 -0
- package/dist/generated/materials-flowir-declarations.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +0 -1
- package/dist/kakapo.d.ts +1 -1
- package/dist/materials/flowir.d.ts +15 -0
- package/dist/materials/flowir.js +362 -0
- package/dist/materials/graph.d.ts +7 -0
- package/dist/materials/graph.js +13 -1
- package/dist/materials/lift.d.ts +3 -0
- package/dist/materials/lift.js +216 -0
- package/dist/materials/schemas/materials-dialect.json +690 -0
- package/dist/materials/types.d.ts +15 -0
- package/dist/scene-operations.d.ts +2 -17
- package/dist/scene-operations.js +4 -40
- package/dist/sdk-geometry-host.js +1 -2
- package/dist/sdk-materials-host.js +136 -10
- package/dist/sdk-openscad-host.js +1 -3
- package/dist/sdk-scene-host.d.ts +3 -5
- package/dist/sdk-scene-host.js +5 -15
- package/dist/server/sqlite-store.d.ts +1 -5
- package/dist/server/sqlite-store.js +3 -40
- package/dist/server/standalone.js +0 -1
- package/package.json +3 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import * as flowir from "@womp/flowir";
|
|
2
|
+
import dialect from "./schemas/materials-dialect.json" with { type: "json" };
|
|
3
|
+
import { formatBoundedDiagnostics } from "../geometry/diagnostics.js";
|
|
4
|
+
import { makeMaterialNode, validateMaterialGraph, value3 } from "./graph.js";
|
|
5
|
+
export const materialFlowirRegistry = flowir.Registry.fromJsonObject(dialect);
|
|
6
|
+
export const materialDialectNames = {
|
|
7
|
+
InputMaterial: "IncomingMaterial", Noise1D: "ScalarNoise", Noise3D: "VectorNoise",
|
|
8
|
+
};
|
|
9
|
+
export const materialScalarMathOperations = ["ScalarAdd", "ScalarSubtract", "ScalarMultiply", "ScalarDivide"];
|
|
10
|
+
function normalizeLegacyProgram(program) {
|
|
11
|
+
const types = new Map(program.statements.flatMap(statement => statement.kind === "node" ? [[statement.id, statement.nodeType]] : []));
|
|
12
|
+
const value = (item) => {
|
|
13
|
+
if ((item.kind === "ref" || item.kind === "float_ref") && types.get(item.nodeId) === "BrickTexture")
|
|
14
|
+
item.port = { Bricks: "Variation", Mortar: "MortarMask" }[item.port] ?? item.port;
|
|
15
|
+
if (item.kind === "vec3ctor")
|
|
16
|
+
[item.x, item.y, item.z].forEach(value);
|
|
17
|
+
if (item.kind === "float_binop") {
|
|
18
|
+
value(item.left);
|
|
19
|
+
value(item.right);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
for (const statement of program.statements) {
|
|
23
|
+
if (statement.kind !== "node")
|
|
24
|
+
continue;
|
|
25
|
+
statement.nodeType = materialDialectNames[statement.nodeType] ?? statement.nodeType;
|
|
26
|
+
const ports = ["ScalarNoise", "VectorNoise", "NoiseFBM"].includes(statement.nodeType)
|
|
27
|
+
? { Scale: "FrequencyScale", NoiseScale: "Amplitude" }
|
|
28
|
+
: statement.nodeType === "WavesTexture" ? { CoordinateScale: "FrequencyScale", Scale: "Amplitude" }
|
|
29
|
+
: statement.nodeType === "CheckerTexture" ? { CoordinateScale: "FrequencyScale" }
|
|
30
|
+
: statement.nodeType === "BrickTexture" ? { Scale: "FrequencyScale" } : {};
|
|
31
|
+
statement.inputs = statement.inputs.filter(input => statement.nodeType !== "PredefinedTexture" || input.name !== "Coarseness");
|
|
32
|
+
for (const input of statement.inputs) {
|
|
33
|
+
input.name = ports[input.name] ?? input.name;
|
|
34
|
+
value(input.value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let prompt;
|
|
39
|
+
export function materialFlowirReference() {
|
|
40
|
+
prompt ??= flowir.renderPrompt(materialFlowirRegistry);
|
|
41
|
+
return prompt;
|
|
42
|
+
}
|
|
43
|
+
export const materialMathOperations = [
|
|
44
|
+
"MathAdd", "MathSubtract", "MathMultiply", "MathDivide", "", "MathPower",
|
|
45
|
+
"MathSine", "MathCosine", "MathAbsoluteValue", "MathLength", "MathFraction",
|
|
46
|
+
"MathModulo", "MathFloor", "MathMin", "MathMax", "MathDotProduct",
|
|
47
|
+
"MathNormalize", "MathDistance", "MathSnap", "MathPingPong", "MathTangent",
|
|
48
|
+
"MathArcsine", "MathArccosine", "MathArctangent", "MathArctan2",
|
|
49
|
+
];
|
|
50
|
+
const enumLabels = {
|
|
51
|
+
Mix: "Base", Chebyshev: "Chebychev", Minkowski: "Minkowsky",
|
|
52
|
+
LessOrEqual: "Less of equal", GreaterOrEqual: "Greater of equal",
|
|
53
|
+
Raw: "No transform", SRGB: "sRGB (Color)", NormalMap: "Normal",
|
|
54
|
+
};
|
|
55
|
+
const normalized = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
56
|
+
function checkPort(type, native, label) {
|
|
57
|
+
const numeric = type === "Vec3" ? ["Float3", "Color"] : type === "Float" ? ["Float"] :
|
|
58
|
+
type === "Int" ? ["Integer"] : type === "Bool" ? ["Boolean"] : [];
|
|
59
|
+
if (materialFlowirRegistry.isEnum(type)) {
|
|
60
|
+
const expected = materialFlowirRegistry.getEnumValues(type);
|
|
61
|
+
if (native.type !== "Enum" || expected.some((value, index) => normalized(enumLabels[value] ?? value) !== normalized(native.values?.[index] ?? "")))
|
|
62
|
+
throw new Error(`Kakapo enum contract differs for ${label}`);
|
|
63
|
+
}
|
|
64
|
+
else if (!numeric.includes(native.type))
|
|
65
|
+
throw new Error(`Kakapo port contract differs for ${label}`);
|
|
66
|
+
}
|
|
67
|
+
export function comparableMaterialGraph(graph) {
|
|
68
|
+
const indices = new Map();
|
|
69
|
+
const literals = new Map();
|
|
70
|
+
const nodes = graph.nodes.flatMap((node, index) => {
|
|
71
|
+
if (node.op === "Options")
|
|
72
|
+
return [];
|
|
73
|
+
indices.set(index, indices.size);
|
|
74
|
+
const copy = { ...node };
|
|
75
|
+
delete copy.reference_id;
|
|
76
|
+
delete copy.location;
|
|
77
|
+
delete copy.editor_flags;
|
|
78
|
+
if (copy.op === "Passthrough" && !graph.connections.some(edge => edge.output_node === index)) {
|
|
79
|
+
literals.set(index, 0);
|
|
80
|
+
delete copy.name;
|
|
81
|
+
}
|
|
82
|
+
else if (copy.op === "Constant" && (copy.in3?.x ?? 0) === 0) {
|
|
83
|
+
const slots = new Set(graph.connections.filter(edge => edge.input_node === index).map(edge => edge.input_slot));
|
|
84
|
+
const slot = slots.size === 1 ? [...slots][0] : undefined;
|
|
85
|
+
if (slot === 1 || slot === 2) {
|
|
86
|
+
literals.set(index, slot);
|
|
87
|
+
return [{ op: "Passthrough", in0: copy[`in${slot}`] ?? value3(0) }];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (copy.op === "PredefinedTexture") {
|
|
91
|
+
delete copy.in1;
|
|
92
|
+
delete copy.in2;
|
|
93
|
+
delete copy.in3; // Native sampling does not use Coarseness.
|
|
94
|
+
}
|
|
95
|
+
if (copy.op === "Constant" && copy.in3?.x === 2) {
|
|
96
|
+
copy.in2 = copy.in1;
|
|
97
|
+
delete copy.in1;
|
|
98
|
+
copy.in3 = value3(1);
|
|
99
|
+
}
|
|
100
|
+
return [copy];
|
|
101
|
+
});
|
|
102
|
+
const connections = graph.connections.map(edge => ({
|
|
103
|
+
input_node: indices.get(edge.input_node),
|
|
104
|
+
input_slot: literals.has(edge.input_node) ? 0 : graph.nodes[edge.input_node]?.op === "Constant" &&
|
|
105
|
+
graph.nodes[edge.input_node].in3?.x === 2 && edge.input_slot === 1
|
|
106
|
+
? 2 : edge.input_slot,
|
|
107
|
+
output_node: indices.get(edge.output_node), output_slot: edge.output_slot,
|
|
108
|
+
flags: edge.flags ?? 0,
|
|
109
|
+
})).sort((a, b) => (a.output_node - b.output_node) || (a.output_slot - b.output_slot));
|
|
110
|
+
return JSON.stringify({ nodes, connections }, (_key, value) => value && typeof value === "object" && !Array.isArray(value)
|
|
111
|
+
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
|
112
|
+
}
|
|
113
|
+
function vector(value) {
|
|
114
|
+
if (typeof value === "number")
|
|
115
|
+
return value3(value);
|
|
116
|
+
return { x: value[0], y: value[1], z: value[2] };
|
|
117
|
+
}
|
|
118
|
+
export function materialPortSlot(nodeType, name, index) {
|
|
119
|
+
if (materialMathOperations.includes(nodeType) ||
|
|
120
|
+
materialScalarMathOperations.includes(nodeType))
|
|
121
|
+
return name === "Clamp" ? 3 : index + 1;
|
|
122
|
+
if (nodeType === "OutputHeight" && name === "Scale")
|
|
123
|
+
return 2;
|
|
124
|
+
if (nodeType === "ColorRamp" && name === "Mode")
|
|
125
|
+
return 3;
|
|
126
|
+
if (nodeType === "PredefinedTexture")
|
|
127
|
+
return { Coordinates: 0, Coarseness: 3, Transform: 4, DisableFiltering: 5 }[name] ?? -1;
|
|
128
|
+
if (nodeType === "ProjectionModeSelector")
|
|
129
|
+
return -1;
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
function primitive(value, type) {
|
|
133
|
+
if (value.kind === "float" || value.kind === "int")
|
|
134
|
+
return value.value;
|
|
135
|
+
if (value.kind === "bool")
|
|
136
|
+
return value.value ? 1 : 0;
|
|
137
|
+
if (value.kind === "vec3")
|
|
138
|
+
return vector(value.value);
|
|
139
|
+
if (value.kind === "string")
|
|
140
|
+
return value.value;
|
|
141
|
+
if (value.kind === "ident") {
|
|
142
|
+
const index = materialFlowirRegistry.getEnumValues(type).indexOf(value.name);
|
|
143
|
+
if (index >= 0)
|
|
144
|
+
return index;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
export function compileMaterialFlowir(source, catalog) {
|
|
149
|
+
const parsed = flowir.parseIRText(source);
|
|
150
|
+
if (!parsed.ok)
|
|
151
|
+
throw new Error(formatBoundedDiagnostics(source, parsed.diagnostics));
|
|
152
|
+
normalizeLegacyProgram(parsed.program);
|
|
153
|
+
const checked = flowir.compileFromProgram(parsed.program, materialFlowirRegistry);
|
|
154
|
+
if (!checked.ok)
|
|
155
|
+
throw new Error(formatBoundedDiagnostics(source, checked.diagnostics));
|
|
156
|
+
const graph = { nodes: [], connections: [] };
|
|
157
|
+
const symbols = {};
|
|
158
|
+
const values = new Map();
|
|
159
|
+
const add = (node) => graph.nodes.push(node) - 1;
|
|
160
|
+
const connect = (from, to, slot) => graph.connections.push({
|
|
161
|
+
input_node: from.node, input_slot: from.slot, output_node: to, output_slot: slot,
|
|
162
|
+
});
|
|
163
|
+
const literal = (value, type) => {
|
|
164
|
+
const result = primitive(value, type);
|
|
165
|
+
if (result === null)
|
|
166
|
+
throw new Error(`Line ${value.line}: ${type} requires a literal value`);
|
|
167
|
+
return result;
|
|
168
|
+
};
|
|
169
|
+
const resolve = (value) => {
|
|
170
|
+
if (value.kind === "ref") {
|
|
171
|
+
const found = values.get(`${value.nodeId}.${value.port}`);
|
|
172
|
+
if (!found)
|
|
173
|
+
throw new Error(`Line ${value.line}: unresolved ${value.nodeId}.${value.port}`);
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
if (value.kind === "ident") {
|
|
177
|
+
const found = values.get(value.name);
|
|
178
|
+
if (found)
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`Line ${value.line}: expected a node output or parameter`);
|
|
182
|
+
};
|
|
183
|
+
const expression = (expr) => {
|
|
184
|
+
if (expr.kind === "float_ident") {
|
|
185
|
+
const found = values.get(expr.name);
|
|
186
|
+
if (!found)
|
|
187
|
+
throw new Error(`Line ${expr.line}: unresolved ${expr.name}`);
|
|
188
|
+
return found;
|
|
189
|
+
}
|
|
190
|
+
if (expr.kind === "float_ref") {
|
|
191
|
+
const found = values.get(`${expr.nodeId}.${expr.port}`);
|
|
192
|
+
if (!found)
|
|
193
|
+
throw new Error(`Line ${expr.line}: unresolved ${expr.nodeId}.${expr.port}`);
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
if (expr.kind === "float_lit") {
|
|
197
|
+
const id = add(makeMaterialNode({ op: "Passthrough", inputs: { "0": expr.value } }));
|
|
198
|
+
return { node: id, slot: 0, type: "Float" };
|
|
199
|
+
}
|
|
200
|
+
const op = { "+": 0, "-": 1, "*": 2, "/": 3 }[expr.op];
|
|
201
|
+
const id = add(makeMaterialNode({ op: "Math", inputs: { "0": op } }));
|
|
202
|
+
connect(expression(expr.left), id, 1);
|
|
203
|
+
connect(expression(expr.right), id, 2);
|
|
204
|
+
return { node: id, slot: 0, type: "Float" };
|
|
205
|
+
};
|
|
206
|
+
const attach = (value, type, to, slot, output) => {
|
|
207
|
+
if (value.kind === "ref" || (value.kind === "ident" && values.has(value.name))) {
|
|
208
|
+
const from = resolve(value);
|
|
209
|
+
if (from.type === "Float" && type === "Vec3" &&
|
|
210
|
+
(value.kind === "ident" || catalog[graph.nodes[from.node].op]?.outputs[from.slot]?.type === "Float3")) {
|
|
211
|
+
const combined = add(makeMaterialNode({ op: "CombineValues" }));
|
|
212
|
+
for (let axis = 0; axis < 3; axis++)
|
|
213
|
+
connect(from, combined, axis);
|
|
214
|
+
connect({ node: combined, slot: 0, type: "Vec3" }, to, slot);
|
|
215
|
+
}
|
|
216
|
+
else
|
|
217
|
+
connect(from, to, slot);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (value.kind === "vec3ctor") {
|
|
221
|
+
const combined = add(makeMaterialNode({ op: "CombineValues" }));
|
|
222
|
+
for (const [axis, component] of [value.x, value.y, value.z].entries())
|
|
223
|
+
connect(expression(component), combined, axis);
|
|
224
|
+
connect({ node: combined, slot: 0, type: "Vec3" }, to, slot);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const raw = literal(value, type);
|
|
228
|
+
if (typeof raw === "string")
|
|
229
|
+
throw new Error(`Line ${value.line}: string is only valid for Texture`);
|
|
230
|
+
if (output) {
|
|
231
|
+
const passthrough = add(makeMaterialNode({ op: "Passthrough", inputs: { "0": raw } }));
|
|
232
|
+
connect({ node: passthrough, slot: 0, type }, to, slot);
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
graph.nodes[to][`in${slot}`] = typeof raw === "number" ? vector(raw) : raw;
|
|
236
|
+
};
|
|
237
|
+
const outputs = new Set();
|
|
238
|
+
for (const statement of parsed.program.statements) {
|
|
239
|
+
if (statement.kind === "param" || statement.kind === "const") {
|
|
240
|
+
const color = statement.typeName === "Vec3";
|
|
241
|
+
const raw = literal(statement.value, statement.typeName);
|
|
242
|
+
if (typeof raw === "string")
|
|
243
|
+
throw new Error(`Line ${statement.line}: invalid parameter value`);
|
|
244
|
+
const node = makeMaterialNode({ op: "Constant", name: statement.name, inputs: {
|
|
245
|
+
[color ? "2" : "1"]: raw,
|
|
246
|
+
"3": statement.kind === "const" ? 0 : color ? 1 : 3,
|
|
247
|
+
...(statement.kind === "param" && statement.range.min !== null && statement.range.max !== null
|
|
248
|
+
? { "4": { x: statement.range.min, y: statement.range.max, z: statement.range.step ?? 0 } }
|
|
249
|
+
: {}),
|
|
250
|
+
} });
|
|
251
|
+
const id = add(node);
|
|
252
|
+
symbols[`$${statement.name}`] = id;
|
|
253
|
+
values.set(statement.name, { node: id, slot: color ? 2 : 1, type: statement.typeName });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (statement.kind !== "node")
|
|
257
|
+
throw new Error(`Line ${statement.line}: material programs do not use out statements`);
|
|
258
|
+
const schema = materialFlowirRegistry.getNode(statement.nodeType);
|
|
259
|
+
if (schema.type.startsWith("Output")) {
|
|
260
|
+
if (outputs.has(schema.type))
|
|
261
|
+
throw new Error(`Line ${statement.line}: duplicate ${schema.type}`);
|
|
262
|
+
outputs.add(schema.type);
|
|
263
|
+
}
|
|
264
|
+
if (!catalog[schema.type])
|
|
265
|
+
throw new Error(`Kakapo does not support ${schema.type}`);
|
|
266
|
+
const id = add(makeMaterialNode({ op: schema.type, name: statement.id }));
|
|
267
|
+
symbols[statement.id] = id;
|
|
268
|
+
const node = graph.nodes[id];
|
|
269
|
+
const scalarMath = materialScalarMathOperations.indexOf(statement.nodeType);
|
|
270
|
+
if (schema.type === "Math") {
|
|
271
|
+
const operation = scalarMath >= 0 ? scalarMath : materialMathOperations.indexOf(statement.nodeType);
|
|
272
|
+
const expected = operation === 2 ? "Mulitiply" : materialMathOperations[operation]?.slice(4);
|
|
273
|
+
if (operation < 0 || normalized(catalog.Math?.inputs[0]?.values?.[operation] ?? "") !== normalized(expected ?? ""))
|
|
274
|
+
throw new Error(`Kakapo does not support ${statement.nodeType}`);
|
|
275
|
+
node.in0 = value3(operation);
|
|
276
|
+
}
|
|
277
|
+
const bindings = new Map(statement.inputs.map(input => [input.name, input.value]));
|
|
278
|
+
for (const [index, port] of schema.inputs.entries()) {
|
|
279
|
+
const value = bindings.get(port.name);
|
|
280
|
+
if (!value)
|
|
281
|
+
continue;
|
|
282
|
+
if (port.literal && (value.kind === "ref" || (value.kind === "ident" && values.has(value.name))))
|
|
283
|
+
throw new Error(`Line ${value.line}: ${statement.nodeType}.${port.name} requires a literal`);
|
|
284
|
+
if (statement.nodeType === "ColorRamp" && /^Position[1-4]$|^Color[1-4]$/.test(port.name))
|
|
285
|
+
continue;
|
|
286
|
+
if (statement.nodeType === "ProjectionModeSelector")
|
|
287
|
+
continue;
|
|
288
|
+
if (statement.nodeType === "PredefinedTexture" && port.name === "Texture")
|
|
289
|
+
continue;
|
|
290
|
+
const slot = materialPortSlot(statement.nodeType, port.name, index);
|
|
291
|
+
if (slot < 0 || slot >= catalog[schema.type].inputs.length)
|
|
292
|
+
throw new Error(`Kakapo has no port for ${statement.nodeType}.${port.name}`);
|
|
293
|
+
checkPort(scalarMath >= 0 && port.type === "Float" ? "Vec3" : port.type, catalog[schema.type].inputs[slot], `${statement.nodeType}.${port.name}`);
|
|
294
|
+
attach(value, port.type, id, slot, schema.type === "OutputMaterial" || schema.type === "OutputSurfaceNormal");
|
|
295
|
+
if (statement.nodeType === "Projection" && port.name === "Mode" && value.kind === "ref")
|
|
296
|
+
node.in5 = value3(1);
|
|
297
|
+
}
|
|
298
|
+
if (statement.nodeType === "ColorRamp") {
|
|
299
|
+
const ramp = [];
|
|
300
|
+
for (let i = 1; i <= 4; i++) {
|
|
301
|
+
const position = bindings.get(`Position${i}`), color = bindings.get(`Color${i}`);
|
|
302
|
+
if (!!position !== !!color)
|
|
303
|
+
throw new Error(`Line ${statement.line}: ramp stop ${i} needs position and color`);
|
|
304
|
+
if (position && color) {
|
|
305
|
+
const p = literal(position, "Float"), c = literal(color, "Vec3");
|
|
306
|
+
if (typeof p !== "number" || typeof c !== "object")
|
|
307
|
+
throw new Error(`Line ${statement.line}: invalid ramp stop ${i}`);
|
|
308
|
+
ramp.push({ position: p, color: c });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
Object.assign(node, makeMaterialNode({ op: "ColorRamp", ramp }));
|
|
312
|
+
}
|
|
313
|
+
if (statement.nodeType === "ProjectionModeSelector") {
|
|
314
|
+
if (bindings.has("Option4") && !bindings.has("Option3"))
|
|
315
|
+
throw new Error(`Line ${statement.line}: Option4 requires Option3`);
|
|
316
|
+
const options = [];
|
|
317
|
+
for (let i = 1; i <= 4; i++) {
|
|
318
|
+
const option = bindings.get(`Option${i}`);
|
|
319
|
+
if (option) {
|
|
320
|
+
const choice = literal(option, "ProjectionMode");
|
|
321
|
+
if (typeof choice !== "number")
|
|
322
|
+
throw new Error(`Line ${option.line}: invalid projection mode`);
|
|
323
|
+
options.push({ name: materialFlowirRegistry.getEnumValues("ProjectionMode")[choice], value: choice });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const selected = bindings.get("Selected");
|
|
327
|
+
const choice = selected ? literal(selected, "Int") : 1;
|
|
328
|
+
if (typeof choice !== "number" || !Number.isInteger(choice) || choice < 1 || choice > options.length)
|
|
329
|
+
throw new Error(`Line ${statement.line}: Selected must name an option within 1..${options.length}`);
|
|
330
|
+
Object.assign(node, makeMaterialNode({ op: "OptionSelector", options, inputs: { "1": choice - 1 } }));
|
|
331
|
+
}
|
|
332
|
+
if (statement.nodeType === "PredefinedTexture") {
|
|
333
|
+
const texture = bindings.get("Texture");
|
|
334
|
+
if (!texture || texture.kind !== "string")
|
|
335
|
+
throw new Error(`Line ${statement.line}: Texture requires a path string`);
|
|
336
|
+
Object.assign(node, makeMaterialNode({ op: "PredefinedTexture", texture: texture.value }));
|
|
337
|
+
}
|
|
338
|
+
for (const [index, port] of schema.outputs.entries())
|
|
339
|
+
values.set(`${statement.id}.${port.name}`, { node: id, slot: index, type: port.type });
|
|
340
|
+
}
|
|
341
|
+
if (outputs.size === 0)
|
|
342
|
+
throw new Error("Material program needs an Output node");
|
|
343
|
+
const validated = validateMaterialGraph(graph, catalog);
|
|
344
|
+
const depths = new Map();
|
|
345
|
+
const depth = (index) => {
|
|
346
|
+
const known = depths.get(index);
|
|
347
|
+
if (known !== undefined)
|
|
348
|
+
return known;
|
|
349
|
+
const inputs = validated.connections.filter(edge => edge.output_node === index);
|
|
350
|
+
const result = inputs.length ? Math.max(...inputs.map(edge => depth(edge.input_node))) + 1 : 0;
|
|
351
|
+
depths.set(index, result);
|
|
352
|
+
return result;
|
|
353
|
+
};
|
|
354
|
+
const rows = new Map();
|
|
355
|
+
validated.nodes.forEach((node, index) => {
|
|
356
|
+
const column = depth(index);
|
|
357
|
+
const row = rows.get(column) ?? 0;
|
|
358
|
+
node.location = { x: column * 250, y: row * 150 };
|
|
359
|
+
rows.set(column, row + 1);
|
|
360
|
+
});
|
|
361
|
+
return { graph: validated, symbols };
|
|
362
|
+
}
|
|
@@ -71,6 +71,13 @@ export declare const writeSchema: z.ZodObject<{
|
|
|
71
71
|
}, z.core.$strict>>>>;
|
|
72
72
|
objectIds: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
|
73
73
|
}, z.core.$strict>;
|
|
74
|
+
export declare const programWriteSchema: z.ZodObject<{
|
|
75
|
+
materialId: z.ZodOptional<z.ZodNumber>;
|
|
76
|
+
cloneFrom: z.ZodOptional<z.ZodNumber>;
|
|
77
|
+
layerIndex: z.ZodNumber;
|
|
78
|
+
source: z.ZodString;
|
|
79
|
+
objectIds: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
|
80
|
+
}, z.core.$strict>;
|
|
74
81
|
export declare const value3: (value: number) => MaterialVector;
|
|
75
82
|
export declare function makeMaterialNode(options: MaterialNodeOptions): MaterialNode;
|
|
76
83
|
export declare function parseMaterialCatalog(value: unknown): Record<string, MaterialNodeDescription>;
|
package/dist/materials/graph.js
CHANGED
|
@@ -61,6 +61,13 @@ export const writeSchema = z
|
|
|
61
61
|
})
|
|
62
62
|
.strict()
|
|
63
63
|
.refine((v) => v.materialId === undefined || v.cloneFrom === undefined, "Use materialId or cloneFrom, not both");
|
|
64
|
+
export const programWriteSchema = z.object({
|
|
65
|
+
materialId: materialId.optional(),
|
|
66
|
+
cloneFrom: materialId.optional(),
|
|
67
|
+
layerIndex: z.number().int().min(0).max(7),
|
|
68
|
+
source: z.string().min(1).max(100_000),
|
|
69
|
+
objectIds: z.array(index).optional(),
|
|
70
|
+
}).strict().refine(value => value.materialId === undefined || value.cloneFrom === undefined, "Use materialId or cloneFrom, not both");
|
|
64
71
|
const nodeOptions = z
|
|
65
72
|
.object({
|
|
66
73
|
op: z.string().min(1),
|
|
@@ -272,7 +279,12 @@ export function validateMaterialGraph(input, catalog) {
|
|
|
272
279
|
!inputPort ||
|
|
273
280
|
graph.nodes[edge.input_node].op.startsWith("Output") ||
|
|
274
281
|
!["Float", "Float3", "Color"].includes(output.type) ||
|
|
275
|
-
!["Float", "Float3", "Color"].includes(inputPort.type)
|
|
282
|
+
!(["Float", "Float3", "Color"].includes(inputPort.type) ||
|
|
283
|
+
(graph.nodes[edge.input_node].op === "OptionSelector" &&
|
|
284
|
+
graph.nodes[edge.output_node].op === "Projection" &&
|
|
285
|
+
edge.input_slot === 0 && edge.output_slot === 0 &&
|
|
286
|
+
inputPort.type === "Enum" &&
|
|
287
|
+
readVector(graph.nodes[edge.output_node], 5).x === 1)))
|
|
276
288
|
throw new Error("Invalid connection: use existing numeric ports; output nodes are terminal and selectors are literals");
|
|
277
289
|
const node = graph.nodes[edge.output_node];
|
|
278
290
|
if (hidden(node.op, edge.output_slot) ||
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { comparableMaterialGraph, compileMaterialFlowir, materialDialectNames, materialFlowirRegistry, materialMathOperations, materialPortSlot, materialScalarMathOperations } from "./flowir.js";
|
|
2
|
+
function number(node, slot) {
|
|
3
|
+
const value = node[`in${slot}`];
|
|
4
|
+
return value?.x ?? 0;
|
|
5
|
+
}
|
|
6
|
+
function literal(value, type) {
|
|
7
|
+
const v = value ?? { x: 0, y: 0, z: 0 };
|
|
8
|
+
if (type === "Vec3")
|
|
9
|
+
return `Vec3(${v.x},${v.y},${v.z})`;
|
|
10
|
+
if (type === "Bool")
|
|
11
|
+
return v.x === 0 ? "false" : "true";
|
|
12
|
+
return String(v.x);
|
|
13
|
+
}
|
|
14
|
+
function nodeType(node) {
|
|
15
|
+
if (node.op === "Math") {
|
|
16
|
+
const name = materialMathOperations[number(node, 0)];
|
|
17
|
+
if (!name)
|
|
18
|
+
throw new Error("Unsupported Math operation");
|
|
19
|
+
return name;
|
|
20
|
+
}
|
|
21
|
+
return node.op === "OptionSelector" ? "ProjectionModeSelector" : materialDialectNames[node.op] ?? node.op;
|
|
22
|
+
}
|
|
23
|
+
function safeId(value, fallback) {
|
|
24
|
+
if (typeof value !== "string" || value.length === 0)
|
|
25
|
+
return fallback;
|
|
26
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
27
|
+
throw new Error(`Node/parameter name ${JSON.stringify(value)} cannot be preserved in FlowIR`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
/** Lift only graphs that compile back to equivalent native data. */
|
|
31
|
+
export function liftMaterialFlowir(graph, catalog) {
|
|
32
|
+
const emitted = new Set();
|
|
33
|
+
const visiting = new Set();
|
|
34
|
+
const names = new Map();
|
|
35
|
+
const incoming = new Map();
|
|
36
|
+
for (const edge of graph.connections)
|
|
37
|
+
incoming.set(`${edge.output_node}:${edge.output_slot}`, { node: edge.input_node, slot: edge.input_slot });
|
|
38
|
+
const scalarMath = (index, seen = new Set()) => {
|
|
39
|
+
const node = graph.nodes[index];
|
|
40
|
+
if (node.op !== "Math" || number(node, 0) > 3 || seen.has(index))
|
|
41
|
+
return false;
|
|
42
|
+
const next = new Set(seen).add(index);
|
|
43
|
+
const users = graph.connections.filter(edge => edge.input_node === index);
|
|
44
|
+
return users.length > 0 && users.every(edge => catalog[graph.nodes[edge.output_node].op]?.inputs[edge.output_slot]?.type === "Float" ||
|
|
45
|
+
scalarMath(edge.output_node, next));
|
|
46
|
+
};
|
|
47
|
+
const types = graph.nodes.map((node, index) => scalarMath(index)
|
|
48
|
+
? materialScalarMathOperations[number(node, 0)] : nodeType(node));
|
|
49
|
+
const lines = [];
|
|
50
|
+
const roles = new Map();
|
|
51
|
+
for (const [index, node] of graph.nodes.entries()) {
|
|
52
|
+
if (node.op === "Options")
|
|
53
|
+
continue;
|
|
54
|
+
const name = safeId(node.name, `n${index}`);
|
|
55
|
+
if ([...names.values()].includes(name))
|
|
56
|
+
throw new Error(`Duplicate FlowIR symbol ${name}`);
|
|
57
|
+
names.set(index, name);
|
|
58
|
+
roles.set(index, node.op === "Constant" || node.op === "Passthrough" ? `$${name}` : name);
|
|
59
|
+
}
|
|
60
|
+
const outputRef = (index, port) => {
|
|
61
|
+
const node = graph.nodes[index];
|
|
62
|
+
if (node.op === "Constant")
|
|
63
|
+
return names.get(index);
|
|
64
|
+
if (node.op === "Passthrough")
|
|
65
|
+
return names.get(index);
|
|
66
|
+
const type = types[index];
|
|
67
|
+
const schema = materialFlowirRegistry.getNode(type);
|
|
68
|
+
const output = schema?.outputs[port];
|
|
69
|
+
if (!output)
|
|
70
|
+
throw new Error(`Unsupported output ${node.op}.${port}`);
|
|
71
|
+
return `${names.get(index)}.${output.name}`;
|
|
72
|
+
};
|
|
73
|
+
for (const [index, node] of graph.nodes.entries()) {
|
|
74
|
+
if (node.op === "Passthrough") {
|
|
75
|
+
if (graph.connections.some(edge => edge.output_node === index))
|
|
76
|
+
throw new Error("Connected Passthrough inputs cannot be preserved");
|
|
77
|
+
lines.push(`const Vec3 ${names.get(index)} = ${literal(node.in0, "Vec3")}`);
|
|
78
|
+
emitted.add(index);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (node.op !== "Constant")
|
|
82
|
+
continue;
|
|
83
|
+
if (number(node, 0) !== 0)
|
|
84
|
+
throw new Error("Constant operation selector cannot be preserved");
|
|
85
|
+
const exposure = number(node, 3);
|
|
86
|
+
if (![0, 1, 2, 3].includes(exposure))
|
|
87
|
+
throw new Error("Unsupported Constant panel widget");
|
|
88
|
+
const used = graph.connections.filter(edge => edge.input_node === index);
|
|
89
|
+
const ports = new Set(used.map(edge => edge.input_slot));
|
|
90
|
+
if (ports.has(0) || ports.size > 1)
|
|
91
|
+
throw new Error("Constant output usage cannot be preserved");
|
|
92
|
+
const color = exposure === 1 || exposure === 2 || ports.has(2);
|
|
93
|
+
if ((color && exposure !== 2 && ports.has(1)) || (!color && ports.has(2)))
|
|
94
|
+
throw new Error("Constant type does not match its connections");
|
|
95
|
+
const type = color ? "Vec3" : "Float";
|
|
96
|
+
const definition = literal(node[`in${color && exposure !== 2 ? 2 : 1}`], type);
|
|
97
|
+
const range = node.in4;
|
|
98
|
+
const suffix = exposure === 3 && range && range.y > range.x
|
|
99
|
+
? ` min=${range.x} max=${range.y} step=${range.z}` : "";
|
|
100
|
+
lines.push(`${exposure === 0 ? "const" : "param"} ${type} ${names.get(index)} = ${definition}${suffix}`);
|
|
101
|
+
emitted.add(index);
|
|
102
|
+
}
|
|
103
|
+
const visit = (index) => {
|
|
104
|
+
if (emitted.has(index))
|
|
105
|
+
return;
|
|
106
|
+
if (visiting.has(index))
|
|
107
|
+
throw new Error("Material graph contains a cycle");
|
|
108
|
+
visiting.add(index);
|
|
109
|
+
const node = graph.nodes[index];
|
|
110
|
+
if (!node)
|
|
111
|
+
throw new Error("Material graph refers to a missing node");
|
|
112
|
+
for (const edge of graph.connections.filter(edge => edge.output_node === index))
|
|
113
|
+
visit(edge.input_node);
|
|
114
|
+
if (node.op === "Options") {
|
|
115
|
+
emitted.add(index);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (node.op === "Constant")
|
|
119
|
+
return;
|
|
120
|
+
const type = types[index];
|
|
121
|
+
const schema = materialFlowirRegistry.getNode(type);
|
|
122
|
+
if (!schema)
|
|
123
|
+
throw new Error(`Unsupported native material operation ${node.op}`);
|
|
124
|
+
const bindings = [];
|
|
125
|
+
for (const [portIndex, port] of schema.inputs.entries()) {
|
|
126
|
+
if (type === "ColorRamp" && /^Position[1-4]$|^Color[1-4]$/.test(port.name))
|
|
127
|
+
continue;
|
|
128
|
+
if (type === "ProjectionModeSelector")
|
|
129
|
+
continue;
|
|
130
|
+
if (type === "PredefinedTexture" && port.name === "Texture")
|
|
131
|
+
continue;
|
|
132
|
+
const nativeSlot = materialPortSlot(type, port.name, portIndex);
|
|
133
|
+
const edge = incoming.get(`${index}:${nativeSlot}`);
|
|
134
|
+
if (edge) {
|
|
135
|
+
bindings.push(`${port.name}=${outputRef(edge.node, edge.slot)}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const stored = node[`in${nativeSlot}`];
|
|
139
|
+
if (port.optional && stored === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
if (node.op === "OutputMaterial")
|
|
142
|
+
continue;
|
|
143
|
+
if (materialFlowirRegistry.isEnum(port.type)) {
|
|
144
|
+
const value = materialFlowirRegistry.getEnumValues(port.type)[number(node, nativeSlot)];
|
|
145
|
+
if (!value)
|
|
146
|
+
throw new Error(`Invalid ${type}.${port.name} enum`);
|
|
147
|
+
bindings.push(`${port.name}=${value}`);
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
bindings.push(`${port.name}=${literal(stored, port.type)}`);
|
|
151
|
+
}
|
|
152
|
+
if (type === "ColorRamp") {
|
|
153
|
+
const count = number(node, 1);
|
|
154
|
+
if (count < 2 || count > 4)
|
|
155
|
+
throw new Error("Unsupported ramp size");
|
|
156
|
+
const bytes = Uint8Array.from(node.external_data);
|
|
157
|
+
const view = new DataView(bytes.buffer);
|
|
158
|
+
for (let i = 0; i < count; i++) {
|
|
159
|
+
bindings.push(`Position${i + 1}=${view.getFloat32(i * 16, true)}`);
|
|
160
|
+
bindings.push(`Color${i + 1}=Vec3(${view.getFloat32(i * 16 + 4, true)},${view.getFloat32(i * 16 + 8, true)},${view.getFloat32(i * 16 + 12, true)})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (type === "ProjectionModeSelector") {
|
|
164
|
+
const count = number(node, 0);
|
|
165
|
+
const selected = number(node, 1);
|
|
166
|
+
if (count < 2 || count > 4 || !Number.isInteger(selected) || selected < 0 || selected >= count)
|
|
167
|
+
throw new Error("Unsupported projection selector");
|
|
168
|
+
const bytes = Uint8Array.from(node.external_data);
|
|
169
|
+
const view = new DataView(bytes.buffer);
|
|
170
|
+
for (let i = 0; i < count; i++) {
|
|
171
|
+
const value = materialFlowirRegistry.getEnumValues("ProjectionMode")[view.getFloat32(i * 36, true)];
|
|
172
|
+
if (!value)
|
|
173
|
+
throw new Error("Unsupported selector option");
|
|
174
|
+
bindings.push(`Option${i + 1}=${value}`);
|
|
175
|
+
}
|
|
176
|
+
if (selected !== 0)
|
|
177
|
+
bindings.push(`Selected=${selected + 1}`);
|
|
178
|
+
}
|
|
179
|
+
if (type === "PredefinedTexture") {
|
|
180
|
+
const bytes = Uint8Array.from(node.external_data);
|
|
181
|
+
const end = bytes.indexOf(0);
|
|
182
|
+
if (end < 1)
|
|
183
|
+
throw new Error("Texture path is missing");
|
|
184
|
+
bindings.push(`Texture=${JSON.stringify(new TextDecoder().decode(bytes.slice(0, end)))}`);
|
|
185
|
+
}
|
|
186
|
+
lines.push(`node ${names.get(index)} ${type}${bindings.length ? " " + bindings.join(" ") : ""}`);
|
|
187
|
+
emitted.add(index);
|
|
188
|
+
visiting.delete(index);
|
|
189
|
+
};
|
|
190
|
+
for (let index = 0; index < graph.nodes.length; index++)
|
|
191
|
+
visit(index);
|
|
192
|
+
const source = lines.join("\n");
|
|
193
|
+
const rebuilt = compileMaterialFlowir(source, catalog);
|
|
194
|
+
const remap = new Map();
|
|
195
|
+
const reordered = { nodes: [], connections: [] };
|
|
196
|
+
for (const [index, role] of roles) {
|
|
197
|
+
const target = rebuilt.symbols[role];
|
|
198
|
+
if (target === undefined)
|
|
199
|
+
throw new Error(`Cannot preserve ${role}`);
|
|
200
|
+
remap.set(index, target);
|
|
201
|
+
reordered.nodes[target] = graph.nodes[index];
|
|
202
|
+
}
|
|
203
|
+
if (reordered.nodes.length !== rebuilt.graph.nodes.length ||
|
|
204
|
+
reordered.nodes.filter(Boolean).length !== rebuilt.graph.nodes.length)
|
|
205
|
+
throw new Error("This graph requires extra nodes to represent in FlowIR");
|
|
206
|
+
reordered.connections = graph.connections.map(edge => ({ ...edge,
|
|
207
|
+
input_node: remap.get(edge.input_node), output_node: remap.get(edge.output_node),
|
|
208
|
+
}));
|
|
209
|
+
rebuilt.graph.nodes.forEach((node, index) => {
|
|
210
|
+
if (reordered.nodes[index].name === undefined)
|
|
211
|
+
delete node.name;
|
|
212
|
+
});
|
|
213
|
+
if (comparableMaterialGraph(rebuilt.graph) !== comparableMaterialGraph(reordered))
|
|
214
|
+
throw new Error("This native graph contains values or connections FlowIR cannot preserve");
|
|
215
|
+
return source;
|
|
216
|
+
}
|