forma-dsl 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,937 @@
1
+ /** @import { EvaluationContext, EvaluatorOptions, ParameterDescriptor, ParameterValue, Scene, ScenePart } from '../index.js' */
2
+ /** @import { BinaryExpression, Block, Body, CallExpression, Document, Expression, ForBlock, IfBlock, MemberExpression, NamedDeclaration } from './ast.js' */
3
+
4
+ import { parse } from './parser.js';
5
+ import { FormaError } from './lexer.js';
6
+ import { BLOCKS, FUNCTIONS, CONSTANTS, TYPE_NAMES, Args, quoteAll } from './builtins.js';
7
+ import { GeometryNode } from '../core/node.js';
8
+ import { Transform } from '../values/transform.js';
9
+
10
+ /**
11
+ * What a body produces: geometry to hand upwards, and any scene parts declared inside it.
12
+ *
13
+ * Parts travel separately from nodes because a part is not an operand — it is a coloured
14
+ * piece of the finished scene, and an enclosing `translate` must not absorb it.
15
+ *
16
+ * @typedef {object} Produced
17
+ * @property {GeometryNode[]} nodes The geometry.
18
+ * @property {ScenePart[]} parts The scene parts declared below here.
19
+ */
20
+
21
+ /** A lexical scope, chained to its parent. */
22
+ class Scope {
23
+ /**
24
+ * @param {Scope | null} [parent] The enclosing scope.
25
+ * @param {Record<string, unknown>} [values] Names bound in this scope.
26
+ */
27
+ constructor(parent = null, values = {}) {
28
+ /** @type {Scope | null} The enclosing scope. */
29
+ this.parent = parent;
30
+ /** @type {Map<string, unknown>} Names bound here. */
31
+ this.values = new Map(Object.entries(values));
32
+ }
33
+
34
+ /**
35
+ * @param {string} name The name to resolve.
36
+ * @returns {unknown} Its value, or undefined if nothing binds it.
37
+ */
38
+ lookup(name) {
39
+ /** @type {Scope | null} */
40
+ let scope = this;
41
+ while (scope) {
42
+ if (scope.values.has(name)) return scope.values.get(name);
43
+ scope = scope.parent;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * Distinct from a `lookup` that returns undefined, since a name may be bound to undefined.
50
+ *
51
+ * @param {string} name The name to look for.
52
+ * @returns {boolean} Whether anything binds it.
53
+ */
54
+ has(name) {
55
+ /** @type {Scope | null} */
56
+ let scope = this;
57
+ while (scope) {
58
+ if (scope.values.has(name)) return true;
59
+ scope = scope.parent;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * @param {string} name The name to bind.
66
+ * @param {unknown} value What to bind it to.
67
+ * @returns {Scope} This scope.
68
+ */
69
+ define(name, value) { this.values.set(name, value); return this; }
70
+
71
+ /**
72
+ * @param {Record<string, unknown>} [values] Names to bind in the new scope.
73
+ * @returns {Scope} A scope nested inside this one.
74
+ */
75
+ child(values = {}) { return new Scope(this, values); }
76
+ }
77
+
78
+ /** A parsed document, indexed by what each declaration is for. */
79
+ export class Program {
80
+ /**
81
+ * @param {Document} document The parsed document.
82
+ * @throws {FormaError} On a duplicate name, or a component that shadows a builtin.
83
+ */
84
+ constructor(document) {
85
+ /** @type {Map<string, any>} Declared params, in source order. */
86
+ this.params = new Map();
87
+ /** @type {any[]} Declared locals, in source order. */
88
+ this.locals = [];
89
+ /** @type {Map<string, NamedDeclaration>} Declared components. */
90
+ this.components = new Map();
91
+ /** @type {Map<string, NamedDeclaration>} Declared models. */
92
+ this.models = new Map();
93
+
94
+ for (const declaration of document.declarations) {
95
+ switch (declaration.kind) {
96
+ case 'param': {
97
+ if (this.params.has(declaration.name)) {
98
+ throw new FormaError(`param "${declaration.name}" is declared twice`, declaration.loc);
99
+ }
100
+ this.params.set(declaration.name, declaration);
101
+ break;
102
+ }
103
+ case 'local': this.locals.push(declaration); break;
104
+ case 'component': {
105
+ if (this.components.has(declaration.name)) {
106
+ throw new FormaError(`component "${declaration.name}" is declared twice`, declaration.loc);
107
+ }
108
+ if (BLOCKS[declaration.name]) {
109
+ throw new FormaError(`component "${declaration.name}" shadows a builtin block`, declaration.loc);
110
+ }
111
+ this.components.set(declaration.name, declaration);
112
+ break;
113
+ }
114
+ case 'model': {
115
+ if (this.models.has(declaration.name)) {
116
+ throw new FormaError(`model "${declaration.name}" is declared twice`, declaration.loc);
117
+ }
118
+ this.models.set(declaration.name, declaration);
119
+ break;
120
+ }
121
+ default: {
122
+ // Unreachable per the AST types; kept as a guard against a hand-built document.
123
+ const other = /** @type {any} */ (declaration);
124
+ throw new FormaError(`unexpected declaration ${other.kind}`, other.loc);
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ /**
131
+ * @param {string} source The document.
132
+ * @returns {Program} The indexed program.
133
+ * @throws {FormaError} On a syntax error or a duplicate declaration.
134
+ */
135
+ static parse(source) { return new Program(parse(source)); }
136
+
137
+ /**
138
+ * The declared parameters, in source order, as the editor needs them to build controls.
139
+ *
140
+ * A param may legitimately have no default — the document then requires a value from the
141
+ * caller — so the descriptor has to stand on its own. `type` is taken from the declaration
142
+ * when it is given and inferred from the default otherwise, and falls back to `number`,
143
+ * which is what a param without either almost always is.
144
+ *
145
+ * @param {((expression: Expression) => unknown) | null} [evaluator] Used to evaluate each
146
+ * metadata attribute. Without one, every field but the name comes back undefined.
147
+ * @returns {ParameterDescriptor[]} One descriptor per declared param.
148
+ */
149
+ parameterDescriptors(evaluator = null) {
150
+ return [...this.params.values()].map((declaration) => {
151
+ const meta = {};
152
+ for (const attribute of declaration.attributes) {
153
+ meta[attribute.name] = evaluator ? evaluator(attribute.value) : undefined;
154
+ }
155
+ const declared = declaration.attributes.some((a) => a.name === 'default');
156
+ return {
157
+ name: declaration.name,
158
+ type: meta.type ?? inferType(meta.default),
159
+ default: meta.default,
160
+ required: !declared,
161
+ min: meta.min, max: meta.max, step: meta.step,
162
+ description: meta.description,
163
+ options: meta.options,
164
+ };
165
+ });
166
+ }
167
+ }
168
+
169
+ /**
170
+ * How deep blocks may nest before the evaluator gives up.
171
+ *
172
+ * A component that calls itself has no base case to reach, so the depth limit is what turns
173
+ * an infinite recursion into a message naming the likely cause.
174
+ */
175
+ const MAX_DEPTH = 64;
176
+
177
+ /** Turns a program into geometry nodes. */
178
+ export class Evaluator {
179
+ /**
180
+ * Resolving the params and locals is part of construction, so every error about a missing
181
+ * or unreadable input surfaces before any geometry is built.
182
+ *
183
+ * @param {Program} program The program to evaluate.
184
+ * @param {EvaluatorOptions} [options] Params, an optional kernel context, and whether
185
+ * params must all resolve.
186
+ * @throws {FormaError} If a param has no default and no supplied value, unless
187
+ * `requireParams` is off.
188
+ */
189
+ constructor(program, options = {}) {
190
+ const { params = {}, context = null, requireParams = true } = options;
191
+
192
+ /** @type {Program} The program being evaluated. */
193
+ this.program = program;
194
+ /**
195
+ * @type {EvaluationContext | null} Needed only by blocks that measure their child —
196
+ * `align` has to know where the geometry actually is — so a program without one can
197
+ * still be built into a node tree without loading the kernel.
198
+ */
199
+ this.context = context;
200
+ /** @type {number} How deep into component calls evaluation currently is. */
201
+ this.depth = 0;
202
+
203
+ const root = new Scope();
204
+ // Constants and type names are in scope before any param default is read, so a default
205
+ // can use `pi` and a `type = number` attribute resolves.
206
+ for (const [name, value] of Object.entries(CONSTANTS)) root.define(name, value);
207
+ for (const name of TYPE_NAMES) root.define(name, name);
208
+
209
+ const variables = {};
210
+ for (const [name, declaration] of program.params) {
211
+ const fallback = declaration.attributes.find((a) => a.name === 'default');
212
+ const value = this.paramValue(params[name], fallback, root);
213
+ if (value === undefined) {
214
+ if (requireParams) {
215
+ throw new FormaError(`param "${name}" has no default and no value was supplied`, declaration.loc);
216
+ }
217
+ continue;
218
+ }
219
+ variables[name] = value;
220
+ root.define(name, value);
221
+ }
222
+ // Params are reachable both bare and under `var.`, which is how a model distinguishes
223
+ // an input from a local when both are in scope.
224
+ root.define('var', variables);
225
+ for (const local of program.locals) root.define(local.name, this.expression(local.value, root));
226
+
227
+ /** @type {Scope} The document-level scope: constants, type names, params and locals. */
228
+ this.root = root;
229
+ }
230
+
231
+ /**
232
+ * Settles one declared param: what the caller gave, or what the declaration defaults to,
233
+ * or nothing at all — which is the caller's problem to report, since a document param and
234
+ * a component param say different things about it.
235
+ *
236
+ * @param {unknown} supplied What was passed in, or undefined.
237
+ * @param {{ value: Expression } | undefined} fallback The `default` attribute, if declared.
238
+ * @param {Scope} scope Where to evaluate that default.
239
+ * @returns {unknown} The value, or undefined when there is none.
240
+ */
241
+ paramValue(supplied, fallback, scope) {
242
+ if (supplied !== undefined) return supplied;
243
+ return fallback ? this.expression(fallback.value, scope) : undefined;
244
+ }
245
+
246
+ // --- expressions ----------------------------------------------------------------
247
+
248
+ /**
249
+ * Evaluates one expression.
250
+ *
251
+ * @param {Expression} node The expression.
252
+ * @param {Scope} scope Where to resolve names.
253
+ * @returns {unknown} Its value.
254
+ * @throws {FormaError} On an unknown name, a bad operand, or a failing builtin.
255
+ */
256
+ expression(node, scope) {
257
+ switch (node.kind) {
258
+ case 'literal': return node.value;
259
+
260
+ case 'template':
261
+ return node.parts
262
+ .map((part) => (part.kind === 'text' ? part.value : formatValue(this.expression(part.expression, scope))))
263
+ .join('');
264
+
265
+ case 'array': return node.items.map((item) => this.expression(item, scope));
266
+
267
+ case 'object': {
268
+ const out = {};
269
+ for (const entry of node.entries) out[entry.key] = this.expression(entry.value, scope);
270
+ return out;
271
+ }
272
+
273
+ case 'identifier': {
274
+ if (!scope.has(node.name)) {
275
+ throw new FormaError(`unknown name "${node.name}"`, node.loc);
276
+ }
277
+ return scope.lookup(node.name);
278
+ }
279
+
280
+ case 'member': return this.member(node, scope);
281
+
282
+ case 'index': {
283
+ const object = this.expression(node.object, scope);
284
+ const index = this.expression(node.index, scope);
285
+ if (!Array.isArray(object) && typeof object !== 'string' && typeof object !== 'object') {
286
+ throw new FormaError('cannot index this value', node.loc);
287
+ }
288
+ // The language is dynamically typed: a list takes a number and an object a string,
289
+ // and anything else is the model's error to discover at runtime.
290
+ return /** @type {any} */ (object)[/** @type {any} */ (index)];
291
+ }
292
+
293
+ case 'unary': {
294
+ const value = this.expression(node.operand, scope);
295
+ if (node.op === '-') {
296
+ if (typeof value !== 'number') throw new FormaError('cannot negate a non-number', node.loc);
297
+ return -value;
298
+ }
299
+ return !truthy(value);
300
+ }
301
+
302
+ case 'binary': return this.binary(node, scope);
303
+
304
+ case 'conditional':
305
+ return truthy(this.expression(node.condition, scope))
306
+ ? this.expression(node.then, scope)
307
+ : this.expression(node.otherwise, scope);
308
+
309
+ case 'call': return this.call(node, scope);
310
+
311
+ default: {
312
+ // Unreachable per the AST types; kept as a guard against a hand-built tree.
313
+ const unknownNode = /** @type {any} */ (node);
314
+ throw new FormaError(`cannot evaluate ${unknownNode.kind}`, unknownNode.loc);
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Reads a property, with `.x`/`.y`/`.z` on a list as sugar over an index rather than a type.
321
+ *
322
+ * @param {MemberExpression} node A member expression.
323
+ * @param {Scope} scope Where to resolve names.
324
+ * @returns {unknown} The property's value.
325
+ * @throws {FormaError} If there is nothing to read from, or no such property.
326
+ */
327
+ member(node, scope) {
328
+ const object = /** @type {any} */ (this.expression(node.object, scope));
329
+ if (object === null || object === undefined) {
330
+ throw new FormaError(`cannot read "${node.property}" of nothing`, node.loc);
331
+ }
332
+ const axis = { x: 0, y: 1, z: 2 }[node.property];
333
+ if (Array.isArray(object) && axis !== undefined) return object[axis];
334
+ if (!(node.property in object)) {
335
+ throw new FormaError(`no attribute "${node.property}"`, node.loc);
336
+ }
337
+ return object[node.property];
338
+ }
339
+
340
+ /**
341
+ * Calls one of the builtin functions. There are no user-defined functions; a component is
342
+ * the way to name a piece of geometry.
343
+ *
344
+ * @param {CallExpression} node A call expression.
345
+ * @param {Scope} scope Where to resolve names.
346
+ * @returns {unknown} The result.
347
+ * @throws {FormaError} On an unknown function, named arguments, the wrong count, or a
348
+ * failure inside the function.
349
+ */
350
+ call(node, scope) {
351
+ if (node.callee.kind !== 'identifier') {
352
+ throw new FormaError('only named functions can be called', node.loc);
353
+ }
354
+ const fn = FUNCTIONS[node.callee.name];
355
+ if (!fn) throw new FormaError(`unknown function "${node.callee.name}"`, node.loc);
356
+ if (node.args.some((a) => a.name)) {
357
+ throw new FormaError(`${node.callee.name}: functions take positional arguments`, node.loc);
358
+ }
359
+ const args = node.args.map((a) => this.expression(a.value, scope));
360
+ if (fn.arity !== null && args.length !== fn.arity) {
361
+ throw new FormaError(`${node.callee.name} takes ${fn.arity} argument(s), got ${args.length}`, node.loc);
362
+ }
363
+ try {
364
+ return fn.call(args);
365
+ } catch (error) {
366
+ // A builtin throwing a plain Error has no position; give it the call's.
367
+ throw error instanceof FormaError ? error : new FormaError(`${node.callee.name}: ${error.message}`, node.loc);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Applies an infix operator.
373
+ *
374
+ * @param {BinaryExpression} node A binary expression.
375
+ * @param {Scope} scope Where to resolve names.
376
+ * @returns {unknown} The result.
377
+ * @throws {FormaError} On operands the operator does not accept.
378
+ */
379
+ binary(node, scope) {
380
+ const { op } = node;
381
+ // Short-circuit before evaluating the right side, so `count > 0 && list[0] > 1` is safe.
382
+ if (op === '&&') {
383
+ return truthy(this.expression(node.left, scope)) ? truthy(this.expression(node.right, scope)) : false;
384
+ }
385
+ if (op === '||') {
386
+ return truthy(this.expression(node.left, scope)) ? true : truthy(this.expression(node.right, scope));
387
+ }
388
+
389
+ const left = this.expression(node.left, scope);
390
+ const right = this.expression(node.right, scope);
391
+
392
+ switch (op) {
393
+ case '==': return deepEqual(left, right);
394
+ case '!=': return !deepEqual(left, right);
395
+ case '+': return add(left, right, node.loc);
396
+ case '-': return subtract(left, right, node.loc);
397
+ case '*': return multiply(left, right, node.loc);
398
+ case '/': return divide(left, right, node.loc);
399
+ case '%': return arithmetic(op, left, right, node.loc);
400
+ case '<': case '<=': case '>': case '>=': return compare(op, left, right, node.loc);
401
+ default: throw new FormaError(`unknown operator ${op}`, node.loc);
402
+ }
403
+ }
404
+
405
+ // --- geometry -------------------------------------------------------------------
406
+
407
+ /**
408
+ * Builds a body into geometry nodes and any scene parts it declared.
409
+ *
410
+ * @param {Body} body The body.
411
+ * @param {Scope} scope The enclosing scope.
412
+ * @returns {Promise<Produced>} What it produced.
413
+ */
414
+ async body(body, scope) {
415
+ const inner = scope.child();
416
+ for (const local of body.locals) inner.define(local.name, this.expression(local.value, inner));
417
+
418
+ /** @type {Produced} */
419
+ const produced = { nodes: [], parts: [] };
420
+
421
+ for (const block of body.blocks) {
422
+ const result = await this.block(block, inner);
423
+ produced.nodes.push(...result.nodes);
424
+ produced.parts.push(...result.parts);
425
+ }
426
+
427
+ return produced;
428
+ }
429
+
430
+ /**
431
+ * Builds one block, dispatching to whichever of the five things it can be.
432
+ *
433
+ * @param {Block | ForBlock | IfBlock} block The block.
434
+ * @param {Scope} scope The enclosing scope.
435
+ * @returns {Promise<Produced>} What it produced.
436
+ * @throws {FormaError} On unknown blocks, misplaced labels, or nesting past {@link MAX_DEPTH}.
437
+ */
438
+ async block(block, scope) {
439
+ if (this.depth > MAX_DEPTH) {
440
+ throw new FormaError(`nesting deeper than ${MAX_DEPTH} blocks — is a component using itself?`, block.loc);
441
+ }
442
+
443
+ if (block.kind === 'for') return this.forBlock(block, scope);
444
+ if (block.kind === 'if') {
445
+ const taken = truthy(this.expression(block.condition, scope)) ? block.then : block.otherwise;
446
+ return taken ? this.body(taken, scope) : { nodes: [], parts: [] };
447
+ }
448
+
449
+ if (block.type === 'part') return this.partBlock(block, scope);
450
+ if (block.type === 'align') return { nodes: [await this.alignBlock(block, scope)], parts: [] };
451
+
452
+ if (this.program.components.has(block.type)) {
453
+ return { nodes: [await this.component(block, scope)], parts: [] };
454
+ }
455
+
456
+ const definition = BLOCKS[block.type];
457
+ if (!definition) {
458
+ throw new FormaError(`unknown block "${block.type}"`, block.loc);
459
+ }
460
+ if (block.labels.length) {
461
+ throw new FormaError(`"${block.type}" does not take a label`, block.loc);
462
+ }
463
+ return { nodes: [await this.builtinBlock(block, definition, scope)], parts: [] };
464
+ }
465
+
466
+ /**
467
+ * Builds one of the blocks in the registry.
468
+ *
469
+ * @param {Block} block The block.
470
+ * @param {import('../index.js').BlockDefinition} definition Its entry in the registry.
471
+ * @param {Scope} scope The enclosing scope.
472
+ * @returns {Promise<GeometryNode>} The node it produced.
473
+ * @throws {FormaError} If a shape was given children, an operation was given none, the
474
+ * children's dimensionality is wrong, or an attribute went unread.
475
+ */
476
+ async builtinBlock(block, definition, scope) {
477
+ const args = new Args(block.type, this.attributes(block.body, scope), block.loc);
478
+ const { nodes: children } = await this.body(block.body, scope.child());
479
+
480
+ if (definition.leaf) {
481
+ if (children.length) throw new FormaError(`"${block.type}" is a shape and cannot contain other blocks`, block.loc);
482
+ const node = definition.build(args);
483
+ args.checkUnused();
484
+ return node;
485
+ }
486
+
487
+ if (!children.length) {
488
+ throw new FormaError(`"${block.type}" needs at least one shape inside it`, block.loc);
489
+ }
490
+ this.checkDimensions(block, definition, children);
491
+
492
+ // Every operation but a boolean takes a single child; several blocks inside one are
493
+ // implicitly unioned first, so `translate { a b }` moves both together.
494
+ const node = definition.combine === 'list'
495
+ ? definition.build(args, children)
496
+ : definition.build(args, GeometryNode.union(children));
497
+ args.checkUnused();
498
+ return node;
499
+ }
500
+
501
+ /**
502
+ * @param {Block} block The block, for its name and position.
503
+ * @param {import('../index.js').BlockDefinition} definition Its entry in the registry.
504
+ * @param {ReadonlyArray<GeometryNode>} children The evaluated children.
505
+ * @returns {void}
506
+ * @throws {FormaError} If the children mix dimensionalities, or are not what the block takes.
507
+ */
508
+ checkDimensions(block, definition, children) {
509
+ const dims = new Set(children.map((c) => c.dim));
510
+ if (dims.size > 1) {
511
+ throw new FormaError(`"${block.type}" cannot mix 2D and 3D shapes`, block.loc);
512
+ }
513
+ const [dim] = dims;
514
+ if (typeof definition.takes === 'number' && definition.takes !== dim) {
515
+ throw new FormaError(`"${block.type}" takes ${definition.takes}D geometry, got ${dim}D`, block.loc);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * @param {Body} body The body whose attributes to evaluate.
521
+ * @param {Scope} scope Where to resolve names.
522
+ * @returns {Record<string, unknown>} The evaluated attributes.
523
+ * @throws {FormaError} If an attribute is set twice.
524
+ */
525
+ attributes(body, scope) {
526
+ /** @type {Record<string, unknown>} */
527
+ const values = {};
528
+ for (const attribute of body.attributes) {
529
+ if (attribute.name in values) {
530
+ throw new FormaError(`attribute "${attribute.name}" is set twice`, attribute.loc);
531
+ }
532
+ values[attribute.name] = this.expression(attribute.value, scope);
533
+ }
534
+ return values;
535
+ }
536
+
537
+ /**
538
+ * Repeats a body once per item, each iteration in its own scope.
539
+ *
540
+ * @param {ForBlock} block The loop.
541
+ * @param {Scope} scope The enclosing scope.
542
+ * @returns {Promise<Produced>} Everything every iteration produced.
543
+ * @throws {FormaError} If the sequence is not a list.
544
+ */
545
+ async forBlock(block, scope) {
546
+ const sequence = this.expression(block.sequence, scope);
547
+ if (!Array.isArray(sequence)) {
548
+ throw new FormaError('for: expected a list to iterate', block.loc);
549
+ }
550
+
551
+ /** @type {Produced} */
552
+ const produced = { nodes: [], parts: [] };
553
+
554
+ for (const [index, item] of sequence.entries()) {
555
+ const iteration = scope.child();
556
+ // One name binds the value; two bind the index and then the value.
557
+ iteration.define(block.names[0], block.names.length > 1 ? index : item);
558
+ if (block.names.length > 1) iteration.define(block.names[1], item);
559
+ const result = await this.body(block.body, iteration);
560
+ produced.nodes.push(...result.nodes);
561
+ produced.parts.push(...result.parts);
562
+ }
563
+ return produced;
564
+ }
565
+
566
+ /**
567
+ * Builds a `part`, which is a separately coloured piece of the scene rather than an operand.
568
+ *
569
+ * @param {Block} block The part block.
570
+ * @param {Scope} scope The enclosing scope.
571
+ * @returns {Promise<Produced>} The part, and no nodes: a part is not an operand.
572
+ * @throws {FormaError} If it nests another part, is empty, or has an unread attribute.
573
+ */
574
+ async partBlock(block, scope) {
575
+ const args = new Args('part', this.attributes(block.body, scope), block.loc);
576
+ const { nodes, parts } = await this.body(block.body, scope.child());
577
+ if (parts.length) throw new FormaError('a part cannot contain another part', block.loc);
578
+ if (!nodes.length) throw new FormaError('a part needs at least one shape inside it', block.loc);
579
+
580
+ const part = {
581
+ // An unlabelled part still needs a name to show in the viewer; its line is the one
582
+ // thing guaranteed to differ between two of them.
583
+ name: block.labels.length
584
+ ? String(this.expression(block.labels[0], scope))
585
+ : `part_${block.loc.line}`,
586
+ color: args.string('color', '#b8c4d0'),
587
+ opacity: args.number('opacity', 1),
588
+ node: GeometryNode.union(nodes),
589
+ };
590
+ args.checkUnused();
591
+ return { nodes: [], parts: [part] };
592
+ }
593
+
594
+ /**
595
+ * Moves geometry so a chosen feature of its bounding box lands on a chosen coordinate.
596
+ *
597
+ * This is the one block that has to evaluate its child before it can build its own node,
598
+ * because where the geometry is cannot be read off the tree.
599
+ *
600
+ * @param {Block} block The align block.
601
+ * @param {Scope} scope The enclosing scope.
602
+ * @returns {Promise<GeometryNode>} The moved geometry.
603
+ * @throws {FormaError} Without a kernel context, with no children, or on an axis value
604
+ * that is neither a keyword nor a number.
605
+ */
606
+ async alignBlock(block, scope) {
607
+ if (!this.context) {
608
+ throw new FormaError('align needs a kernel context — render the model rather than building it', block.loc);
609
+ }
610
+ const args = new Args('align', this.attributes(block.body, scope), block.loc);
611
+ const { nodes: children } = await this.body(block.body, scope.child());
612
+ if (!children.length) throw new FormaError('align needs at least one shape inside it', block.loc);
613
+
614
+ const child = GeometryNode.union(children);
615
+ const bounds = await this.bounds(child);
616
+ const axes = child.dim === 2 ? ['x', 'y'] : ['x', 'y', 'z'];
617
+
618
+ const offset = axes.map((axis, i) => {
619
+ if (!args.has(axis)) return 0;
620
+ const target = args.raw(axis);
621
+ const [min, max] = [bounds.min[i], bounds.max[i]];
622
+ // A number places the near face there; a keyword places the named feature at zero.
623
+ if (typeof target === 'number') return target - min;
624
+ switch (target) {
625
+ case 'min': return -min;
626
+ case 'center': return -(min + max) / 2;
627
+ case 'max': return -max;
628
+ default: throw new FormaError(`align: "${axis}" must be "min", "center", "max" or a number`, block.loc);
629
+ }
630
+ });
631
+
632
+ args.checkUnused();
633
+ return GeometryNode.transformed(child, Transform.translation(offset, child.dim));
634
+ }
635
+
636
+ /**
637
+ * Measures a node, which means solving it.
638
+ *
639
+ * @param {GeometryNode} node The node to measure.
640
+ * @returns {Promise<{ min: number[], max: number[] }>} Its bounding box.
641
+ */
642
+ async bounds(node) {
643
+ // A 2D node yields a cross-section and a 3D one a solid; each spells its extent
644
+ // differently, and the node's own dimensionality is what says which to ask for.
645
+ const concrete = /** @type {any} */ (await this.context.evaluate(node));
646
+ const box = node.dim === 2 ? concrete.bounds() : concrete.boundingBox();
647
+ return { min: box.min, max: box.max };
648
+ }
649
+
650
+ /**
651
+ * Calls a component, binding its params from the attributes at the call site.
652
+ *
653
+ * @param {Block} block The call.
654
+ * @param {Scope} scope The scope at the call site, used only to evaluate the arguments.
655
+ * @returns {Promise<GeometryNode>} The geometry it produced.
656
+ * @throws {FormaError} On a missing or unknown param, a declared part, or no geometry.
657
+ */
658
+ async component(block, scope) {
659
+ const declaration = this.program.components.get(block.type);
660
+ const supplied = this.attributes(block.body, scope);
661
+
662
+ // A component gets a fresh scope rooted at the document, not at the call site: a name
663
+ // that happens to exist where it is used must not leak into its body.
664
+ const inner = this.root.child();
665
+ const declared = new Set();
666
+ for (const param of declaration.body.params) {
667
+ declared.add(param.name);
668
+ const fallback = param.attributes.find((a) => a.name === 'default');
669
+ const value = this.paramValue(supplied[param.name], fallback, inner);
670
+ if (value === undefined) {
671
+ throw new FormaError(`${block.type}: "${param.name}" has no default and was not given`, block.loc);
672
+ }
673
+ inner.define(param.name, value);
674
+ }
675
+
676
+ const unknown = Object.keys(supplied).filter((k) => !declared.has(k));
677
+ if (unknown.length) {
678
+ const plural = unknown.length > 1 ? 's' : '';
679
+ throw new FormaError(`${block.type}: unknown param${plural} ${quoteAll(unknown)}`, block.loc);
680
+ }
681
+
682
+ this.depth++;
683
+ try {
684
+ const { nodes, parts } = await this.body(declaration.body, inner);
685
+ if (parts.length) throw new FormaError('a component cannot declare parts', block.loc);
686
+ if (!nodes.length) throw new FormaError(`component "${block.type}" produced no geometry`, block.loc);
687
+ return GeometryNode.union(nodes);
688
+ } finally {
689
+ this.depth--;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Builds one model into a scene.
695
+ *
696
+ * @param {string | null} [name] Which model; defaults to the first the document declares.
697
+ * @returns {Promise<Scene>} Its parts, and the union of everything as `node`.
698
+ * @throws {FormaError} If there is no such model, a model param lacks a default, or the
699
+ * model produces nothing.
700
+ */
701
+ async model(name) {
702
+ const declaration = name
703
+ ? this.program.models.get(name)
704
+ : this.program.models.values().next().value;
705
+ if (!declaration) {
706
+ throw new FormaError(name ? `no model named "${name}"` : 'the document declares no model');
707
+ }
708
+
709
+ const scope = this.root.child();
710
+ for (const param of declaration.body.params) {
711
+ const fallback = param.attributes.find((a) => a.name === 'default');
712
+ if (!fallback) throw new FormaError(`model "${declaration.name}": param "${param.name}" needs a default`, param.loc);
713
+ scope.define(param.name, this.expression(fallback.value, scope));
714
+ }
715
+
716
+ const { nodes, parts } = await this.body(declaration.body, scope);
717
+ const all = [...parts.map((p) => p.node), ...nodes];
718
+ if (!all.length) throw new FormaError(`model "${declaration.name}" produced no geometry`, declaration.loc);
719
+
720
+ // Geometry written straight into the model, outside any part, becomes one default part
721
+ // so that everything the viewer draws is a part.
722
+ const loose = nodes.length
723
+ ? [{ name: declaration.name, color: '#b8c4d0', opacity: 1, node: GeometryNode.union(nodes) }]
724
+ : [];
725
+
726
+ return {
727
+ name: declaration.name,
728
+ parts: [...parts, ...loose],
729
+ node: GeometryNode.union(all),
730
+ };
731
+ }
732
+ }
733
+
734
+ // --- value helpers ---------------------------------------------------------------------
735
+
736
+ /**
737
+ * Whether a value counts as true in a condition.
738
+ *
739
+ * Empty strings and empty lists are false, which is what makes `if var.label { ... }` read
740
+ * the way someone writing it expects.
741
+ *
742
+ * @param {unknown} value The value.
743
+ * @returns {boolean} Whether it is truthy.
744
+ */
745
+ function truthy(value) {
746
+ if (typeof value === 'boolean') return value;
747
+ if (value === null || value === undefined) return false;
748
+ if (typeof value === 'number') return value !== 0;
749
+ if (typeof value === 'string') return value.length > 0;
750
+ if (Array.isArray(value)) return value.length > 0;
751
+ return true;
752
+ }
753
+
754
+ /**
755
+ * Renders a value for string interpolation and concatenation.
756
+ *
757
+ * @param {unknown} value The value.
758
+ * @returns {string} Its text, with null and undefined as the empty string.
759
+ */
760
+ function formatValue(value) {
761
+ if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`;
762
+ if (value === null || value === undefined) return '';
763
+ return String(value);
764
+ }
765
+
766
+ /**
767
+ * Structural equality, so two lists with the same numbers compare equal.
768
+ *
769
+ * @param {unknown} a The left value.
770
+ * @param {unknown} b The right value.
771
+ * @returns {boolean} Whether they are equal.
772
+ */
773
+ function deepEqual(a, b) {
774
+ if (Array.isArray(a) && Array.isArray(b)) {
775
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
776
+ }
777
+ return a === b;
778
+ }
779
+
780
+ /**
781
+ * Combines two lists componentwise.
782
+ *
783
+ * @param {ReadonlyArray<any>} a The left list.
784
+ * @param {ReadonlyArray<any>} b The right list.
785
+ * @param {(x: any, y: any) => any} f Applied to each pair.
786
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
787
+ * @returns {any[]} The combined list.
788
+ * @throws {FormaError} If the lists are different lengths.
789
+ */
790
+ function zip(a, b, f, loc) {
791
+ if (a.length !== b.length) throw new FormaError(`cannot combine vectors of length ${a.length} and ${b.length}`, loc);
792
+ return a.map((v, i) => f(v, b[i]));
793
+ }
794
+
795
+ /**
796
+ * `+`: string concatenation when either side is text, componentwise on two lists, and
797
+ * ordinary addition otherwise.
798
+ *
799
+ * @param {unknown} left The left operand.
800
+ * @param {unknown} right The right operand.
801
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
802
+ * @returns {unknown} The result.
803
+ * @throws {FormaError} On operands it does not accept.
804
+ */
805
+ function add(left, right, loc) {
806
+ if (typeof left === 'string' || typeof right === 'string') return formatValue(left) + formatValue(right);
807
+ if (Array.isArray(left) && Array.isArray(right)) return zip(left, right, (a, b) => a + b, loc);
808
+ return arithmetic('+', left, right, loc);
809
+ }
810
+
811
+ /**
812
+ * `-`: componentwise on two lists, ordinary subtraction otherwise.
813
+ *
814
+ * @param {unknown} left The left operand.
815
+ * @param {unknown} right The right operand.
816
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
817
+ * @returns {unknown} The result.
818
+ * @throws {FormaError} On operands it does not accept.
819
+ */
820
+ function subtract(left, right, loc) {
821
+ if (Array.isArray(left) && Array.isArray(right)) return zip(left, right, (a, b) => a - b, loc);
822
+ return arithmetic('-', left, right, loc);
823
+ }
824
+
825
+ /**
826
+ * `*`: a vector times a scalar in either order — the common case in a model — componentwise
827
+ * on two lists, and ordinary multiplication otherwise.
828
+ *
829
+ * @param {unknown} left The left operand.
830
+ * @param {unknown} right The right operand.
831
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
832
+ * @returns {unknown} The result.
833
+ * @throws {FormaError} On operands it does not accept.
834
+ */
835
+ function multiply(left, right, loc) {
836
+ if (Array.isArray(left) && typeof right === 'number') return left.map((v) => v * right);
837
+ if (typeof left === 'number' && Array.isArray(right)) return right.map((v) => v * left);
838
+ if (Array.isArray(left) && Array.isArray(right)) return zip(left, right, (a, b) => a * b, loc);
839
+ return arithmetic('*', left, right, loc);
840
+ }
841
+
842
+ /**
843
+ * `/`: a vector by a scalar, or ordinary division. Unlike `*` there is no scalar-over-vector
844
+ * case, because dividing a number by a vector is not a thing a model means.
845
+ *
846
+ * @param {unknown} left The left operand.
847
+ * @param {unknown} right The right operand.
848
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
849
+ * @returns {unknown} The result.
850
+ * @throws {FormaError} On operands it does not accept, or division by zero.
851
+ */
852
+ function divide(left, right, loc) {
853
+ if (Array.isArray(left) && typeof right === 'number') return left.map((v) => v / right);
854
+ return arithmetic('/', left, right, loc);
855
+ }
856
+
857
+ /**
858
+ * Applies an operator to two numbers.
859
+ *
860
+ * Division and modulo by zero are errors rather than Infinity or NaN, which would otherwise
861
+ * travel all the way into the kernel as a degenerate vertex.
862
+ *
863
+ * @param {string} op The operator.
864
+ * @param {unknown} left The left operand.
865
+ * @param {unknown} right The right operand.
866
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
867
+ * @returns {number} The result.
868
+ * @throws {FormaError} On non-numeric operands, division by zero, or an unknown operator.
869
+ */
870
+ function arithmetic(op, left, right, loc) {
871
+ if (typeof left !== 'number' || typeof right !== 'number') {
872
+ throw new FormaError(`cannot apply ${op} to ${typeName(left)} and ${typeName(right)}`, loc);
873
+ }
874
+ switch (op) {
875
+ case '+': return left + right;
876
+ case '-': return left - right;
877
+ case '*': return left * right;
878
+ case '/':
879
+ if (right === 0) throw new FormaError('division by zero', loc);
880
+ return left / right;
881
+ case '%':
882
+ if (right === 0) throw new FormaError('modulo by zero', loc);
883
+ return left % right;
884
+ default: throw new FormaError(`unknown operator ${op}`, loc);
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Orders two values of the same type.
890
+ *
891
+ * @param {string} op The operator.
892
+ * @param {any} left The left operand.
893
+ * @param {any} right The right operand.
894
+ * @param {import('../index.js').SourceLocation} loc Where the operator was.
895
+ * @returns {boolean} The result.
896
+ * @throws {FormaError} If the operands are different types, or the operator is unknown.
897
+ */
898
+ function compare(op, left, right, loc) {
899
+ if (typeof left !== typeof right) {
900
+ throw new FormaError(`cannot compare ${typeName(left)} with ${typeName(right)}`, loc);
901
+ }
902
+ switch (op) {
903
+ case '<': return left < right;
904
+ case '<=': return left <= right;
905
+ case '>': return left > right;
906
+ case '>=': return left >= right;
907
+ default: throw new FormaError(`unknown operator ${op}`, loc);
908
+ }
909
+ }
910
+
911
+ /**
912
+ * Names a value's type for an error message, phrased to follow "cannot apply + to".
913
+ *
914
+ * @param {unknown} value The value.
915
+ * @returns {string} Its type, as a noun phrase.
916
+ */
917
+ function typeName(value) {
918
+ if (Array.isArray(value)) return 'a list';
919
+ if (value === null) return 'null';
920
+ return `a ${typeof value}`;
921
+ }
922
+
923
+ /**
924
+ * Maps a default value onto the name of the control the editor should build for it.
925
+ *
926
+ * @param {ParameterValue | undefined} value The param's default, if it has one.
927
+ * @returns {string} The type name.
928
+ */
929
+ function inferType(value) {
930
+ if (typeof value === 'number') return 'number';
931
+ if (typeof value === 'boolean') return 'bool';
932
+ if (typeof value === 'string') return 'string';
933
+ if (Array.isArray(value)) return 'vector';
934
+ return 'number';
935
+ }
936
+
937
+ export { Scope, FormaError };