forma-dsl 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-dsl",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A declarative DSL for 3D modeling and scene composition, reusable components, and live previews.",
5
5
  "keywords": [
6
6
  "cad",
@@ -233,7 +233,7 @@ export class EvaluationContext {
233
233
  case 'union': return Concrete.union(parts);
234
234
  case 'difference': return Concrete.difference(parts);
235
235
  case 'intersection': return Concrete.intersection(parts);
236
- default: throw new Error(`unknown boolean operation ${props.op}`);
236
+ default: throw new Error(`unknown boolean operation ${JSON.stringify(props.op)}`);
237
237
  }
238
238
  }
239
239
 
@@ -18,18 +18,34 @@ function rotl(x, r) {
18
18
  return (x << r) | (x >>> (32 - r));
19
19
  }
20
20
 
21
+ /**
22
+ * Wraps a number to a signed 32-bit integer, the way the algorithm's additions are defined.
23
+ *
24
+ * `Math.imul(x, 1)` rather than `x | 0`: the two are identical — both coerce through
25
+ * ToInt32 — but this one says *wrap*, which is what MurmurHash means here. `Math.trunc` is
26
+ * emphatically not a substitute: it drops the fraction without wrapping, so `2**31 + 5`
27
+ * stays positive instead of becoming negative, and every digest downstream changes.
28
+ *
29
+ * @param {number} x The value to wrap.
30
+ * @returns {number} The low 32 bits, signed.
31
+ */
32
+ function int32(x) {
33
+ return Math.imul(x, 1);
34
+ }
35
+
21
36
  /**
22
37
  * Multiplies two 32-bit words, keeping the low bits.
23
38
  *
24
39
  * `Math.imul`, not `*`: a 32-bit product above 2^53 loses its low bits to the double, and
25
- * those low bits are most of the mixing.
40
+ * those low bits are most of the mixing. It is also already defined to return a signed
41
+ * 32-bit result, so nothing here has to wrap it again.
26
42
  *
27
43
  * @param {number} a The left operand.
28
44
  * @param {number} b The right operand.
29
45
  * @returns {number} The low 32 bits of the product.
30
46
  */
31
47
  function mul(a, b) {
32
- return Math.imul(a, b) | 0;
48
+ return Math.imul(a, b);
33
49
  }
34
50
 
