forma-dsl-mcp 0.1.4

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +52 -0
  4. package/src/bin.js +30 -0
  5. package/src/catalogue.js +703 -0
  6. package/src/document.js +285 -0
  7. package/src/examples.js +212 -0
  8. package/src/guide.js +313 -0
  9. package/src/reference/README.md +153 -0
  10. package/src/reference/api/describe-parameters.md +167 -0
  11. package/src/reference/api/evaluation-context.md +221 -0
  12. package/src/reference/api/export.md +185 -0
  13. package/src/reference/api/geometry-node.md +215 -0
  14. package/src/reference/api/kernel.md +182 -0
  15. package/src/reference/api/low-level.md +239 -0
  16. package/src/reference/api/render.md +217 -0
  17. package/src/reference/api/values.md +223 -0
  18. package/src/reference/errors.md +253 -0
  19. package/src/reference/language/control-flow.md +160 -0
  20. package/src/reference/language/expressions.md +206 -0
  21. package/src/reference/language/overview.md +156 -0
  22. package/src/reference/language/scope.md +184 -0
  23. package/src/reference/language/syntax.md +210 -0
  24. package/src/reference/reference/align.md +189 -0
  25. package/src/reference/reference/booleans.md +231 -0
  26. package/src/reference/reference/component.md +248 -0
  27. package/src/reference/reference/conversions.md +269 -0
  28. package/src/reference/reference/functions.md +314 -0
  29. package/src/reference/reference/local.md +197 -0
  30. package/src/reference/reference/model.md +213 -0
  31. package/src/reference/reference/param.md +230 -0
  32. package/src/reference/reference/part.md +229 -0
  33. package/src/reference/reference/refinement.md +284 -0
  34. package/src/reference/reference/shapes-2d.md +309 -0
  35. package/src/reference/reference/shapes-3d.md +279 -0
  36. package/src/reference/reference/transforms.md +246 -0
  37. package/src/reference.js +160 -0
  38. package/src/server.js +219 -0
  39. package/src/tools.js +523 -0
  40. package/src/workspace.js +204 -0
