forma-dsl 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/core/context.js +5 -5
- package/src/core/digest.js +3 -3
- package/src/core/kernel.js +2 -2
- package/src/core/node.js +2 -2
- package/src/export/stl.js +2 -2
- package/src/index.d.ts +20 -3
- package/src/index.js +5 -4
- package/src/lang/ast.d.ts +1 -1
- package/src/lang/builtins.js +5 -5
- package/src/lang/evaluate.js +25 -16
- package/src/lang/excerpt.js +41 -0
- package/src/lang/lexer.js +4 -4
- package/src/lang/parser.js +3 -3
- package/src/values/angle.js +1 -1
- package/src/values/transform.js +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
A declarative DSL for 3D modeling and scene composition, with reusable components and live
|
|
7
7
|
previews.
|
|
8
8
|
|
|
9
|
-
Models are written as text
|
|
9
|
+
Models are written as text - blocks, attributes and expressions in an HCL-like syntax -
|
|
10
10
|
parsed and solved by the [Manifold](https://github.com/elalish/manifold) CSG kernel compiled
|
|
11
11
|
to WebAssembly. It runs unchanged in Node and in the browser.
|
|
12
12
|
|
|
@@ -97,10 +97,11 @@ Hand-written TypeScript declarations ship with it, so the API is typed from eith
|
|
|
97
97
|
|
|
98
98
|
## Documentation
|
|
99
99
|
|
|
100
|
-
**[The full reference](https://kalpak44.github.io/forma-dsl/docs/)**
|
|
100
|
+
**[The full reference](https://kalpak44.github.io/forma-dsl/docs/)** - every declaration,
|
|
101
101
|
block, function and error, each with usage and troubleshooting. The
|
|
102
102
|
**[editor](https://kalpak44.github.io/forma-dsl/editor/)** runs the whole thing in the browser:
|
|
103
|
-
type, and the solid updates.
|
|
103
|
+
type, and the solid updates. **[The site](https://kalpak44.github.io/forma-dsl/)** has a demo
|
|
104
|
+
that types a document out and solves it as it goes.
|
|
104
105
|
|
|
105
106
|
## How it works
|
|
106
107
|
|
|
@@ -113,7 +114,7 @@ source ──▶ lexer ──▶ parser ──▶ evaluator ──▶ geometry n
|
|
|
113
114
|
The evaluator does not call the kernel. It produces a tree of immutable geometry nodes, each
|
|
114
115
|
carrying a 128-bit digest of its own content and its children's digests. Identity is digest
|
|
115
116
|
identity, so a component used twenty times is one node, evaluated once. Nodes are the only
|
|
116
|
-
thing that reaches the kernel, and every result is memoized by digest
|
|
117
|
+
thing that reaches the kernel, and every result is memoized by digest - which is what makes
|
|
117
118
|
an edit cheap, since only the subtrees whose content actually changed are re-evaluated.
|
|
118
119
|
|
|
119
120
|
WebAssembly objects are not garbage-collected, so an `EvaluationContext` owns everything it
|
|
@@ -124,4 +125,4 @@ One runtime dependency, `manifold-3d`.
|
|
|
124
125
|
|
|
125
126
|
## License
|
|
126
127
|
|
|
127
|
-
MIT
|
|
128
|
+
MIT - see [LICENSE](LICENSE).
|
package/package.json
CHANGED
package/src/core/context.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Two things earn this its own module. The cache is keyed on content rather than on object
|
|
6
6
|
* identity, so an edit that rebuilds the tree from scratch still hits every subtree it did
|
|
7
|
-
* not change
|
|
7
|
+
* not change - which is what makes re-solving on a keystroke affordable. And the kernel's
|
|
8
8
|
* objects are WASM allocations that no garbage collector will free, so something has to own
|
|
9
9
|
* them and let them go; that is what `dispose` and `collect` are for, and why the caller is
|
|
10
10
|
* told in `render`'s contract that the context is theirs.
|
|
@@ -18,8 +18,8 @@ import { loadKernel } from './kernel.js';
|
|
|
18
18
|
/**
|
|
19
19
|
* How many unreachable entries `collect` keeps.
|
|
20
20
|
*
|
|
21
|
-
* Editing a model churns the tree
|
|
22
|
-
* untouched
|
|
21
|
+
* Editing a model churns the tree - a keystroke changes one subtree and leaves its siblings
|
|
22
|
+
* untouched - so the entries that just fell out of the tree are the ones most likely to come
|
|
23
23
|
* straight back, on an undo or on the next character of a number being typed.
|
|
24
24
|
*/
|
|
25
25
|
const DEFAULT_RETAINED = 512;
|
|
@@ -113,7 +113,7 @@ export class EvaluationContext {
|
|
|
113
113
|
/**
|
|
114
114
|
* Resolves to the kernel object for a node.
|
|
115
115
|
*
|
|
116
|
-
* Concurrent requests for the same node share one evaluation
|
|
116
|
+
* Concurrent requests for the same node share one evaluation - without the in-flight map,
|
|
117
117
|
* a shape used twice in a tree that is walked concurrently would be built twice and one
|
|
118
118
|
* copy leaked.
|
|
119
119
|
*
|
|
@@ -335,6 +335,6 @@ function free(value) {
|
|
|
335
335
|
try {
|
|
336
336
|
value?.delete();
|
|
337
337
|
} catch {
|
|
338
|
-
// Already freed by the kernel, or never owned one
|
|
338
|
+
// Already freed by the kernel, or never owned one - either way there is nothing to do.
|
|
339
339
|
}
|
|
340
340
|
}
|
package/src/core/digest.js
CHANGED
|
@@ -23,8 +23,8 @@ function rotl(x, r) {
|
|
|
23
23
|
/**
|
|
24
24
|
* Wraps a number to a signed 32-bit integer, the way the algorithm's additions are defined.
|
|
25
25
|
*
|
|
26
|
-
* `Math.imul(x, 1)` rather than `x | 0`: the two are identical
|
|
27
|
-
* ToInt32
|
|
26
|
+
* `Math.imul(x, 1)` rather than `x | 0`: the two are identical - both coerce through
|
|
27
|
+
* ToInt32 - but this one says *wrap*, which is what MurmurHash means here. `Math.trunc` is
|
|
28
28
|
* emphatically not a substitute: it drops the fraction without wrapping, so `2**31 + 5`
|
|
29
29
|
* stays positive instead of becoming negative, and every digest downstream changes.
|
|
30
30
|
*
|
|
@@ -68,7 +68,7 @@ function fmix(h) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Packs the bytes after the last whole block into four words, little-endian.
|
|
70
70
|
*
|
|
71
|
-
* Byte `i` of the tail belongs to word `i >> 2` at shift `(i & 3) * 8`
|
|
71
|
+
* Byte `i` of the tail belongs to word `i >> 2` at shift `(i & 3) * 8` - which is what
|
|
72
72
|
* MurmurHash3's fallthrough spells out one case at a time. The arithmetic is identical;
|
|
73
73
|
* only the shape differs, and the digests it produces are asserted to be unchanged.
|
|
74
74
|
*
|
package/src/core/kernel.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Loading the Manifold WASM module, and the global knobs for curve resolution.
|
|
3
3
|
*
|
|
4
4
|
* One instance per page or process. Memoized here rather than left to each caller, because
|
|
5
|
-
* two models evaluating concurrently at startup must not each start an instantiation
|
|
5
|
+
* two models evaluating concurrently at startup must not each start an instantiation - and
|
|
6
6
|
* because the resolution controls below are global to the module, so there is nowhere else
|
|
7
7
|
* they could honestly live.
|
|
8
8
|
*/
|
|
@@ -38,7 +38,7 @@ export function loadKernel(options) {
|
|
|
38
38
|
return wasm;
|
|
39
39
|
}, (error) => {
|
|
40
40
|
// A rejected promise would be memoized along with everything else, so a transient
|
|
41
|
-
// failure
|
|
41
|
+
// failure - a WASM fetch that lost the network - would be permanent for the page.
|
|
42
42
|
modulePromise = null;
|
|
43
43
|
throw error;
|
|
44
44
|
});
|
package/src/core/node.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The geometry tree: a description of geometry that has not been built yet.
|
|
3
3
|
*
|
|
4
4
|
* A node names an operation and its children, and carries a digest of the whole subtree
|
|
5
|
-
* beneath it. Nothing here touches the kernel
|
|
5
|
+
* beneath it. Nothing here touches the kernel - that separation is the point, and the
|
|
6
6
|
* evaluation context is the other half of it.
|
|
7
7
|
*
|
|
8
8
|
* The digest is what the evaluation cache is keyed on, so everything feeding it has to be
|
|
@@ -19,7 +19,7 @@ import { Transform } from '../values/transform.js';
|
|
|
19
19
|
/**
|
|
20
20
|
* Orders strings by UTF-16 code unit, which is what a bare `sort()` does.
|
|
21
21
|
*
|
|
22
|
-
* Written out rather than left implicit
|
|
22
|
+
* Written out rather than left implicit - and deliberately *not* `localeCompare`, which is
|
|
23
23
|
* what a linter will suggest. Collation is locale-dependent, and this ordering feeds the
|
|
24
24
|
* digest: two machines with different locales would have to agree, or the cache would miss
|
|
25
25
|
* across them and a model would re-solve from scratch on someone else's laptop.
|
package/src/export/stl.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Binary STL, for handing a solved part to a slicer.
|
|
3
3
|
*
|
|
4
|
-
* STL is the least a mesh format can carry
|
|
5
|
-
* no notion of a part
|
|
4
|
+
* STL is the least a mesh format can carry - triangles and normals, no units, no colour and
|
|
5
|
+
* no notion of a part - and it is what every slicer reads. Anything richer belongs in its
|
|
6
6
|
* own exporter rather than in a dialect of this one.
|
|
7
7
|
*/
|
|
8
8
|
|
package/src/index.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ export interface FormaDocument {
|
|
|
47
47
|
|
|
48
48
|
export declare function parse(source: string): FormaDocument;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Renders the lines around a position with a caret under its column.
|
|
52
|
+
*
|
|
53
|
+
* Both the CLI and the MCP server print this beside an error message; a position on its own
|
|
54
|
+
* still makes the reader count lines.
|
|
55
|
+
*/
|
|
56
|
+
export declare function excerpt(
|
|
57
|
+
source: string,
|
|
58
|
+
loc?: Pick<SourceLocation, 'line' | 'column'> | null,
|
|
59
|
+
): string | undefined;
|
|
60
|
+
|
|
50
61
|
// --- the kernel -------------------------------------------------------------------------
|
|
51
62
|
|
|
52
63
|
// Re-exported from the dependency rather than restated here. A hand-written approximation
|
|
@@ -66,7 +77,7 @@ export type Kernel = ManifoldToplevel;
|
|
|
66
77
|
* A live 3D solid owned by an EvaluationContext.
|
|
67
78
|
*
|
|
68
79
|
* WebAssembly objects are not garbage-collected. Anything obtained from a context belongs
|
|
69
|
-
* to that context
|
|
80
|
+
* to that context - do not call `delete()` on it yourself.
|
|
70
81
|
*/
|
|
71
82
|
export type Solid = Manifold;
|
|
72
83
|
|
|
@@ -146,7 +157,7 @@ export declare class Transform {
|
|
|
146
157
|
readonly m: readonly number[];
|
|
147
158
|
readonly order: number;
|
|
148
159
|
at(row: number, col: number): number;
|
|
149
|
-
/** `this ∘ other`
|
|
160
|
+
/** `this ∘ other` - `other` runs first. */
|
|
150
161
|
concat(other: Transform): Transform;
|
|
151
162
|
toArray(): number[];
|
|
152
163
|
}
|
|
@@ -204,7 +215,7 @@ export interface ContextStats {
|
|
|
204
215
|
/**
|
|
205
216
|
* Evaluates a geometry tree against the Manifold kernel, memoized by node digest.
|
|
206
217
|
*
|
|
207
|
-
* A context is meant to outlive a single render
|
|
218
|
+
* A context is meant to outlive a single render - reusing one is what makes a re-render
|
|
208
219
|
* after an edit skip the subtrees that did not change. It owns every WASM object it holds,
|
|
209
220
|
* so it must eventually be `dispose()`d, and `collect()` bounds what it holds meanwhile.
|
|
210
221
|
*/
|
|
@@ -357,6 +368,12 @@ export interface FunctionDefinition {
|
|
|
357
368
|
export declare const FUNCTIONS: Record<string, FunctionDefinition>;
|
|
358
369
|
export declare const CONSTANTS: Record<string, number>;
|
|
359
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The type names a `param` block may declare, each of which is also an ordinary identifier
|
|
373
|
+
* bound to itself in the root scope - which is why `type = number` needs no quoting.
|
|
374
|
+
*/
|
|
375
|
+
export declare const TYPE_NAMES: readonly ParameterType[];
|
|
376
|
+
|
|
360
377
|
export interface RenderMesh {
|
|
361
378
|
/** Three vertices per triangle, expanded so hard edges stay hard. */
|
|
362
379
|
positions: Float32Array;
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
export { Program, Evaluator } from './lang/evaluate.js';
|
|
14
14
|
export { parse } from './lang/parser.js';
|
|
15
15
|
export { tokenize, FormaError } from './lang/lexer.js';
|
|
16
|
-
export {
|
|
16
|
+
export { excerpt } from './lang/excerpt.js';
|
|
17
|
+
export { BLOCKS, FUNCTIONS, CONSTANTS, TYPE_NAMES } from './lang/builtins.js';
|
|
17
18
|
export { GeometryNode } from './core/node.js';
|
|
18
19
|
export { EvaluationContext } from './core/context.js';
|
|
19
20
|
export { loadKernel, setQuality, resetQuality } from './core/kernel.js';
|
|
@@ -32,7 +33,7 @@ import { toRenderMesh } from './export/mesh.js';
|
|
|
32
33
|
*
|
|
33
34
|
* The caller owns the returned `context` and must dispose it: it holds the WASM objects for
|
|
34
35
|
* every node in the tree, and those are not garbage-collected. Passing the same context back
|
|
35
|
-
* on the next call is the intended use
|
|
36
|
+
* on the next call is the intended use - that is what lets the digest cache skip the subtrees
|
|
36
37
|
* an edit did not change. Call `context.collect(parts.map(p => p.node))` afterwards to bound
|
|
37
38
|
* how much a reused context holds.
|
|
38
39
|
*
|
|
@@ -66,7 +67,7 @@ export async function render(source, options = {}) {
|
|
|
66
67
|
// Checked before solving rather than after: a 2D part is going to be refused either
|
|
67
68
|
// way, and there is no reason to spend the kernel call first.
|
|
68
69
|
if (part.node.dim !== 3) {
|
|
69
|
-
throw new Error(`part "${part.name}" is 2D
|
|
70
|
+
throw new Error(`part "${part.name}" is 2D - extrude or revolve it before rendering`);
|
|
70
71
|
}
|
|
71
72
|
// The check above is what makes this a solid rather than a cross-section.
|
|
72
73
|
const concrete = /** @type {Solid} */ (await owned.evaluate(part.node));
|
|
@@ -110,7 +111,7 @@ export function describeParameters(source) {
|
|
|
110
111
|
try {
|
|
111
112
|
return evaluator.expression(expression, evaluator.root);
|
|
112
113
|
} catch {
|
|
113
|
-
// One unreadable attribute
|
|
114
|
+
// One unreadable attribute - a min that refers to a param with no value yet - must
|
|
114
115
|
// not cost the caller every other descriptor in the document.
|
|
115
116
|
return undefined;
|
|
116
117
|
}
|
package/src/lang/ast.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* one carries, could only be recovered by reading the parser and the evaluator side by side
|
|
6
6
|
* and hoping they agreed.
|
|
7
7
|
*
|
|
8
|
-
* Declarations rather than JSDoc typedefs, which is what this always was
|
|
8
|
+
* Declarations rather than JSDoc typedefs, which is what this always was - there is no
|
|
9
9
|
* runtime content here, so there is nothing for a `.js` file to carry and nothing to build.
|
|
10
10
|
* Written as types, a discriminant is `kind: 'unary'` rather than a `@property` whose
|
|
11
11
|
* description the lint obliged someone to invent.
|
package/src/lang/builtins.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* This is the one description of what the language contains. The editor's highlighting, the
|
|
6
6
|
* landing page's and the docs' code samples, and the MCP server's catalogue all read
|
|
7
|
-
* `BLOCKS` and `FUNCTIONS` from here rather than restating them
|
|
7
|
+
* `BLOCKS` and `FUNCTIONS` from here rather than restating them - so a block added here is
|
|
8
8
|
* one they all learn about, and a block they claim without it existing fails their tests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
@@ -195,8 +195,8 @@ export class Args {
|
|
|
195
195
|
* leaves it guessing at spellings. What it read is tracked already, so the list costs
|
|
196
196
|
* nothing to produce.
|
|
197
197
|
*
|
|
198
|
-
* A block that branches on which of two spellings it was given
|
|
199
|
-
* `diameter`
|
|
198
|
+
* A block that branches on which of two spellings it was given - `radius` against
|
|
199
|
+
* `diameter` - only reads one of them, so the list is what this call would accept and not
|
|
200
200
|
* always the whole set.
|
|
201
201
|
*
|
|
202
202
|
* @returns {void}
|
|
@@ -207,7 +207,7 @@ export class Args {
|
|
|
207
207
|
if (extra.length) {
|
|
208
208
|
const plural = extra.length > 1 ? 's' : '';
|
|
209
209
|
const reads = [...this.used].sort((a, z) => a.localeCompare(z));
|
|
210
|
-
const instead = reads.length ? `
|
|
210
|
+
const instead = reads.length ? ` - ${this.type} reads ${quoteAll(reads)}` : '';
|
|
211
211
|
throw new FormaError(
|
|
212
212
|
`${this.type}: unknown attribute${plural} ${quoteAll(extra)}${instead}`,
|
|
213
213
|
this.loc,
|
|
@@ -579,6 +579,6 @@ export const CONSTANTS = { pi: Math.PI, e: Math.E };
|
|
|
579
579
|
|
|
580
580
|
/**
|
|
581
581
|
* Type names are ordinary identifiers that stand for themselves, so `type = number` in a
|
|
582
|
-
* param block needs no keyword and no quoting
|
|
582
|
+
* param block needs no keyword and no quoting - the same trade HCL makes.
|
|
583
583
|
*/
|
|
584
584
|
export const TYPE_NAMES = ['number', 'string', 'bool', 'vector', 'list', 'angle'];
|
package/src/lang/evaluate.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Nothing here calls the kernel. Evaluating a model produces {@link GeometryNode}s, which
|
|
5
5
|
* describe geometry without building any, and solving those is {@link EvaluationContext}'s
|
|
6
|
-
* job. Keeping the two apart is what makes a live preview affordable
|
|
6
|
+
* job. Keeping the two apart is what makes a live preview affordable - a description is
|
|
7
7
|
* cheap to rebuild on every keystroke, and a solid is not.
|
|
8
8
|
*
|
|
9
9
|
* The three things in this file fail in three different ways, which is why they are three
|
|
@@ -30,7 +30,7 @@ const SPECIAL_FORMS = ['align', 'for', 'if', 'part'];
|
|
|
30
30
|
/**
|
|
31
31
|
* What a body produces: geometry to hand upwards, and any scene parts declared inside it.
|
|
32
32
|
*
|
|
33
|
-
* Parts travel separately from nodes because a part is not an operand
|
|
33
|
+
* Parts travel separately from nodes because a part is not an operand - it is a colored
|
|
34
34
|
* piece of the finished scene, and an enclosing `translate` must not absorb it.
|
|
35
35
|
*
|
|
36
36
|
* @typedef {object} Produced
|
|
@@ -150,8 +150,8 @@ export class Program {
|
|
|
150
150
|
/**
|
|
151
151
|
* The declared parameters, in source order, as the editor needs them to build controls.
|
|
152
152
|
*
|
|
153
|
-
* A param may legitimately have no default
|
|
154
|
-
* caller
|
|
153
|
+
* A param may legitimately have no default - the document then requires a value from the
|
|
154
|
+
* caller - so the descriptor has to stand on its own. `type` is taken from the declaration
|
|
155
155
|
* when it is given and inferred from the default otherwise, and falls back to `number`,
|
|
156
156
|
* which is what a param without either almost always is.
|
|
157
157
|
*
|
|
@@ -205,8 +205,8 @@ export class Evaluator {
|
|
|
205
205
|
/** @type {Program} The program being evaluated. */
|
|
206
206
|
this.program = program;
|
|
207
207
|
/**
|
|
208
|
-
* @type {EvaluationContext | null} Needed only by blocks that measure their child
|
|
209
|
-
* `align` has to know where the geometry actually is
|
|
208
|
+
* @type {EvaluationContext | null} Needed only by blocks that measure their child -
|
|
209
|
+
* `align` has to know where the geometry actually is - so a program without one can
|
|
210
210
|
* still be built into a node tree without loading the kernel.
|
|
211
211
|
*/
|
|
212
212
|
this.context = context;
|
|
@@ -217,13 +217,13 @@ export class Evaluator {
|
|
|
217
217
|
* How many blocks may be built before evaluation gives up, or null for no limit.
|
|
218
218
|
*
|
|
219
219
|
* {@link MAX_DEPTH} stops a component that calls itself, and `range` caps how many values
|
|
220
|
-
* a single loop produces
|
|
220
|
+
* a single loop produces - but neither bounds their product. Two loops of ten thousand
|
|
221
221
|
* nested in each other is a hundred million blocks, each one a kernel allocation, and no
|
|
222
222
|
* amount of waiting finishes it.
|
|
223
223
|
*
|
|
224
224
|
* Off by default, because the embedder that controls its own input does not need it and
|
|
225
|
-
* should not have a ceiling imposed. A host that builds documents it did not write
|
|
226
|
-
* MCP server, a shared editor
|
|
225
|
+
* should not have a ceiling imposed. A host that builds documents it did not write - an
|
|
226
|
+
* MCP server, a shared editor - should pass one.
|
|
227
227
|
*
|
|
228
228
|
* @type {number | null}
|
|
229
229
|
*/
|
|
@@ -265,7 +265,7 @@ export class Evaluator {
|
|
|
265
265
|
|
|
266
266
|
/**
|
|
267
267
|
* Settles one declared param: what the caller gave, or what the declaration defaults to,
|
|
268
|
-
* or nothing at all
|
|
268
|
+
* or nothing at all - which is the caller's problem to report, since a document param and
|
|
269
269
|
* a component param say different things about it.
|
|
270
270
|
*
|
|
271
271
|
* @param {unknown} supplied What was passed in, or undefined.
|
|
@@ -452,6 +452,12 @@ export class Evaluator {
|
|
|
452
452
|
/** @type {Produced} */
|
|
453
453
|
const produced = { nodes: [], parts: [] };
|
|
454
454
|
|
|
455
|
+
// One block at a time, deliberately. `block` checks the signal and the node budget on
|
|
456
|
+
// the way in, so building these concurrently would enter every one of them before any
|
|
457
|
+
// could trip either - which turns `maxNodes` from a bound on how much gets built into a
|
|
458
|
+
// bound on how much gets built after everything has already started. There is also
|
|
459
|
+
// nothing to overlap: the evaluator is pure, and the one block that reaches the kernel
|
|
460
|
+
// is memoized by the context anyway.
|
|
455
461
|
for (const block of body.blocks) {
|
|
456
462
|
const result = await this.block(block, inner);
|
|
457
463
|
produced.nodes.push(...result.nodes);
|
|
@@ -478,14 +484,14 @@ export class Evaluator {
|
|
|
478
484
|
|
|
479
485
|
if (this.maxNodes !== null && ++this.built > this.maxNodes) {
|
|
480
486
|
throw new FormaError(
|
|
481
|
-
`more than ${this.maxNodes} blocks built
|
|
487
|
+
`more than ${this.maxNodes} blocks built - the document asks for more geometry than `
|
|
482
488
|
+ 'this budget allows; narrow a loop, or raise maxNodes if it is genuinely this large',
|
|
483
489
|
block.loc,
|
|
484
490
|
);
|
|
485
491
|
}
|
|
486
492
|
|
|
487
493
|
if (this.depth > MAX_DEPTH) {
|
|
488
|
-
throw new FormaError(`nesting deeper than ${MAX_DEPTH} blocks
|
|
494
|
+
throw new FormaError(`nesting deeper than ${MAX_DEPTH} blocks - is a component using itself?`, block.loc);
|
|
489
495
|
}
|
|
490
496
|
|
|
491
497
|
if (block.kind === 'for') return this.forBlock(block, scope);
|
|
@@ -505,7 +511,7 @@ export class Evaluator {
|
|
|
505
511
|
if (!definition) {
|
|
506
512
|
// The whole vocabulary, because the reader is often a program with no copy of the
|
|
507
513
|
// manual: a misspelling it cannot correct from the error costs a round trip it has no
|
|
508
|
-
// way to end. Declared components come first
|
|
514
|
+
// way to end. Declared components come first - they are the names this document itself
|
|
509
515
|
// introduced, and the likeliest thing a near miss was reaching for.
|
|
510
516
|
const known = [
|
|
511
517
|
...[...this.program.components.keys()].sort((a, z) => a.localeCompare(z)),
|
|
@@ -513,7 +519,7 @@ export class Evaluator {
|
|
|
513
519
|
...Object.keys(BLOCKS).sort((a, z) => a.localeCompare(z)),
|
|
514
520
|
];
|
|
515
521
|
throw new FormaError(
|
|
516
|
-
`unknown block "${block.type}"
|
|
522
|
+
`unknown block "${block.type}" - the blocks are ${quoteAll(known)}`,
|
|
517
523
|
block.loc,
|
|
518
524
|
);
|
|
519
525
|
}
|
|
@@ -611,6 +617,9 @@ export class Evaluator {
|
|
|
611
617
|
/** @type {Produced} */
|
|
612
618
|
const produced = { nodes: [], parts: [] };
|
|
613
619
|
|
|
620
|
+
// Sequential for the same reason `body` is, and more so: this is the loop a runaway
|
|
621
|
+
// document runs away in, and the budget can only stop it if the iterations are entered
|
|
622
|
+
// one at a time.
|
|
614
623
|
for (const [index, item] of sequence.entries()) {
|
|
615
624
|
const iteration = scope.child();
|
|
616
625
|
// One name binds the value; two bind the index and then the value.
|
|
@@ -665,7 +674,7 @@ export class Evaluator {
|
|
|
665
674
|
*/
|
|
666
675
|
async alignBlock(block, scope) {
|
|
667
676
|
if (!this.context) {
|
|
668
|
-
throw new FormaError('align needs a kernel context
|
|
677
|
+
throw new FormaError('align needs a kernel context - render the model rather than building it', block.loc);
|
|
669
678
|
}
|
|
670
679
|
const args = new Args('align', this.attributes(block.body, scope), block.loc);
|
|
671
680
|
const { nodes: children } = await this.body(block.body, scope.child());
|
|
@@ -903,7 +912,7 @@ function subtract(left, right, loc) {
|
|
|
903
912
|
}
|
|
904
913
|
|
|
905
914
|
/**
|
|
906
|
-
* `*`: a vector times a scalar in either order
|
|
915
|
+
* `*`: a vector times a scalar in either order - the common case in a model - componentwise
|
|
907
916
|
* on two lists, and ordinary multiplication otherwise.
|
|
908
917
|
*
|
|
909
918
|
* @param {unknown} left The left operand.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering the lines around an error, with a caret under the column it points at.
|
|
3
|
+
*
|
|
4
|
+
* Every error the language raises carries a position, and a position on its own still makes
|
|
5
|
+
* the reader count lines. This turns one into something that can be printed next to the
|
|
6
|
+
* message - which is what the CLI writes to stderr and what the MCP server hands back to a
|
|
7
|
+
* model that has just written a document that does not parse.
|
|
8
|
+
*
|
|
9
|
+
* It lives in the library rather than in either consumer because both want the same answer,
|
|
10
|
+
* and two copies of it would be two things to keep agreeing about what a column means.
|
|
11
|
+
*/
|
|
12
|
+
/** @import { SourceLocation } from '../index.js' */
|
|
13
|
+
|
|
14
|
+
/** How many lines of source to show either side of the one in question. */
|
|
15
|
+
const CONTEXT_LINES = 2;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders a source excerpt with a caret under the column an error points at.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} source The document.
|
|
21
|
+
* @param {Pick<SourceLocation, 'line' | 'column'> | null} [loc] Where the error was raised.
|
|
22
|
+
* @returns {string | undefined} The excerpt, or undefined when there is no position to show.
|
|
23
|
+
*/
|
|
24
|
+
export function excerpt(source, loc = null) {
|
|
25
|
+
if (!loc || !Number.isInteger(loc.line)) return undefined;
|
|
26
|
+
|
|
27
|
+
const lines = source.split('\n');
|
|
28
|
+
const first = Math.max(1, loc.line - CONTEXT_LINES);
|
|
29
|
+
const last = Math.min(lines.length, loc.line + CONTEXT_LINES);
|
|
30
|
+
const width = String(last).length;
|
|
31
|
+
|
|
32
|
+
/** @type {string[]} */
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let n = first; n <= last; n++) {
|
|
35
|
+
out.push(`${String(n).padStart(width)} | ${lines[n - 1]}`);
|
|
36
|
+
if (n === loc.line) {
|
|
37
|
+
out.push(`${' '.repeat(width)} | ${' '.repeat(Math.max(0, loc.column - 1))}^`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out.join('\n');
|
|
41
|
+
}
|
package/src/lang/lexer.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Strings are the one thing it does not hand on whole. A `"..."` carrying `${...}` is split
|
|
5
5
|
* here into literal runs and spliced source, because working out where a splice ends means
|
|
6
|
-
* counting braces
|
|
6
|
+
* counting braces - which is a job for something reading characters, not for a parser reading
|
|
7
7
|
* tokens.
|
|
8
8
|
*
|
|
9
9
|
* {@link FormaError} lives here rather than next to the evaluator because this is the first
|
|
@@ -62,15 +62,15 @@ const isDigit = (c) => c >= '0' && c <= '9';
|
|
|
62
62
|
/**
|
|
63
63
|
* @param {string} c A single character.
|
|
64
64
|
* @returns {boolean} Whether it is whitespace that carries no meaning. A newline does carry
|
|
65
|
-
* meaning
|
|
65
|
+
* meaning - it ends an attribute - so it is deliberately not here.
|
|
66
66
|
*/
|
|
67
67
|
const isSpace = (c) => c === ' ' || c === '\t' || c === '\r';
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Walks the source once, keeping the cursor and the accumulated tokens together.
|
|
71
71
|
*
|
|
72
|
-
* A class rather than one long function because the four scanners below
|
|
73
|
-
* strings, numbers, names
|
|
72
|
+
* A class rather than one long function because the four scanners below - comments,
|
|
73
|
+
* strings, numbers, names - all need the same mutable cursor, and threading it through
|
|
74
74
|
* arguments made each of them harder to read than the grammar it implements.
|
|
75
75
|
*/
|
|
76
76
|
class Lexer {
|
package/src/lang/parser.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Recursive descent with a precedence table for the infix operators. The grammar is small
|
|
5
5
|
* enough that a generator would cost more than it saved, and writing it by hand is what lets
|
|
6
|
-
* every node carry the source position that produced it
|
|
6
|
+
* every node carry the source position that produced it - which is the whole of how errors
|
|
7
7
|
* point at something later on.
|
|
8
8
|
*
|
|
9
9
|
* It checks shape and nothing else. Whether `cylinder` is a real block, and whether its
|
|
@@ -321,7 +321,7 @@ class Parser {
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
/**
|
|
324
|
-
* @returns {boolean} Whether the current token starts a new line outside any bracket
|
|
324
|
+
* @returns {boolean} Whether the current token starts a new line outside any bracket -
|
|
325
325
|
* the signal that the expression is finished and the next attribute has begun.
|
|
326
326
|
*/
|
|
327
327
|
breaksLine() {
|
|
@@ -361,7 +361,7 @@ class Parser {
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
/**
|
|
364
|
-
* Reads the suffixes
|
|
364
|
+
* Reads the suffixes - member access, indexing and calls - that bind tightest.
|
|
365
365
|
*
|
|
366
366
|
* @returns {Expression} The expression.
|
|
367
367
|
*/
|
package/src/values/angle.js
CHANGED
|
@@ -40,7 +40,7 @@ export class Angle {
|
|
|
40
40
|
static turns(value) { return new Angle(Number(value) * 2 * Math.PI); }
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Accepts an Angle or a bare number, which is read as degrees
|
|
43
|
+
* Accepts an Angle or a bare number, which is read as degrees - the unit a person writing
|
|
44
44
|
* a model thinks in.
|
|
45
45
|
*
|
|
46
46
|
* @param {Angle | number} value An angle, or a number of degrees.
|
package/src/values/transform.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A value type: every operation returns a new transform and none mutates the receiver, so a
|
|
5
5
|
* transform captured by a scope cannot be changed underneath it by a sibling block. That
|
|
6
|
-
* matters more than it sounds
|
|
6
|
+
* matters more than it sounds - nesting is how the language composes, and a mutable
|
|
7
7
|
* transform would make a block's meaning depend on what ran before it.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -116,7 +116,7 @@ export class Transform {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* Rotates about x, then y, then z
|
|
119
|
+
* Rotates about x, then y, then z - the order OpenSCAD and Cadova both use, so a model
|
|
120
120
|
* ported from either lands in the same orientation.
|
|
121
121
|
*
|
|
122
122
|
* @param {Vector | ReadonlyArray<number>} angles Degrees about x, y and z.
|