35
51
  /**
@@ -90,16 +106,16 @@ export function digestBytes(bytes) {
90
106
  let k4 = view.getUint32(o + 12, true);
91
107
 
92
108
  k1 = mul(k1, C1); k1 = rotl(k1, 15); k1 = mul(k1, C2); h1 ^= k1;
93
- h1 = rotl(h1, 19); h1 = (h1 + h2) | 0; h1 = (mul(h1, 5) + 0x561ccd1b) | 0;
109
+ h1 = rotl(h1, 19); h1 = int32(h1 + h2); h1 = int32(mul(h1, 5) + 0x561ccd1b);
94
110
 
95
111
  k2 = mul(k2, C2); k2 = rotl(k2, 16); k2 = mul(k2, C3); h2 ^= k2;
96
- h2 = rotl(h2, 17); h2 = (h2 + h3) | 0; h2 = (mul(h2, 5) + 0x0bcaa747) | 0;
112
+ h2 = rotl(h2, 17); h2 = int32(h2 + h3); h2 = int32(mul(h2, 5) + 0x0bcaa747);
97
113
 
98
114
  k3 = mul(k3, C3); k3 = rotl(k3, 17); k3 = mul(k3, C4); h3 ^= k3;
99
- h3 = rotl(h3, 15); h3 = (h3 + h4) | 0; h3 = (mul(h3, 5) + 0x96cd1c35) | 0;
115
+ h3 = rotl(h3, 15); h3 = int32(h3 + h4); h3 = int32(mul(h3, 5) + 0x96cd1c35);
100
116
 
101
117
  k4 = mul(k4, C4); k4 = rotl(k4, 18); k4 = mul(k4, C1); h4 ^= k4;
102
- h4 = rotl(h4, 13); h4 = (h4 + h1) | 0; h4 = (mul(h4, 5) + 0x32ac3b17) | 0;
118
+ h4 = rotl(h4, 13); h4 = int32(h4 + h1); h4 = int32(mul(h4, 5) + 0x32ac3b17);
103
119
  }
104
120
 
105
121
  // The trailing bytes that did not fill a 16-byte block, folded in a word at a time.
@@ -115,11 +131,11 @@ export function digestBytes(bytes) {
115
131
  if (rem >= 13) { let x = mul(k[3], C4); x = rotl(x, 18); h4 ^= mul(x, C1); }
116
132
 
117
133
  h1 ^= len; h2 ^= len; h3 ^= len; h4 ^= len;
118
- h1 = (h1 + h2) | 0; h1 = (h1 + h3) | 0; h1 = (h1 + h4) | 0;
119
- h2 = (h2 + h1) | 0; h3 = (h3 + h1) | 0; h4 = (h4 + h1) | 0;
134
+ h1 = int32(h1 + h2); h1 = int32(h1 + h3); h1 = int32(h1 + h4);
135
+ h2 = int32(h2 + h1); h3 = int32(h3 + h1); h4 = int32(h4 + h1);
120
136
  h1 = fmix(h1); h2 = fmix(h2); h3 = fmix(h3); h4 = fmix(h4);
121
- h1 = (h1 + h2) | 0; h1 = (h1 + h3) | 0; h1 = (h1 + h4) | 0;
122
- h2 = (h2 + h1) | 0; h3 = (h3 + h1) | 0; h4 = (h4 + h1) | 0;
137
+ h1 = int32(h1 + h2); h1 = int32(h1 + h3); h1 = int32(h1 + h4);
138
+ h2 = int32(h2 + h1); h3 = int32(h3 + h1); h4 = int32(h4 + h1);
123
139
 
124
140
  return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]
125
141
  .map((w) => w.toString(16).padStart(8, '0'))
@@ -170,7 +186,7 @@ export class DigestWriter {
170
186
  */
171
187
  int(value) {
172
188
  const bytes = new Uint8Array(4);
173
- new DataView(bytes.buffer).setInt32(0, value | 0, true);
189
+ new DataView(bytes.buffer).setInt32(0, int32(value), true);
174
190
  this.#push(bytes);
175
191
  return this;
176
192
  }
package/src/core/node.js CHANGED
@@ -3,6 +3,23 @@
3
3
  import { DigestWriter } from './digest.js';
4
4
  import { Transform } from '../values/transform.js';
5
5
 
6
+ /**
7
+ * Orders strings by UTF-16 code unit, which is what a bare `sort()` does.
8
+ *
9
+ * Written out rather than left implicit — and deliberately *not* `localeCompare`, which is
10
+ * what a linter will suggest. Collation is locale-dependent, and this ordering feeds the
11
+ * digest: two machines with different locales would have to agree, or the cache would miss
12
+ * across them and a model would re-solve from scratch on someone else's laptop.
13
+ *
14
+ * @param {string} a The left string.
15
+ * @param {string} b The right string.
16
+ * @returns {number} Negative, zero or positive, as `sort` wants.
17
+ */
18
+ function byCodeUnit(a, b) {
19
+ if (a < b) return -1;
20
+ return a > b ? 1 : 0;
21
+ }
22
+
6
23
  /**
7
24
  * Canonically encodes an attribute value into the digest.
8
25
  *
@@ -29,7 +46,7 @@ function writeValue(writer, value) {
29
46
  } else if (value instanceof Transform) {
30
47
  writer.string('t').numbers(value.toArray());
31
48
  } else {
32
- const keys = Object.keys(value).sort();
49
+ const keys = Object.keys(value).sort(byCodeUnit);
33
50
  writer.string('o').int(keys.length);
34
51
  for (const key of keys) {
35
52
  writer.string(key);
@@ -113,7 +130,7 @@ export class GeometryNode {
113
130
  throw new TypeError(`cannot combine 2D and 3D geometry in a ${op}`);
114
131
  }
115
132
  const ordered = op === 'union'
116
- ? [...live].sort((a, b) => a.subtreeSize - b.subtreeSize || a.digest.localeCompare(b.digest))
133
+ ? [...live].sort((a, b) => a.subtreeSize - b.subtreeSize || byCodeUnit(a.digest, b.digest))
117
134
  : live;
118
135
  return new GeometryNode(dim, 'boolean', { op }, ordered);
119
136
  }
package/src/lang/ast.js CHANGED
@@ -100,13 +100,13 @@ export {};
100
100
  */