@@ -0,0 +1,223 @@
1
+ # Values
2
+
3
+ Three small immutable classes the evaluator works in: [`Vector`](#vector),
4
+ [`Angle`](#angle) and [`Transform`](#transform). Documents never see them directly — a
5
+ document writes lists and bare numbers — but they are exported for anyone building geometry
6
+ through the [`GeometryNode`](geometry-node.md) API.
7
+
8
+ ```js
9
+ import { Vector, Angle, Transform } from 'forma-dsl';
10
+ ```
11
+
12
+ All three are frozen on construction. Every method returns a new value.
13
+
14
+ ---
15
+
16
+ ## `Vector`
17
+
18
+ A 2D or 3D vector. One class covers both, because every operation is componentwise and a
19
+ separate 3D class would be the same code with one more letter.
20
+
21
+ ### Construction
22
+
23
+ | | |
24
+ | --- | --- |
25
+ | `new Vector([x, y])` | From components |
26
+ | `Vector.of(value, dimensions)` | From anything vector-like |
27
+ | `Vector.zero(dimensions)` | The origin |
28
+
29
+ `Vector.of` accepts a `Vector`, a **number** — broadcast to every component — an array, or an
30
+ `{x, y, z}` object whose missing keys read as zero. This is what makes
31
+ `scale { factor = 2 }` work the same as `factor = [2, 2, 2]`.
32
+
33
+ ### Properties
34
+
35
+ | Property | Description |
36
+ | --- | --- |
37
+ | `components` | The components, frozen |
38
+ | `size` | How many there are |
39
+ | `x`, `y`, `z` | The first, second and third. `z` is `undefined` in 2D |
40
+ | `magnitude` | Length |
41
+
42
+ ### Methods
43
+
44
+ | Method | Description |
45
+ | --- | --- |
46
+ | `plus(other)` `minus(other)` | Componentwise |
47
+ | `times(other)` `dividedBy(other)` | Componentwise; a number scales every component |
48
+ | `negated()` | The other way round |
49
+ | `dot(other)` | Dot product |
50
+ | `cross(other)` | Cross product. 3D only |
51
+ | `normalized()` | Scaled to unit length |
52
+ | `toArray()` | A copy, safe to keep |
53
+
54
+ #### Caveats
55
+
56
+ - **A zero vector normalises to itself**, not to NaN, so a degenerate normal cannot quietly
57
+ poison a transform downstream.
58
+ - `Vector.of` throws if the size does not match what was asked for.
59
+
60
+ ---
61
+
62
+ ## `Angle`
63
+
64
+ An angle, stored in radians and read in whichever unit the caller wants.
65
+
66
+ The kernel takes degrees and JavaScript's trigonometry takes radians, so a bare number would
67
+ be ambiguous at exactly the call sites where getting it wrong produces a model that is subtly
68
+ wrong rather than an error.
69
+
70
+ **Every angle the language reads or writes is in degrees.** Radians appear only here.
71
+
72
+ ### Construction
73
+
74
+ | | |
75
+ | --- | --- |
76
+ | `Angle.degrees(v)` | From degrees |
77
+ | `Angle.radians(v)` | From radians |
78
+ | `Angle.turns(v)` | From full turns, so `0.25` is a right angle |
79
+ | `Angle.of(v)` | From an `Angle`, or a **number read as degrees** |
80
+ | `new Angle(radians)` | Rarely the unit at hand; prefer the factories |
81
+
82
+ ### Properties and methods
83
+
84
+ | | Description |
85
+ | --- | --- |
86
+ | `radians` | The angle in radians |
87
+ | `degrees` | The angle in degrees |
88
+ | `sin` `cos` `tan` | Trigonometry, as properties |
89
+ | `plus(other)` `minus(other)` | Other may be an `Angle` or a number of degrees |
90
+ | `times(factor)` | A plain multiplier, not an angle |
91
+
92
+ The unit names appear twice — `Angle.degrees(90)` builds one, `angle.degrees` reads one back.
93
+ They are different namespaces and do not collide.
94
+
95
+ ---
96
+
97
+ ## `Transform`
98
+
99
+ An affine transform, held column-major at the size the kernel wants: 3×3 for 2D and 4×4 for
100
+ 3D.
101
+
102
+ Column-major throughout, because that is what Manifold reads. Storing row-major and
103
+ transposing at the boundary would put a silent transpose in the one place a wrong model still
104
+ renders.
105
+
106
+ ### Construction
107
+
108
+ | | |
109
+ | --- | --- |
110
+ | `Transform.identity(dim)` | Changes nothing |
111
+ | `Transform.translation(offset, dim)` | Moves |
112
+ | `Transform.scaling(factor, dim)` | Scales about the origin |
113
+ | `Transform.rotation2D(angle)` | Rotates about the origin. A bare number is degrees |
114
+ | `Transform.rotation3D(angles)` | Degrees about X, Y and Z |
115
+ | `Transform.rotation(angles, dim)` | Dispatches to whichever of the two fits |
116
+ | `Transform.mirroring(normal, dim)` | Reflects in the plane through the origin |
117
+
118
+ ### Properties and methods
119
+
120
+ | | Description |
121
+ | --- | --- |
122
+ | `dim` | 2 or 3 |
123
+ | `m` | The matrix, column-major and frozen |
124
+ | `order` | Side length — one more than `dim` |
125
+ | `at(row, col)` | One entry, read out of column-major storage |
126
+ | `concat(other)` | `this ∘ other` — **`other` runs first** |
127
+ | `toArray()` | A copy, safe to keep |
128
+
129
+ #### Caveats
130
+
131
+ - **3D rotation applies X, then Y, then Z**, matching OpenSCAD, so a model ported from there
132
+ lands in the same orientation.
133
+ - **`concat` applies its argument first.** That order matches how blocks nest: the transform
134
+ closest to the shape is applied to it first.
135
+ - `mirroring` normalises the normal, so its length does not change the result.
136
+ - Every transform is about the **origin**. To act about some other point, translate there,
137
+ act, and translate back.
138
+
139
+ ---
140
+
141
+ ## Usage
142
+
143
+ ### Composing a placement
144
+
145
+ ```js
146
+ import { Transform, GeometryNode } from 'forma-dsl';
147
+
148
+ // Rotate, then move — the argument to concat runs first.
149
+ const placement = Transform.translation([30, 0, 0], 3)
150
+ .concat(Transform.rotation3D([0, 0, 45]));
151
+
152
+ const placed = GeometryNode.transformed(shape, placement);
153
+ ```
154
+
155
+ Reading it as blocks, that is:
156
+
157
+ ```hcl
158
+ translate { offset = [30, 0, 0] rotate { z = 45 shape { } } }
159
+ ```
160
+
161
+ ### Rotating about a point
162
+
163
+ ```js
164
+ const about = (point, rotation) => Transform.translation(point, 3)
165
+ .concat(rotation)
166
+ .concat(Transform.translation(new Vector(point).negated().toArray(), 3));
167
+
168
+ const spun = GeometryNode.transformed(shape, about([20, 20, 0], Transform.rotation3D([0, 0, 30])));
169
+ ```
170
+
171
+ ### Working in degrees
172
+
173
+ ```js
174
+ import { Angle } from 'forma-dsl';
175
+
176
+ const a = Angle.degrees(30);
177
+ a.sin; // 0.5
178
+ a.radians; // 0.5235987755982988
179
+ a.plus(60).degrees; // 90 — a bare number is read as degrees
180
+ ```
181
+
182
+ ### Vector arithmetic
183
+
184
+ ```js
185
+ import { Vector } from 'forma-dsl';
186
+
187
+ const size = new Vector([40, 30, 12]);
188
+ const wall = 2;
189
+
190
+ const inner = size.minus(new Vector([wall * 2, wall * 2, 0]));
191
+ inner.toArray(); // [36, 26, 12]
192
+
193
+ Vector.of(3, 3).toArray(); // [3, 3, 3] — a scalar broadcasts
194
+ Vector.of({ x: 1, z: 5 }, 3).toArray(); // [1, 0, 5] — missing keys are zero
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Troubleshooting
200
+
201
+ ### `expected 3 components, got 2`
202
+
203
+ `Vector.of` was asked for a dimensionality the value does not have. In a document this
204
+ surfaces more helpfully as `translate: "offset" must be a number or an array of 3 numbers`.
205
+
206
+ ### `cannot read an angle from string`
207
+
208
+ `Angle.of` takes an `Angle` or a number. A string is not coerced — that would let `"90deg"`
209
+ silently become `NaN`.
210
+
211
+ ### My composed transform is applied in the wrong order
212
+
213
+ `concat` runs its **argument** first. `a.concat(b)` means "do `b`, then `a`", matching how
214
+ the equivalent nested blocks read.
215
+
216
+ ### My rotation moved the shape instead of turning it
217
+
218
+ Every transform is about the origin. Centre the geometry, rotate, then translate.
219
+
220
+ ### Non-uniform scaling made my circle an ellipse
221
+
222
+ Expected — that is how [`ellipse`](../reference/shapes-2d.md#ellipse) is built internally, and
223
+ it is the cheapest route to an ellipsoid from a sphere.
@@ -0,0 +1,253 @@
1
+ # Errors
2
+
3
+ Every error the language raises, grouped by the stage that raises it. Knowing the stage
4
+ narrows the cause: a lexer error is a typo, an evaluator error is a mistake about meaning.
5
+
6
+ All of them carry a source position where one is known, appended to the message and available
7
+ as `error.loc`:
8
+
9
+ ```js
10
+ try {
11
+ await render(source, { context });
12
+ } catch (error) {
13
+ error.loc?.line; // undefined when the error has no position
14
+ }
15
+ ```
16
+
17
+ Most are a [`FormaError`](api/low-level.md#formaerror). A few come from the kernel or from
18
+ `render` itself and are plain `Error`s, so test `error.loc` rather than the class.
19
+
20
+ ---
21
+
22
+ ## Lexer
23
+
24
+ Raised while turning characters into tokens. Always a typo.
25
+
26
+ | Message | Cause |
27
+ | --- | --- |
28
+ | `unexpected character "…"` | A character that cannot begin a token — a smart quote, a stray backtick, a non-ASCII dash |
29
+ | `unterminated string` | A `"` with no closing `"`, or a literal newline inside one |
30
+ | `unterminated block comment` | A `/*` with no `*/`. Block comments do not nest |
31
+ | `unknown escape \q` | Only `\n` `\t` `\r` `\"` `\\` `\$` exist |
32
+ | `unterminated ${ } in string` | A splice with no closing brace |
33
+
34
+ ---
35
+
36
+ ## Parser
37
+
38
+ Raised while turning tokens into a tree. Always a grammar mistake.
39
+
40
+ | Message | Cause |
41
+ | --- | --- |
42
+ | `expected a block, found "…"` | Something at the top level that is not a block |
43
+ | `unknown top-level block "shape"` | Only `param`, `local`, `component` and `model` may appear at the top level |
44
+ | `expected an attribute or block, found "…"` | Inside a body, an entry that does not start with a name |
45
+ | `expected {, found "…"` | A `param` with no `=` and no body — write `param s { }`. Also a `component` or `model` with no body |
46
+ | `expected =, found "…"` | A `local` with no value |
47
+ | `expected ident, found "…"` | A keyword where a name was required |
48
+ | `"for" cannot be used as a block type` | One of the eleven [keywords](language/syntax.md#keywords) used as a block name |
49
+ | `a param block holds attributes, not nested blocks` | Usually a missing `=`: `param h { default 20 }` |
50
+ | `a label cannot contain ${ }` | `component` and `model` names must be static. [`part`](reference/part.md) labels may be expressions |
51
+ | `expected = or : in an object` | An object literal entry with no separator |
52
+ | `unexpected "…"` | A token that cannot begin an expression |
53
+ | `trailing input in ${ }` | A splice holding more than one expression |
54
+
55
+ ### `expected {, found "box"`
56
+
57
+ The most common parser error, and the least obvious. A component param needs `=` or a body,
58
+ even an empty one:
59
+
60
+ ```hcl
61
+ component "c" {
62
+ param s // ✗ the parser is looking for `=` or `{`
63
+ box { size = [s, s, s] }
64
+ }
65
+
66
+ component "c" {
67
+ param s { } // ✓ declared, and required at the call site
68
+ box { size = [s, s, s] }
69
+ }
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Declarations
75
+
76
+ Raised when the parsed document is indexed.
77
+
78
+ | Message | Cause |
79
+ | --- | --- |
80
+ | `param "a" is declared twice` | Two top-level params share a name |
81
+ | `component "c" is declared twice` | Two components share a name |
82
+ | `model "m" is declared twice` | Two models share a name |
83
+ | `component "box" shadows a builtin block` | Components and builtins share one namespace |
84
+
85
+ Locals are the exception: a duplicate `local` is accepted and silently replaces the first.
86
+
87
+ ---
88
+
89
+ ## Names and expressions
90
+
91
+ Raised while evaluating.
92
+
93
+ | Message | Cause |
94
+ | --- | --- |
95
+ | `unknown name "x"` | Nothing in scope binds it. See [Scope](language/scope.md) |
96
+ | `no attribute "x"` | A missing key on an object — including `var.x` for something that is not a param |
97
+ | `cannot read "x" of nothing` | A property read on `null` |
98
+ | `cannot index this value` | Indexing something that is not a list, string or object |
99
+ | `cannot negate a non-number` | Unary `-` on a string, list or bool |
100
+ | `cannot apply + to a list and a number` | Operands the operator does not accept. See the [operator table](language/expressions.md#arithmetic) |
101
+ | `cannot compare a number with a string` | `<` `<=` `>` `>=` require both sides to be the same type |
102
+ | `cannot combine vectors of length 2 and 3` | Componentwise arithmetic on lists of different lengths |
103
+ | `division by zero` · `modulo by zero` | Errors rather than `Infinity` or `NaN`, so a degenerate value cannot reach the kernel |
104
+ | `unknown function "clamp"` | No such builtin, and there are no user-defined functions |
105
+ | `len takes 1 argument(s), got 2` | Wrong argument count for a fixed-arity function |
106
+ | `pow: functions take positional arguments` | Named arguments are not supported |
107
+ | `range: step cannot be 0` | A zero step would never terminate |
108
+ | `range: more than 100000 values` | A guard against a live editor allocating until the tab dies |
109
+
110
+ ### `unknown name "outer-inner"`
111
+
112
+ Hyphens are part of an identifier, so `outer-inner` is one name. Put spaces around the
113
+ operator: `outer - inner`. See [Identifiers](language/syntax.md#identifiers).
114
+
115
+ ### `unknown name "var"`
116
+
117
+ A param default referred to `var`, which is assembled only after every param has resolved.
118
+ Use the earlier param's bare name instead — see
119
+ [the root scope](language/scope.md#the-root-scope).
120
+
121
+ ---
122
+
123
+ ## Params
124
+
125
+ | Message | Cause |
126
+ | --- | --- |
127
+ | `param "h" has no default and no value was supplied` | A required param with no value. Pass one, or give it a default |
128
+ | `model "m": param "s" needs a default` | Model params cannot be set from outside, so a default is the only way they get a value |
129
+
130
+ ---
131
+
132
+ ## Blocks
133
+
134
+ Raised while building geometry.
135
+
136
+ | Message | Cause |
137
+ | --- | --- |
138
+ | `unknown block "bloop"` | Not a builtin and not a declared component. Check the spelling against the [block list](README.md#shapes) |
139
+ | `"box" does not take a label` | Only [`part`](reference/part.md) takes a meaningful label |
140
+ | `"box" is a shape and cannot contain other blocks` | A shape is a leaf. Usually a missing `}` swallowed the next block |
141
+ | `"translate" needs at least one shape inside it` | An operation with nothing to act on |
142
+ | `box: unknown attribute "colour"` | Every block rejects attributes it did not read, so a typo fails loudly |
143
+ | `attribute "size" is set twice` | The same attribute written twice in one block |
144
+ | `box: "size" must be a number or an array of 3 numbers` | Wrong shape of value |
145
+ | `circle: "radius" must be a finite number` | A `NaN` or `Infinity` reached the block |
146
+ | `regular_polygon: "sides" must be at least 3` | |
147
+ | `polygon: "points" must be an array of at least 3 points` | |
148
+ | `circle: give either "radius" or "diameter", not both` | They mean the same thing |
149
+ | `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"` | One end radius on its own is ambiguous |
150
+ | `offset: "join" must be one of "Square", "Round", "Miter"` | An enum attribute with an unrecognised value |
151
+
152
+ ### `box: unknown attribute "colour"`
153
+
154
+ Worth dwelling on, because it is the error that saves the most time. A misspelled attribute
155
+ would otherwise be silent, and the model would render subtly wrong with nothing to point at.
156
+ The one exception is a [`param`](reference/param.md) block, which is metadata and passes
157
+ anything through.
158
+
159
+ ---
160
+
161
+ ## Dimensionality
162
+
163
+ | Message | Cause |
164
+ | --- | --- |
165
+ | `"union" cannot mix 2D and 3D shapes` | Children of different dimensionality in one block |
166
+ | `"extrude" takes 2D geometry, got 3D` | A block that fixes what it takes got the other thing |
167
+ | `part "body" is 2D — extrude or revolve it before rendering` | A cross-section reached the renderer |
168
+ | `cannot combine 2D and 3D geometry in a union` | The same mistake, from the [node API](api/geometry-node.md) rather than a document |
169
+
170
+ See [Dimensionality](language/overview.md#dimensionality) for the three rules, and
171
+ [2D ↔ 3D](reference/conversions.md) for the way across.
172
+
173
+ ---
174
+
175
+ ## Components
176
+
177
+ | Message | Cause |
178
+ | --- | --- |
179
+ | `c: "s" has no default and was not given` | A required component param the call site did not pass |
180
+ | `c: unknown param "t"` | Components have no pass-through: every value must be declared |
181
+ | `component "c" produced no geometry` | An empty body, a loop over an empty list, or an `if` that took no branch |
182
+ | `a component cannot declare parts` | Scene structure belongs in the model |
183
+ | `nesting deeper than 64 blocks — is a component using itself?` | Direct or mutual recursion. Rewrite as a `for` loop |
184
+
185
+ ---
186
+
187
+ ## Parts and scenes
188
+
189
+ | Message | Cause |
190
+ | --- | --- |
191
+ | `a part cannot contain another part` | Parts are a flat list |
192
+ | `a part needs at least one shape inside it` | Put the `if` around the part, not inside it |
193
+ | `model "m" produced no geometry` | Nothing was produced. Often a `for` over an empty range |
194
+ | `the document declares no model` | Add a `model` block |
195
+ | `no model named "x"` | `render(source, { model })` named one that does not exist |
196
+
197
+ A [`part`](reference/part.md) nested inside an operation raises **nothing** — it is silently
198
+ dropped. If a part is missing from a render, check it is written directly in the model.
199
+
200
+ ---
201
+
202
+ ## Control flow
203
+
204
+ | Message | Cause |
205
+ | --- | --- |
206
+ | `for: expected a list to iterate` | The sequence is not a list. Strings are not iterable here |
207
+
208
+ ---
209
+
210
+ ## `align`
211
+
212
+ | Message | Cause |
213
+ | --- | --- |
214
+ | `align needs a kernel context — render the model rather than building it` | An `Evaluator` built with no context. [`render`](api/render.md) always supplies one |
215
+ | `align needs at least one shape inside it` | Nothing to measure |
216
+ | `align: "x" must be "min", "center", "max" or a number` | The spelling is `"center"` |
217
+
218
+ ---
219
+
220
+ ## Runtime
221
+
222
+ Raised by the JavaScript API rather than by a document.
223
+
224
+ | Message | Cause |
225
+ | --- | --- |
226
+ | `EvaluationContext has been disposed` | A solid or a render used a context after `dispose()` |
227
+ | `EvaluationContext used before create() resolved` | The constructor was called directly. Use `EvaluationContext.create()` |
228
+ | `bad dimensionality 4` | `new GeometryNode` with something other than 2 or 3 |
229
+ | `expected 3 components, got 2` | `Vector.of` asked for a size the value does not have |
230
+ | `cannot read an angle from string` | `Angle.of` takes an `Angle` or a number |
231
+
232
+ ---
233
+
234
+ ## Silent behaviours
235
+
236
+ Not errors, and each is deliberate — but each has surprised someone. A parameter reaching the
237
+ end of its range should not break a model, so these degenerate quietly:
238
+
239
+ | What | Result |
240
+ | --- | --- |
241
+ | `extrude { height = 0 }` | Empty geometry |
242
+ | `revolve { angle = 0 }` | Empty geometry |
243
+ | A `difference` that removes everything | Empty geometry |
244
+ | A negative `offset` larger than the shape | Empty geometry |
245
+ | `range` running backwards for its step | An empty list |
246
+ | A label on `align` or a component call | Ignored |
247
+ | A supplied param the document does not declare | Ignored |
248
+ | `min`, `max`, `step`, `options` on a param | Advisory; never enforced |
249
+ | A duplicate `local` | Replaces the first, everywhere |
250
+ | A `part` inside an operation | Dropped from the scene |
251
+ | An index past the end of a list | Nothing, which fails later where it is used |
252
+
253
+ If a model renders but is not what you meant, this table is the place to start.
@@ -0,0 +1,160 @@
1
+ # Control flow
2
+
3
+ Two constructs, both of which appear where a block appears and produce geometry rather than
4
+ a value. Neither is an expression — for a value that depends on a condition, use
5
+ [`cond ? a : b`](expressions.md#conditional).
6
+
7
+ ---
8
+
9
+ ## `for`
10
+
11
+ Repeats a body once per item in a list.
12
+
13
+ ```hcl
14
+ for i in range(0, 6) { ... } // one name: the value
15
+ for i, item in list { ... } // two names: the index, then the value
16
+ ```
17
+
18
+ Everything every iteration produces is collected and handed to the enclosing block, so a
19
+ loop inside an operation contributes several children to it:
20
+
21
+ ```hcl
22
+ difference {
23
+ extrude { height = 20 rounded_rect { size = [62, 13] radius = 6.5 center = true } }
24
+
25
+ for i, size in sizes { // each iteration adds one more thing to subtract
26
+ translate {
27
+ offset = [(i - (len(sizes) - 1) / 2) * 8, 0, 2.5]
28
+ extrude { height = 20 regular_polygon { sides = 6 width_across_flats = size } }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ### Reference
35
+
36
+ | | |
37
+ | --- | --- |
38
+ | **Sequence** | Any expression that evaluates to a list. Iterating anything else is an error: `for: expected a list to iterate` |
39
+ | **Names** | One name binds the value. Two names bind the index and then the value |
40
+ | **Scope** | Each iteration gets a fresh child scope holding just those names |
41
+ | **Result** | The concatenation of what every iteration produced — geometry, and any `part`s |
42
+
43
+ #### Caveats
44
+
45
+ - A loop over an empty list produces nothing. If that is all a model contains, the model
46
+ fails with `model "m" produced no geometry`. Guard the empty case with
47
+ [`if`](#if) when a parameter can legitimately reach zero.
48
+ - `range(a, b)` **excludes** `b`, and produces an empty list rather than looping forever when
49
+ the range runs backwards. See [`range`](../reference/functions.md#range).
50
+ - Iterating a string does not work — strings are not lists here. Use
51
+ [`len`](../reference/functions.md#len) and index into it if you need the characters.
52
+ - There is no `break`, no `continue` and no accumulator. A loop that needs to skip items
53
+ should filter the list it iterates, or wrap its body in an `if`.
54
+
55
+ ### Building parts in a loop
56
+
57
+ A [`part`](../reference/part.md) inside a loop needs a distinct name, and the loop variable
58
+ is how it gets one:
59
+
60
+ ```hcl
61
+ model "stacked_trays" {
62
+ for i in range(0, var.rows) {
63
+ part "tray_${i}" {
64
+ color = i % 2 == 0 ? "#3f7fbf" : "#bf6b3f"
65
+ translate { offset = [0, 0, i * 13] tray { size = [40, 30, 12] } }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Part labels are expressions, so `"tray_${i}"` is evaluated once per iteration. Two parts with
72
+ the same name are not rejected, but the viewer will show two entries called the same thing.
73
+
74
+ ### Radial arrays
75
+
76
+ The common pattern for anything on a bolt circle:
77
+
78
+ ```hcl
79
+ for i in range(0, var.bolts) {
80
+ rotate {
81
+ z = i * 360 / var.bolts
82
+ translate { offset = [var.plate_r - 9, 0, -1] cylinder { radius = 3 height = 10 } }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ---
88
+
89
+ ## `if`
90
+
91
+ Includes a body only when a condition holds.
92
+
93
+ ```hcl
94
+ if var.reinforced {
95
+ box { size = [40, 40, 4] }
96
+ }
97
+ ```
98
+
99
+ With an alternative, and chained:
100
+
101
+ ```hcl
102
+ if var.style == "round" {
103
+ cylinder { radius = 10 height = 4 }
104
+ } else if var.style == "hex" {
105
+ extrude { height = 4 regular_polygon { sides = 6 radius = 10 } }
106
+ } else {
107
+ box { size = [20, 20, 4] center = true }
108
+ }
109
+ ```
110
+
111
+ ### Reference
112
+
113
+ | | |
114
+ | --- | --- |
115
+ | **Condition** | Any expression. Judged by [truthiness](expressions.md#truthiness), so `if var.name { }` is true for a non-empty string |
116
+ | **Branches** | `else` takes a body; `else if` chains, nested rather than flattened |
117
+ | **Scope** | Each branch is its own body scope |
118
+ | **Result** | What the taken branch produced, or nothing when no branch is taken |
119
+
120
+ #### Caveats
121
+
122
+ - A condition is an ordinary expression, so both branches are *parsed* but only the taken one
123
+ is evaluated. An error inside the untaken branch will not be reported.
124
+ - An `if` that takes no branch contributes nothing. Inside an operation that requires
125
+ children, that can turn into `"translate" needs at least one shape inside it`.
126
+ - There is no `if` for attributes. Use the conditional operator:
127
+ `color = var.hot ? "#e2593c" : "#3f7fbf"`.
128
+
129
+ ### Making a part optional
130
+
131
+ Put the `if` **outside** the `part`, not inside it. A part with no geometry is an error
132
+ (`a part needs at least one shape inside it`), so guarding the contents is not enough:
133
+
134
+ ```hcl
135
+ // ✓ the part exists only when it is wanted
136
+ if var.show_label {
137
+ part "label" { color = "#f4f1e4" ... }
138
+ }
139
+
140
+ // ✗ fails with "a part needs at least one shape inside it" when show_label is false
141
+ part "label" {
142
+ if var.show_label { ... }
143
+ }
144
+ ```
145
+
146
+ ### Choosing between shapes
147
+
148
+ Because `if` produces geometry, it composes inside any operation:
149
+
150
+ ```hcl
151
+ difference {
152
+ box { size = [40, 40, 10] }
153
+
154
+ if var.through {
155
+ translate { offset = [20, 20, -1] cylinder { radius = 4 height = 12 } }
156
+ } else {
157
+ translate { offset = [20, 20, 4] cylinder { radius = 4 height = 7 } }
158
+ }
159
+ }
160
+ ```