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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +56 -0
- package/src/core/context.js +328 -0
- package/src/core/digest.js +232 -0
- package/src/core/kernel.js +67 -0
- package/src/core/node.js +187 -0
- package/src/export/mesh.js +83 -0
- package/src/export/stl.js +49 -0
- package/src/index.d.ts +406 -0
- package/src/index.js +102 -0
- package/src/lang/ast.js +226 -0
- package/src/lang/builtins.js +562 -0
- package/src/lang/evaluate.js +937 -0
- package/src/lang/lexer.js +359 -0
- package/src/lang/parser.js +522 -0
- package/src/values/angle.js +94 -0
- package/src/values/transform.js +159 -0
- package/src/values/vector.js +169 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/** @import { AttributeReader, BlockDefinition, Dimensionality, FunctionDefinition, SourceLocation } from '../index.js' */
|
|
2
|
+
|
|
3
|
+
import { GeometryNode } from '../core/node.js';
|
|
4
|
+
import { Transform } from '../values/transform.js';
|
|
5
|
+
import { Angle } from '../values/angle.js';
|
|
6
|
+
import { FormaError } from './lexer.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders names as a quoted, comma-separated list, for an error message that has to say
|
|
10
|
+
* which ones it means.
|
|
11
|
+
*
|
|
12
|
+
* @param {ReadonlyArray<string>} names The names.
|
|
13
|
+
* @returns {string} The list, each name in double quotes.
|
|
14
|
+
*/
|
|
15
|
+
export function quoteAll(names) {
|
|
16
|
+
return names.map((name) => `"${name}"`).join(', ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The evaluated attributes of one block, read with the type each one is meant to be.
|
|
21
|
+
*
|
|
22
|
+
* Every block reads its attributes through this, so a bad value names the block and the
|
|
23
|
+
* attribute rather than surfacing later as a kernel error about a NaN vertex.
|
|
24
|
+
*
|
|
25
|
+
* Reading through it is also what tracks which attributes were used, so
|
|
26
|
+
* {@link Args#checkUnused} can reject the ones that were not.
|
|
27
|
+
*/
|
|
28
|
+
export class Args {
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} type The block's name, used in every error message.
|
|
31
|
+
* @param {Record<string, unknown>} values The evaluated attributes.
|
|
32
|
+
* @param {SourceLocation} loc Where the block was written.
|
|
33
|
+
*/
|
|
34
|
+
constructor(type, values, loc) {
|
|
35
|
+
/** @type {string} The block's name. */
|
|
36
|
+
this.type = type;
|
|
37
|
+
/** @type {Record<string, unknown>} The evaluated attributes. */
|
|
38
|
+
this.values = values;
|
|
39
|
+
/** @type {SourceLocation} Where the block was written. */
|
|
40
|
+
this.loc = loc;
|
|
41
|
+
/** @type {Set<string>} Which attributes have been read. */
|
|
42
|
+
this.used = new Set();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tests for an attribute without marking it used, so a block can branch on which of two
|
|
47
|
+
* spellings was given and still reject the one it did not read.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} name The attribute.
|
|
50
|
+
* @returns {boolean} Whether it was given.
|
|
51
|
+
*/
|
|
52
|
+
has(name) { return this.values[name] !== undefined; }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} name The attribute.
|
|
56
|
+
* @param {unknown} [fallback] Used when the attribute was not given.
|
|
57
|
+
* @returns {unknown} The value, unchecked.
|
|
58
|
+
*/
|
|
59
|
+
raw(name, fallback) {
|
|
60
|
+
this.used.add(name);
|
|
61
|
+
const value = this.values[name];
|
|
62
|
+
return value === undefined ? fallback : value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} name The attribute.
|
|
67
|
+
* @param {string} expected What it should have been, phrased to follow "must be".
|
|
68
|
+
* @returns {never} Never returns.
|
|
69
|
+
* @throws {FormaError} Always.
|
|
70
|
+
*/
|
|
71
|
+
fail(name, expected) {
|
|
72
|
+
throw new FormaError(`${this.type}: "${name}" must be ${expected}`, this.loc);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} name The attribute.
|
|
77
|
+
* @param {number} [fallback] Used when the attribute was not given.
|
|
78
|
+
* @returns {number} The value.
|
|
79
|
+
* @throws {FormaError} If it is not a finite number.
|
|
80
|
+
*/
|
|
81
|
+
number(name, fallback) {
|
|
82
|
+
const value = this.raw(name, fallback);
|
|
83
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) this.fail(name, 'a finite number');
|
|
84
|
+
return /** @type {number} */ (value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} name The attribute.
|
|
89
|
+
* @returns {number | undefined} The value, or undefined when it was not given.
|
|
90
|
+
* @throws {FormaError} If it was given but is not a finite number.
|
|
91
|
+
*/
|
|
92
|
+
optionalNumber(name) {
|
|
93
|
+
return this.has(name) ? this.number(name) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} name The attribute.
|
|
98
|
+
* @param {number} [fallback] Used when the attribute was not given.
|
|
99
|
+
* @returns {number} The value.
|
|
100
|
+
* @throws {FormaError} If it is not a whole finite number.
|
|
101
|
+
*/
|
|
102
|
+
int(name, fallback) {
|
|
103
|
+
const value = this.number(name, fallback);
|
|
104
|
+
if (!Number.isInteger(value)) this.fail(name, 'a whole number');
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} name The attribute.
|
|
110
|
+
* @param {boolean} [fallback] Used when the attribute was not given.
|
|
111
|
+
* @returns {boolean} The value.
|
|
112
|
+
* @throws {FormaError} If it is not a boolean.
|
|
113
|
+
*/
|
|
114
|
+
bool(name, fallback) {
|
|
115
|
+
const value = this.raw(name, fallback);
|
|
116
|
+
if (typeof value !== 'boolean') this.fail(name, 'true or false');
|
|
117
|
+
return /** @type {boolean} */ (value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} name The attribute.
|
|
122
|
+
* @param {string} [fallback] Used when the attribute was not given.
|
|
123
|
+
* @returns {string} The value.
|
|
124
|
+
* @throws {FormaError} If it is not a string.
|
|
125
|
+
*/
|
|
126
|
+
string(name, fallback) {
|
|
127
|
+
const value = this.raw(name, fallback);
|
|
128
|
+
if (typeof value !== 'string') this.fail(name, 'a string');
|
|
129
|
+
return /** @type {string} */ (value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} name The attribute.
|
|
134
|
+
* @param {ReadonlyArray<string>} allowed The accepted values.
|
|
135
|
+
* @param {string} [fallback] Used when the attribute was not given.
|
|
136
|
+
* @returns {string} The value.
|
|
137
|
+
* @throws {FormaError} If it is not one of the accepted values.
|
|
138
|
+
*/
|
|
139
|
+
enum(name, allowed, fallback) {
|
|
140
|
+
const value = this.string(name, fallback);
|
|
141
|
+
if (!allowed.includes(value)) {
|
|
142
|
+
this.fail(name, `one of ${quoteAll(allowed)}`);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reads a vector attribute, accepting a single number as shorthand for every component,
|
|
149
|
+
* which is what makes `scale { factor = 2 }` read naturally beside `factor = [2, 1, 1]`.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} name The attribute.
|
|
152
|
+
* @param {Dimensionality} dim How many components are wanted.
|
|
153
|
+
* @param {number | ReadonlyArray<number>} [fallback] Used when the attribute was not given.
|
|
154
|
+
* @returns {number[]} The components.
|
|
155
|
+
* @throws {FormaError} If it is neither a number nor an array of the right length.
|
|
156
|
+
*/
|
|
157
|
+
vector(name, dim, fallback) {
|
|
158
|
+
const value = this.raw(name, fallback);
|
|
159
|
+
if (typeof value === 'number') return new Array(dim).fill(value);
|
|
160
|
+
if (Array.isArray(value) && value.length === dim && value.every((v) => typeof v === 'number')) {
|
|
161
|
+
return value.map(Number);
|
|
162
|
+
}
|
|
163
|
+
return this.fail(name, `a number or an array of ${dim} numbers`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} name The attribute.
|
|
168
|
+
* @param {Angle | number} [fallback] Used when the attribute was not given.
|
|
169
|
+
* @returns {Angle} The angle; a bare number is read as degrees.
|
|
170
|
+
* @throws {FormaError} If it is neither an angle nor a number.
|
|
171
|
+
*/
|
|
172
|
+
angle(name, fallback) {
|
|
173
|
+
const value = this.raw(name, fallback);
|
|
174
|
+
if (value instanceof Angle) return value;
|
|
175
|
+
if (typeof value === 'number') return Angle.degrees(value);
|
|
176
|
+
return this.fail(name, 'an angle in degrees');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Rejects anything the block did not read.
|
|
181
|
+
*
|
|
182
|
+
* A misspelled attribute is otherwise silent, and the model renders subtly wrong with
|
|
183
|
+
* nothing to point at.
|
|
184
|
+
*
|
|
185
|
+
* @returns {void}
|
|
186
|
+
* @throws {FormaError} If any attribute went unread.
|
|
187
|
+
*/
|
|
188
|
+
checkUnused() {
|
|
189
|
+
const extra = Object.keys(this.values).filter((k) => !this.used.has(k));
|
|
190
|
+
if (extra.length) {
|
|
191
|
+
const plural = extra.length > 1 ? 's' : '';
|
|
192
|
+
throw new FormaError(`${this.type}: unknown attribute${plural} ${quoteAll(extra)}`, this.loc);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Reads a radius written either way.
|
|
199
|
+
*
|
|
200
|
+
* `radius = 5` and `diameter = 10` are the same circle; accepting both and refusing both at
|
|
201
|
+
* once keeps a model from silently preferring one.
|
|
202
|
+
*
|
|
203
|
+
* @param {AttributeReader} args The block's attributes.
|
|
204
|
+
* @param {number} [fallback] Used when neither was given.
|
|
205
|
+
* @returns {number} The radius.
|
|
206
|
+
* @throws {FormaError} If both spellings were given.
|
|
207
|
+
*/
|
|
208
|
+
function radiusOf(args, fallback) {
|
|
209
|
+
if (args.has('radius') && args.has('diameter')) {
|
|
210
|
+
throw new FormaError(`${args.type}: give either "radius" or "diameter", not both`, args.loc);
|
|
211
|
+
}
|
|
212
|
+
if (args.has('diameter')) return args.number('diameter') / 2;
|
|
213
|
+
return args.number('radius', fallback);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- shape construction --------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The corners of a regular polygon, centred on the origin.
|
|
220
|
+
*
|
|
221
|
+
* @param {number} sides How many sides.
|
|
222
|
+
* @param {number} radius Distance from the centre to a corner.
|
|
223
|
+
* @param {number} rotation Where the first corner sits, in radians.
|
|
224
|
+
* @returns {number[][]} The corners, counter-clockwise.
|
|
225
|
+
*/
|
|
226
|
+
function regularPolygonPoints(sides, radius, rotation) {
|
|
227
|
+
const points = [];
|
|
228
|
+
for (let i = 0; i < sides; i++) {
|
|
229
|
+
const a = rotation + (2 * Math.PI * i) / sides;
|
|
230
|
+
points.push([radius * Math.cos(a), radius * Math.sin(a)]);
|
|
231
|
+
}
|
|
232
|
+
return points;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A rectangle with rounded corners, centred on the origin.
|
|
237
|
+
*
|
|
238
|
+
* Built by shrinking the rectangle and offsetting it back out, because the offset is what
|
|
239
|
+
* produces a true arc at each corner. The radius is clamped so it cannot exceed half the
|
|
240
|
+
* shorter side and invert the shape.
|
|
241
|
+
*
|
|
242
|
+
* @param {ReadonlyArray<number>} size Width and height.
|
|
243
|
+
* @param {number} radius Corner radius.
|
|
244
|
+
* @param {number} segments How many segments per corner; 0 asks the kernel to choose.
|
|
245
|
+
* @returns {GeometryNode} The rounded rectangle.
|
|
246
|
+
*/
|
|
247
|
+
function roundedRect(size, radius, segments) {
|
|
248
|
+
const [w, h] = size;
|
|
249
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
250
|
+
if (r <= 0) return GeometryNode.shape(2, 'rect', { size: [w, h], center: true });
|
|
251
|
+
const inner = GeometryNode.shape(2, 'rect', { size: [w - 2 * r, h - 2 * r], center: true });
|
|
252
|
+
return GeometryNode.unary('offset', inner, {
|
|
253
|
+
amount: r, joinType: 'Round', miterLimit: 2, segments,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Moves a shape built around the origin so it sits in the positive quadrant instead, unless
|
|
259
|
+
* the block asked to keep it centred.
|
|
260
|
+
*
|
|
261
|
+
* @param {GeometryNode} node The centred shape.
|
|
262
|
+
* @param {AttributeReader} args The block's attributes, read for `center`.
|
|
263
|
+
* @param {ReadonlyArray<number>} size The shape's width and height.
|
|
264
|
+
* @returns {GeometryNode} The shape, placed.
|
|
265
|
+
*/
|
|
266
|
+
function placed(node, args, size) {
|
|
267
|
+
return args.bool('center', false)
|
|
268
|
+
? node
|
|
269
|
+
: GeometryNode.transformed(node, Transform.translation([size[0] / 2, size[1] / 2], 2));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Every block the language understands, by name.
|
|
274
|
+
*
|
|
275
|
+
* `dim` is the block's output dimensionality; `takes` is what its children must be. An
|
|
276
|
+
* operation that says `takes: 'same'` passes its children's dimensionality through, which is
|
|
277
|
+
* what lets translate and hull work unchanged in 2D and 3D.
|
|
278
|
+
*
|
|
279
|
+
* @type {Record<string, BlockDefinition>}
|
|
280
|
+
*/
|
|
281
|
+
export const BLOCKS = {
|
|
282
|
+
// 2D shapes
|
|
283
|
+
rect: {
|
|
284
|
+
dim: 2, leaf: true,
|
|
285
|
+
build: (a) => GeometryNode.shape(2, 'rect', {
|
|
286
|
+
size: a.vector('size', 2), center: a.bool('center', false),
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
rounded_rect: {
|
|
290
|
+
dim: 2, leaf: true,
|
|
291
|
+
build: (a) => {
|
|
292
|
+
const size = a.vector('size', 2);
|
|
293
|
+
return placed(roundedRect(size, a.number('radius', 1), a.int('segments', 0)), a, size);
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
circle: {
|
|
297
|
+
dim: 2, leaf: true,
|
|
298
|
+
build: (a) => GeometryNode.shape(2, 'circle', {
|
|
299
|
+
radius: radiusOf(a, 1), segments: a.int('segments', 0),
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
ellipse: {
|
|
303
|
+
dim: 2, leaf: true,
|
|
304
|
+
build: (a) => {
|
|
305
|
+
// A scaled unit circle, so the two radii stay exact rather than being approximated.
|
|
306
|
+
const radii = a.vector('radii', 2);
|
|
307
|
+
const unit = GeometryNode.shape(2, 'circle', { radius: 1, segments: a.int('segments', 0) });
|
|
308
|
+
return GeometryNode.transformed(unit, Transform.scaling(radii, 2));
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
regular_polygon: {
|
|
312
|
+
dim: 2, leaf: true,
|
|
313
|
+
build: (a) => {
|
|
314
|
+
const sides = a.int('sides');
|
|
315
|
+
if (sides < 3) a.fail('sides', 'at least 3');
|
|
316
|
+
// Across-flats is the measurement a hex key or a nut is specified by, so it is worth
|
|
317
|
+
// accepting directly rather than making every model divide by cos(pi/n).
|
|
318
|
+
let radius;
|
|
319
|
+
if (a.has('width_across_flats')) radius = a.number('width_across_flats') / 2 / Math.cos(Math.PI / sides);
|
|
320
|
+
else if (a.has('width_across_corners')) radius = a.number('width_across_corners') / 2;
|
|
321
|
+
else radius = radiusOf(a, 1);
|
|
322
|
+
const rotation = a.angle('rotation', 0).radians;
|
|
323
|
+
return GeometryNode.shape(2, 'polygon', {
|
|
324
|
+
contours: [regularPolygonPoints(sides, radius, rotation)], fillRule: 'NonZero',
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
stadium: {
|
|
329
|
+
dim: 2, leaf: true,
|
|
330
|
+
build: (a) => {
|
|
331
|
+
// Fully rounded on the short axis, which is what makes it a stadium rather than a
|
|
332
|
+
// rounded rectangle that happens to look like one.
|
|
333
|
+
const size = a.vector('size', 2);
|
|
334
|
+
const r = Math.min(size[0], size[1]) / 2;
|
|
335
|
+
return placed(roundedRect(size, r, a.int('segments', 0)), a, size);
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
polygon: {
|
|
339
|
+
dim: 2, leaf: true,
|
|
340
|
+
build: (a) => {
|
|
341
|
+
const points = a.raw('points');
|
|
342
|
+
if (!Array.isArray(points) || points.length < 3) a.fail('points', 'an array of at least 3 points');
|
|
343
|
+
// One contour or several: a nested array is read as a list of contours, so a shape
|
|
344
|
+
// with a hole in it can be written without a separate difference.
|
|
345
|
+
const contours = Array.isArray(points[0][0]) ? points : [points];
|
|
346
|
+
return GeometryNode.shape(2, 'polygon', {
|
|
347
|
+
contours,
|
|
348
|
+
fillRule: a.enum('fill_rule', ['EvenOdd', 'NonZero', 'Positive', 'Negative'], 'NonZero'),
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// 3D shapes
|
|
354
|
+
box: {
|
|
355
|
+
dim: 3, leaf: true,
|
|
356
|
+
build: (a) => GeometryNode.shape(3, 'box', {
|
|
357
|
+
size: a.vector('size', 3), center: a.bool('center', false),
|
|
358
|
+
}),
|
|
359
|
+
},
|
|
360
|
+
sphere: {
|
|
361
|
+
dim: 3, leaf: true,
|
|
362
|
+
build: (a) => GeometryNode.shape(3, 'sphere', {
|
|
363
|
+
radius: radiusOf(a, 1), segments: a.int('segments', 0),
|
|
364
|
+
}),
|
|
365
|
+
},
|
|
366
|
+
cylinder: {
|
|
367
|
+
dim: 3, leaf: true,
|
|
368
|
+
build: (a) => {
|
|
369
|
+
// One radius for a plain cylinder, or two for a frustum; mixing them is the error.
|
|
370
|
+
const base = a.has('radius') || a.has('diameter') ? radiusOf(a, 1) : undefined;
|
|
371
|
+
const bottom = a.optionalNumber('bottom_radius') ?? base;
|
|
372
|
+
const top = a.optionalNumber('top_radius') ?? base;
|
|
373
|
+
if (bottom === undefined || top === undefined) {
|
|
374
|
+
throw new FormaError('cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"', a.loc);
|
|
375
|
+
}
|
|
376
|
+
return GeometryNode.shape(3, 'cylinder', {
|
|
377
|
+
height: a.number('height'), bottomRadius: bottom, topRadius: top,
|
|
378
|
+
segments: a.int('segments', 0), center: a.bool('center', false),
|
|
379
|
+
});
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
cone: {
|
|
383
|
+
dim: 3, leaf: true,
|
|
384
|
+
build: (a) => GeometryNode.shape(3, 'cylinder', {
|
|
385
|
+
height: a.number('height'), bottomRadius: radiusOf(a, 1),
|
|
386
|
+
topRadius: a.number('top_radius', 0), segments: a.int('segments', 0),
|
|
387
|
+
center: a.bool('center', false),
|
|
388
|
+
}),
|
|
389
|
+
},
|
|
390
|
+
torus: {
|
|
391
|
+
dim: 3, leaf: true,
|
|
392
|
+
build: (a) => {
|
|
393
|
+
// A circle pushed out to the ring radius and revolved, which is what a torus is.
|
|
394
|
+
const ring = a.number('radius');
|
|
395
|
+
const tube = a.number('tube_radius');
|
|
396
|
+
const section = GeometryNode.transformed(
|
|
397
|
+
GeometryNode.shape(2, 'circle', { radius: tube, segments: a.int('tube_segments', 0) }),
|
|
398
|
+
Transform.translation([ring, 0], 2),
|
|
399
|
+
);
|
|
400
|
+
return GeometryNode.unary('revolve', section,
|
|
401
|
+
{ degrees: a.angle('angle', 360).degrees, segments: a.int('segments', 0) }, 3);
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
// Booleans
|
|
406
|
+
union: { takes: 'same', combine: 'list', build: (a, children) => GeometryNode.union(children) },
|
|
407
|
+
difference: { takes: 'same', combine: 'list', build: (a, children) => GeometryNode.difference(children) },
|
|
408
|
+
intersection: { takes: 'same', combine: 'list', build: (a, children) => GeometryNode.intersection(children) },
|
|
409
|
+
|
|
410
|
+
// Transforms
|
|
411
|
+
translate: {
|
|
412
|
+
takes: 'same',
|
|
413
|
+
build: (a, child) => GeometryNode.transformed(child, Transform.translation(a.vector('offset', child.dim), child.dim)),
|
|
414
|
+
},
|
|
415
|
+
rotate: {
|
|
416
|
+
takes: 'same',
|
|
417
|
+
build: (a, child) => {
|
|
418
|
+
if (child.dim === 2) {
|
|
419
|
+
return GeometryNode.transformed(child, Transform.rotation2D(a.angle('angle')));
|
|
420
|
+
}
|
|
421
|
+
// `angles = [x, y, z]` or the axes named one at a time; both read naturally depending
|
|
422
|
+
// on whether the model is turning about one axis or several.
|
|
423
|
+
const angles = a.has('angles') ? a.vector('angles', 3) : [a.number('x', 0), a.number('y', 0), a.number('z', 0)];
|
|
424
|
+
return GeometryNode.transformed(child, Transform.rotation3D(angles));
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
scale: {
|
|
428
|
+
takes: 'same',
|
|
429
|
+
build: (a, child) => GeometryNode.transformed(child, Transform.scaling(a.vector('factor', child.dim), child.dim)),
|
|
430
|
+
},
|
|
431
|
+
mirror: {
|
|
432
|
+
takes: 'same',
|
|
433
|
+
build: (a, child) => GeometryNode.transformed(child, Transform.mirroring(a.vector('normal', child.dim), child.dim)),
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
// 2D → 3D
|
|
437
|
+
extrude: {
|
|
438
|
+
dim: 3, takes: 2,
|
|
439
|
+
build: (a, child) => GeometryNode.unary('extrude', child, {
|
|
440
|
+
height: a.number('height'),
|
|
441
|
+
twist: a.angle('twist', 0).degrees,
|
|
442
|
+
divisions: a.int('divisions', 0),
|
|
443
|
+
scaleTop: a.vector('scale_top', 2, 1),
|
|
444
|
+
center: a.bool('center', false),
|
|
445
|
+
}, 3),
|
|
446
|
+
},
|
|
447
|
+
revolve: {
|
|
448
|
+
dim: 3, takes: 2,
|
|
449
|
+
build: (a, child) => GeometryNode.unary('revolve', child, {
|
|
450
|
+
degrees: a.angle('angle', 360).degrees, segments: a.int('segments', 0),
|
|
451
|
+
}, 3),
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
// 3D → 2D
|
|
455
|
+
project: { dim: 2, takes: 3, build: (a, child) => GeometryNode.unary('projection', child, { type: 'full' }, 2) },
|
|
456
|
+
slice: { dim: 2, takes: 3, build: (a, child) => GeometryNode.unary('projection', child, { type: 'slice', z: a.number('z', 0) }, 2) },
|
|
457
|
+
|
|
458
|
+
// Refinement
|
|
459
|
+
offset: {
|
|
460
|
+
takes: 2,
|
|
461
|
+
build: (a, child) => GeometryNode.unary('offset', child, {
|
|
462
|
+
amount: a.number('amount'),
|
|
463
|
+
joinType: a.enum('join', ['Square', 'Round', 'Miter'], 'Round'),
|
|
464
|
+
miterLimit: a.number('miter_limit', 2),
|
|
465
|
+
segments: a.int('segments', 0),
|
|
466
|
+
}),
|
|
467
|
+
},
|
|
468
|
+
hull: { takes: 'same', build: (a, child) => GeometryNode.unary('hull', child) },
|
|
469
|
+
refine: { takes: 3, build: (a, child) => GeometryNode.unary('refine', child, { edgeLength: a.number('edge_length') }) },
|
|
470
|
+
simplify: { takes: 'same', build: (a, child) => GeometryNode.unary('simplify', child, { tolerance: a.number('tolerance', 1e-6) }) },
|
|
471
|
+
smooth: {
|
|
472
|
+
takes: 3,
|
|
473
|
+
build: (a, child) => GeometryNode.unary('smooth', child, {
|
|
474
|
+
minSharpAngle: a.number('min_sharp_angle', 60), minSmoothness: a.number('min_smoothness', 0),
|
|
475
|
+
}),
|
|
476
|
+
},
|
|
477
|
+
trim: {
|
|
478
|
+
takes: 3,
|
|
479
|
+
build: (a, child) => GeometryNode.unary('trim', child, {
|
|
480
|
+
normal: a.vector('normal', 3), offset: a.number('offset', 0),
|
|
481
|
+
}),
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// --- expression functions --------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Wraps a plain numeric function as a builtin.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} name What the language calls it.
|
|
491
|
+
* @param {(...args: number[]) => number} f The implementation.
|
|
492
|
+
* @param {number} [arity] How many arguments it takes.
|
|
493
|
+
* @returns {[string, FunctionDefinition]} An entry for the registry.
|
|
494
|
+
*/
|
|
495
|
+
const numeric = (name, f, arity = 1) => [name, { arity, call: (args) => f(...args) }];
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* How many values `range` will produce before giving up.
|
|
499
|
+
*
|
|
500
|
+
* Guarded rather than trusted: `range(0, 1e9)` would otherwise allocate until the tab dies,
|
|
501
|
+
* and in a live editor that is every keystroke while the number is being typed.
|
|
502
|
+
*/
|
|
503
|
+
const MAX_RANGE = 100000;
|
|
504
|
+
|
|
505
|
+
/** @type {Record<string, FunctionDefinition>} */
|
|
506
|
+
export const FUNCTIONS = Object.fromEntries([
|
|
507
|
+
numeric('abs', Math.abs), numeric('floor', Math.floor), numeric('ceil', Math.ceil),
|
|
508
|
+
numeric('round', Math.round), numeric('sqrt', Math.sqrt), numeric('sign', Math.sign),
|
|
509
|
+
numeric('pow', Math.pow, 2), numeric('atan2', Math.atan2, 2),
|
|
510
|
+
numeric('log', Math.log), numeric('exp', Math.exp),
|
|
511
|
+
// Trigonometry takes and returns degrees, because every angle a model writes is in
|
|
512
|
+
// degrees and a stray radian conversion is invisible until the part is printed.
|
|
513
|
+
numeric('sin', (d) => Math.sin((d * Math.PI) / 180)),
|
|
514
|
+
numeric('cos', (d) => Math.cos((d * Math.PI) / 180)),
|
|
515
|
+
numeric('tan', (d) => Math.tan((d * Math.PI) / 180)),
|
|
516
|
+
numeric('asin', (v) => (Math.asin(v) * 180) / Math.PI),
|
|
517
|
+
numeric('acos', (v) => (Math.acos(v) * 180) / Math.PI),
|
|
518
|
+
numeric('atan', (v) => (Math.atan(v) * 180) / Math.PI),
|
|
519
|
+
// The variadic ones flatten, so `max(sizes)` and `max(a, b)` both work.
|
|
520
|
+
['min', { arity: null, call: (args) => Math.min(...args.flat()) }],
|
|
521
|
+
['max', { arity: null, call: (args) => Math.max(...args.flat()) }],
|
|
522
|
+
['sum', { arity: null, call: (args) => args.flat().reduce((a, b) => a + b, 0) }],
|
|
523
|
+
['len', { arity: 1, call: ([v]) => (Array.isArray(v) || typeof v === 'string' ? v.length : 0) }],
|
|
524
|
+
['concat', { arity: null, call: (args) => args.flat() }],
|
|
525
|
+
['reverse', { arity: 1, call: ([v]) => [...v].reverse() }],
|
|
526
|
+
['contains', { arity: 2, call: ([list, v]) => list.includes(v) }],
|
|
527
|
+
['join', { arity: 2, call: ([sep, list]) => list.join(sep) }],
|
|
528
|
+
['str', { arity: 1, call: ([v]) => String(v) }],
|
|
529
|
+
['num', { arity: 1, call: ([v]) => Number(v) }],
|
|
530
|
+
['range', { arity: null, call: (args) => range(args) }],
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Builds a list of numbers, as `range(end)`, `range(start, end)` or `range(start, end, step)`.
|
|
535
|
+
*
|
|
536
|
+
* A backwards range is empty rather than infinite, which is what a loop written around a
|
|
537
|
+
* parameter needs when the parameter crosses zero.
|
|
538
|
+
*
|
|
539
|
+
* @param {number[]} args The arguments as written.
|
|
540
|
+
* @returns {number[]} The values, excluding the end.
|
|
541
|
+
* @throws {FormaError} If the step is zero, or the range would exceed {@link MAX_RANGE}.
|
|
542
|
+
*/
|
|
543
|
+
function range(args) {
|
|
544
|
+
const [start, end, step] = args.length === 1 ? [0, args[0], 1] : [args[0], args[1], args[2] ?? 1];
|
|
545
|
+
if (step === 0) throw new FormaError('range: step cannot be 0');
|
|
546
|
+
|
|
547
|
+
const out = [];
|
|
548
|
+
for (let v = start; step > 0 ? v < end : v > end; v += step) {
|
|
549
|
+
out.push(v);
|
|
550
|
+
if (out.length > MAX_RANGE) throw new FormaError(`range: more than ${MAX_RANGE} values`);
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Names that stand for a fixed number. */
|
|
556
|
+
export const CONSTANTS = { pi: Math.PI, e: Math.E };
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Type names are ordinary identifiers that stand for themselves, so `type = number` in a
|
|
560
|
+
* param block needs no keyword and no quoting — the same trade Terraform makes.
|
|
561
|
+
*/
|
|
562
|
+
export const TYPE_NAMES = ['number', 'string', 'bool', 'vector', 'list', 'angle'];
|