101
101
 
102
102
  /**
103
- * A `condition ? then : otherwise`.
103
+ * A `condition ? consequent : alternate`.
104
104
  *
105
105
  * @typedef {object} ConditionalExpression
106
106
  * @property {'conditional'} kind Discriminant.
107
107
  * @property {Expression} condition What decides.
108
- * @property {Expression} then The value when truthy.
109
- * @property {Expression} otherwise The value when not.
108
+ * @property {Expression} consequent The value when truthy.
109
+ * @property {Expression} alternate The value when not.
110
110
  * @property {SourceLocation} [loc] Where it was written.
111
111
  */
112
112
 
@@ -176,8 +176,8 @@ export {};
176
176
  * @typedef {object} IfBlock
177
177
  * @property {'if'} kind Discriminant.
178
178
  * @property {Expression} condition What decides.
179
- * @property {Body} then The branch taken when truthy.
180
- * @property {Body | null} otherwise The branch taken otherwise, if there is one.
179
+ * @property {Body} consequent The branch taken when truthy.
180
+ * @property {Body | null} alternate The branch taken otherwise, if there is one.
181
181
  * @property {SourceLocation} loc Where the `if` was.
182
182
  */
183
183
 
@@ -18,6 +18,9 @@ import { Transform } from '../values/transform.js';
18
18
  * @property {ScenePart[]} parts The scene parts declared below here.
19
19
  */
20
20
 
21
+ /** Which index `.x`, `.y` and `.z` stand for when read off a list. */
22
+ const AXES = { x: 0, y: 1, z: 2 };
23
+
21
24
  /** A lexical scope, chained to its parent. */
22
25
  class Scope {
23
26
  /**
@@ -36,13 +39,8 @@ class Scope {
36
39
  * @returns {unknown} Its value, or undefined if nothing binds it.
37
40
  */
38
41
  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;
42
+ if (this.values.has(name)) return this.values.get(name);
43
+ return this.parent ? this.parent.lookup(name) : undefined;
46
44
  }
47
45
 
48
46
  /**
@@ -52,13 +50,8 @@ class Scope {
52
50
  * @returns {boolean} Whether anything binds it.
53
51
  */
54
52
  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;
53
+ if (this.values.has(name)) return true;
54
+ return this.parent ? this.parent.has(name) : false;
62
55
  }
63
56
 
64
57
  /**
@@ -303,8 +296,8 @@ export class Evaluator {
303
296
 
304
297
  case 'conditional':
305
298
  return truthy(this.expression(node.condition, scope))
306
- ? this.expression(node.then, scope)
307
- : this.expression(node.otherwise, scope);
299
+ ? this.expression(node.consequent, scope)
300
+ : this.expression(node.alternate, scope);
308
301
 
309
302
  case 'call': return this.call(node, scope);
310
303
 
@@ -329,8 +322,7 @@ export class Evaluator {
329
322
  if (object === null || object === undefined) {
330
323
  throw new FormaError(`cannot read "${node.property}" of nothing`, node.loc);
331
324
  }
332
- const axis = { x: 0, y: 1, z: 2 }[node.property];
333
- if (Array.isArray(object) && axis !== undefined) return object[axis];
325
+ if (Array.isArray(object) && node.property in AXES) return object[AXES[node.property]];
334
326
  if (!(node.property in object)) {
335
327
  throw new FormaError(`no attribute "${node.property}"`, node.loc);
336
328
  }
@@ -442,7 +434,7 @@ export class Evaluator {
442
434
 
443
435
  if (block.kind === 'for') return this.forBlock(block, scope);
444
436
  if (block.kind === 'if') {
445
- const taken = truthy(this.expression(block.condition, scope)) ? block.then : block.otherwise;
437
+ const taken = truthy(this.expression(block.condition, scope)) ? block.consequent : block.alternate;
446
438
  return taken ? this.body(taken, scope) : { nodes: [], parts: [] };
447
439
  }
448
440
 
@@ -581,7 +573,7 @@ export class Evaluator {
581
573
  // An unlabelled part still needs a name to show in the viewer; its line is the one
582
574
  // thing guaranteed to differ between two of them.
583
575
  name: block.labels.length
584
- ? String(this.expression(block.labels[0], scope))
576
+ ? formatValue(this.expression(block.labels[0], scope))
585
577
  : `part_${block.loc.line}`,
586
578
  color: args.string('color', '#b8c4d0'),
587
579
  opacity: args.number('opacity', 1),
@@ -760,7 +752,27 @@ function truthy(value) {
760
752
  function formatValue(value) {
761
753
  if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`;
762
754
  if (value === null || value === undefined) return '';
763
- return String(value);
755
+ // An object reaching here is almost always a mistake in the model, so it is shown the way
756
+ // it was written rather than as JavaScript's `[object Object]`, which says nothing about
757
+ // which object went wrong.
758
+ if (typeof value === 'object') return `{${Object.entries(value).map(formatEntry).join(', ')}}`;
759
+ if (typeof value === 'string') return value;
760
+ // A forma expression yields a number, a string, a bool, null, a list or an object, so by
761
+ // here it is a number or a bool. Spelling that out keeps the coercion off `unknown`, where
762
+ // it could silently produce "[object Object]" for something this does not handle.
763
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
764
+ return '';
765
+ }
766
+
767
+ /**
768
+ * Renders one object entry. Separate from {@link formatValue} only so the template literals
769
+ * do not nest.
770
+ *
771
+ * @param {[string, unknown]} entry The key and its value.
772
+ * @returns {string} `key: value`.
773
+ */
774
+ function formatEntry([key, value]) {
775
+ return `${key}: ${formatValue(value)}`;
764
776
  }
765
777
 
766
778
  /**
@@ -75,7 +75,8 @@ class Parser {
75
75
 
76
76
  /**
77
77
  * @param {TokenType} type The kind required.
78
- * @param {unknown} [value] The exact value required, when it matters.
78
+ * @param {string} [value] The exact value required, when it matters. Always a keyword or a
79
+ * piece of punctuation, which is why it can go straight into the message.
79
80
  * @returns {Token} The consumed token.
80
81
  * @throws {FormaError} If the current token is not what was required.
81
82
  */
@@ -271,17 +272,17 @@ class Parser {
271
272
  parseIf() {
272
273
  const loc = this.expect('ident', 'if').loc;
273
274
  const condition = this.parseExpression();
274
- const then = this.parseBracedBody();
275
- let otherwise = null;
275
+ const consequent = this.parseBracedBody();
276
+ let alternate = null;
276
277
  if (this.isIdent('else')) {
277
278
  this.next();
278
279
  if (this.isIdent('if')) {
279
- otherwise = { attributes: [], params: [], locals: [], blocks: [this.parseIf()] };
280
+ alternate = { attributes: [], params: [], locals: [], blocks: [this.parseIf()] };
280
281
  } else {
281
- otherwise = this.parseBracedBody();
282
+ alternate = this.parseBracedBody();
282
283
  }
283
284
  }
284
- return { kind: 'if', condition, then, otherwise, loc };
285
+ return { kind: 'if', condition, consequent, alternate, loc };
285
286
  }
286
287
 
287
288
  // --- expressions ----------------------------------------------------------------
@@ -298,10 +299,10 @@ class Parser {
298
299
  const condition = this.parseBinary(0);
299
300
  if (this.isPunct('?') && !this.breaksLine()) {
300
301
  this.next();
301
- const then = this.parseExpression();
302
+ const consequent = this.parseExpression();
302
303
  this.expect('punct', ':');
303
- const otherwise = this.parseExpression();
304
- return { kind: 'conditional', condition, then, otherwise };
304
+ const alternate = this.parseExpression();
305
+ return { kind: 'conditional', condition, consequent, alternate };
305
306
  }
306
307
  return condition;
307
308
  }