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,279 @@
1
+ # 3D shapes
2
+
3
+ Five leaf blocks producing solids. They take attributes and never contain other blocks.
4
+
5
+ | Block | Sized by | Centred by default |
6
+ | --- | --- | --- |
7
+ | [`box`](#box) | `size` | no |
8
+ | [`sphere`](#sphere) | `radius` or `diameter` | always |
9
+ | [`cylinder`](#cylinder) | `height` and one or two radii | no |
10
+ | [`cone`](#cone) | `height`, `radius`, `top_radius` | no |
11
+ | [`torus`](#torus) | `radius`, `tube_radius` | always |
12
+
13
+ Anything these cannot express is usually a 2D profile plus
14
+ [`extrude`](conversions.md#extrude) or [`revolve`](conversions.md#revolve) — that is where
15
+ the real modelling happens.
16
+
17
+ ---
18
+
19
+ ## `box`
20
+
21
+ An axis-aligned cuboid.
22
+
23
+ ```hcl
24
+ box { size = [40, 20, 10] }
25
+ box { size = [40, 20, 10] center = true }
26
+ ```
27
+
28
+ #### Attributes
29
+
30
+ | Attribute | Type | Default | Description |
31
+ | --- | --- | --- | --- |
32
+ | `size` | 3-vector | **required** | X, Y and Z extents |
33
+ | `center` | bool | `false` | `true` centres it on the origin; otherwise its minimum corner sits there |
34
+
35
+ #### Caveats
36
+
37
+ - A single number is accepted as shorthand for all three, so `size = 10` is a 10 mm cube.
38
+ - There is no rounded box. Round a 2D profile and extrude it —
39
+ see [Usage](#rounded-boxes).
40
+
41
+ ---
42
+
43
+ ## `sphere`
44
+
45
+ ```hcl
46
+ sphere { radius = 12 }
47
+ sphere { diameter = 24 segments = 64 }
48
+ ```
49
+
50
+ #### Attributes
51
+
52
+ | Attribute | Type | Default | Description |
53
+ | --- | --- | --- | --- |
54
+ | `radius` | number | `1` | |
55
+ | `diameter` | number | — | An alternative spelling of the same thing |
56
+ | `segments` | integer | `0` | Segments around the equator; `0` lets the kernel choose |
57
+
58
+ #### Caveats
59
+
60
+ - Give `radius` **or** `diameter`, never both.
61
+ - Always centred on the origin.
62
+
63
+ ---
64
+
65
+ ## `cylinder`
66
+
67
+ A cylinder, or a frustum when the two ends differ.
68
+
69
+ ```hcl
70
+ cylinder { radius = 5 height = 20 }
71
+ cylinder { diameter = 10 height = 20 center = true }
72
+ cylinder { bottom_radius = 12 top_radius = 8 height = 15 }
73
+ ```
74
+
75
+ #### Attributes
76
+
77
+ | Attribute | Type | Default | Description |
78
+ | --- | --- | --- | --- |
79
+ | `height` | number | **required** | Along Z |
80
+ | `radius` | number | — | Both ends alike |
81
+ | `diameter` | number | — | An alternative spelling of `radius` |
82
+ | `bottom_radius` | number | — | The end at Z = 0 |
83
+ | `top_radius` | number | — | The end at Z = `height` |
84
+ | `segments` | integer | `0` | Segments around; `0` lets the kernel choose |
85
+ | `center` | bool | `false` | `true` centres it on Z = 0; otherwise it stands on the XY plane |
86
+
87
+ #### Caveats
88
+
89
+ - Give **either** `radius`/`diameter`, **or both** `bottom_radius` and `top_radius`. Anything
90
+ else is `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"` —
91
+ one radius on its own is ambiguous, and there is no default.
92
+ - Unlike every other shape, `radius` has no usable default here: the check runs before the
93
+ fallback would apply.
94
+ - `center` affects Z only. X and Y are always centred on the axis.
95
+
96
+ ---
97
+
98
+ ## `cone`
99
+
100
+ A cone, or a truncated cone.
101
+
102
+ ```hcl
103
+ cone { radius = 10 height = 20 }
104
+ cone { radius = 10 top_radius = 4 height = 20 }
105
+ ```
106
+
107
+ #### Attributes
108
+
109
+ | Attribute | Type | Default | Description |
110
+ | --- | --- | --- | --- |
111
+ | `height` | number | **required** | Along Z |
112
+ | `radius` | number | `1` | The base, at Z = 0 |
113
+ | `diameter` | number | — | An alternative spelling of `radius` |
114
+ | `top_radius` | number | `0` | `0` gives a point |
115
+ | `segments` | integer | `0` | Segments around |
116
+ | `center` | bool | `false` | As for [`cylinder`](#cylinder) |
117
+
118
+ #### Caveats
119
+
120
+ - `cone` and `cylinder` build the same kind of solid; they differ only in which attributes
121
+ are required and what `top_radius` defaults to. Use whichever reads better — `cone` when
122
+ the top is a point, `cylinder` when both ends matter.
123
+
124
+ ---
125
+
126
+ ## `torus`
127
+
128
+ A ring, optionally a partial arc.
129
+
130
+ ```hcl
131
+ torus { radius = 20 tube_radius = 4 }
132
+ torus { radius = 20 tube_radius = 4 angle = 180 }
133
+ ```
134
+
135
+ #### Attributes
136
+
137
+ | Attribute | Type | Default | Description |
138
+ | --- | --- | --- | --- |
139
+ | `radius` | number | **required** | Centre of the ring to centre of the tube |
140
+ | `tube_radius` | number | **required** | Radius of the tube itself |
141
+ | `angle` | angle | `360` | How far round to sweep, in degrees |
142
+ | `segments` | integer | `0` | Segments around the ring |
143
+ | `tube_segments` | integer | `0` | Segments around the tube's cross-section |
144
+
145
+ #### Caveats
146
+
147
+ - Built as a circle pushed out to `radius` and revolved, which is what a torus is. The outer
148
+ radius of the result is `radius + tube_radius`.
149
+ - `tube_radius` greater than `radius` produces self-intersecting geometry. The kernel will
150
+ resolve it into *something*, but not anything you meant.
151
+ - An `angle` of `0` or less yields empty geometry rather than an error, so a parameter
152
+ sweeping to zero does not break the model.
153
+ - Always centred on the origin, lying in the XY plane.
154
+
155
+ ---
156
+
157
+ ## Usage
158
+
159
+ ### Rounded boxes
160
+
161
+ There is no rounded-box primitive. Round the 2D profile and extrude it — which also gives
162
+ control over whether the vertical edges or the horizontal ones are rounded:
163
+
164
+ ```hcl
165
+ component "rounded_box" {
166
+ param size = [20, 20, 10]
167
+ param radius = 3
168
+
169
+ extrude {
170
+ height = size.z
171
+ rounded_rect { size = [size.x, size.y] radius = radius center = true }
172
+ }
173
+ }
174
+ ```
175
+
176
+ For rounding in all three axes, [`smooth`](refinement.md#smooth) or a
177
+ [`hull`](refinement.md#hull) of eight spheres are the usual approaches.
178
+
179
+ ### Through-holes
180
+
181
+ Make the cutter longer than the thing it cuts, and start it below:
182
+
183
+ ```hcl
184
+ difference {
185
+ cylinder { radius = 40 height = 8 }
186
+
187
+ translate {
188
+ offset = [0, 0, -1] // start below the bottom face
189
+ cylinder { radius = 12 height = 10 } // and finish above the top
190
+ }
191
+ }
192
+ ```
193
+
194
+ A cutter that ends exactly on a face leaves a zero-thickness coincident surface, which is
195
+ slow to resolve and can produce artefacts. One millimetre of overshoot at each end costs
196
+ nothing.
197
+
198
+ ### Bolt circles
199
+
200
+ ```hcl
201
+ for i in range(0, var.bolts) {
202
+ rotate {
203
+ z = i * 360 / var.bolts
204
+ translate { offset = [var.plate_r - 9, 0, -1] cylinder { radius = 3 height = 10 } }
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### Rings and fillets from a torus
210
+
211
+ A torus is the cheapest way to get a circular fillet or a raised thread ridge:
212
+
213
+ ```hcl
214
+ for i in range(0, 3) {
215
+ translate {
216
+ offset = [0, 0, 174 + i * 4.5]
217
+ torus { radius = 15.6 tube_radius = 1.1 segments = 96 tube_segments = 16 }
218
+ }
219
+ }
220
+ ```
221
+
222
+ ### Controlling facet count
223
+
224
+ `segments` is per shape, and it is worth setting on anything that must fit something else:
225
+
226
+ ```hcl
227
+ param preview { type = bool default = true }
228
+ local facets = var.preview ? 24 : 128
229
+
230
+ model "m" {
231
+ cylinder { radius = 10 height = 20 segments = facets }
232
+ }
233
+ ```
234
+
235
+ Globally, [`setQuality`](../api/kernel.md#setquality) moves the default that `segments = 0`
236
+ resolves to.
237
+
238
+ ---
239
+
240
+ ## Troubleshooting
241
+
242
+ ### `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"`
243
+
244
+ Either one radius for both ends, or two for a frustum. Giving only `bottom_radius` leaves the
245
+ top undefined:
246
+
247
+ ```hcl
248
+ cylinder { height = 10 bottom_radius = 5 } // ✗
249
+ cylinder { height = 10 bottom_radius = 5 top_radius = 5 } // ✓
250
+ cylinder { height = 10 radius = 5 } // ✓ simpler
251
+ ```
252
+
253
+ ### `sphere: give either "radius" or "diameter", not both`
254
+
255
+ Pass one.
256
+
257
+ ### `"box" is a shape and cannot contain other blocks`
258
+
259
+ A shape is a leaf. Usually a missing `}` on an earlier line has swallowed the next block.
260
+
261
+ ### `box: "size" must be a number or an array of 3 numbers`
262
+
263
+ `size` needs three components in 3D. A 2-vector is a common slip when converting a profile:
264
+
265
+ ```hcl
266
+ box { size = [40, 20] } // ✗
267
+ box { size = [40, 20, 10] } // ✓
268
+ ```
269
+
270
+ ### My cylinder is not where I expect
271
+
272
+ By default a cylinder stands **on** the XY plane, with its base at Z = 0. `center = true`
273
+ puts its middle there instead. The same is true of `box`, whose minimum corner — not its
274
+ centre — sits at the origin by default.
275
+
276
+ ### Two solids that touch exactly render with artefacts
277
+
278
+ Coincident faces are ambiguous. Overlap them by a small amount when unioning, and overshoot
279
+ by a small amount when subtracting.
@@ -0,0 +1,246 @@
1
+ # Transforms
2
+
3
+ Four operations that move geometry without changing its shape. Each works in 2D and 3D, and
4
+ passes its children's dimensionality through.
5
+
6
+ All four **union their children first**, so a transform applied to several blocks moves them
7
+ together:
8
+
9
+ ```hcl
10
+ translate {
11
+ offset = [0, 0, 10]
12
+ box { size = [4, 4, 4] }
13
+ sphere { radius = 3 } // both move
14
+ }
15
+ ```
16
+
17
+ Nested transforms collapse into a single matrix before they reach the kernel, so a deep stack
18
+ of `translate` and `rotate` blocks costs one operation rather than one per level.
19
+
20
+ ---
21
+
22
+ ## `translate`
23
+
24
+ Moves geometry.
25
+
26
+ ```hcl
27
+ translate { offset = [10, 0, 5] box { size = [4, 4, 4] } }
28
+ translate { offset = [10, 0] rect { size = [4, 4] } } // 2D
29
+ ```
30
+
31
+ #### Attributes
32
+
33
+ | Attribute | Type | Default | Description |
34
+ | --- | --- | --- | --- |
35
+ | `offset` | vector of the child's dimensionality | **required** | How far to move along each axis |
36
+
37
+ #### Caveats
38
+
39
+ - `offset` must have as many components as the child has dimensions — 2 for a cross-section,
40
+ 3 for a solid. A mismatch is `translate: "offset" must be a number or an array of 3
41
+ numbers`.
42
+ - A single number broadcasts to every axis, which is rarely what you want here but is
43
+ consistent with every other vector attribute.
44
+
45
+ ---
46
+
47
+ ## `rotate`
48
+
49
+ Turns geometry about the origin. **All angles are in degrees.**
50
+
51
+ ```hcl
52
+ rotate { z = 45 box { size = [20, 4, 4] } } // 3D, one axis
53
+ rotate { angles = [0, 90, 45] box { size = [20, 4, 4] } } // 3D, all three
54
+ rotate { angle = 30 rect { size = [20, 4] } } // 2D
55
+ ```
56
+
57
+ #### Attributes — 3D children
58
+
59
+ | Attribute | Type | Default | Description |
60
+ | --- | --- | --- | --- |
61
+ | `angles` | 3-vector | — | Degrees about X, Y and Z at once |
62
+ | `x`, `y`, `z` | number | `0` | Degrees about that axis, named one at a time |
63
+
64
+ #### Attributes — 2D children
65
+
66
+ | Attribute | Type | Default | Description |
67
+ | --- | --- | --- | --- |
68
+ | `angle` | angle | **required** | Degrees, counter-clockwise |
69
+
70
+ #### Caveats
71
+
72
+ - **Rotation is about the origin, not about the geometry.** To spin something in place, put
73
+ it at the origin, rotate, then translate — or build it centred in the first place.
74
+ - In 3D the axes are applied **X, then Y, then Z**, matching OpenSCAD, so a model ported from
75
+ there lands in the same orientation. Composing rotations in a different order requires
76
+ separate `rotate` blocks.
77
+ - `angles` and the named axes are alternatives. Using `angle` on a 3D child, or `x`/`y`/`z`
78
+ on a 2D one, is an unknown attribute.
79
+
80
+ ---
81
+
82
+ ## `scale`
83
+
84
+ Scales geometry about the origin.
85
+
86
+ ```hcl
87
+ scale { factor = 2 sphere { radius = 5 } }
88
+ scale { factor = [1, 1, 0.5] sphere { radius = 5 } } // flattened
89
+ ```
90
+
91
+ #### Attributes
92
+
93
+ | Attribute | Type | Default | Description |
94
+ | --- | --- | --- | --- |
95
+ | `factor` | vector of the child's dimensionality | **required** | Multiplier per axis. A single number scales every axis alike |
96
+
97
+ #### Caveats
98
+
99
+ - Scaling is about the origin, so anything not centred there also moves.
100
+ - A negative factor mirrors as well as scales, and flips the winding. Prefer
101
+ [`mirror`](#mirror) when reflection is what you mean.
102
+ - A factor of `0` collapses the geometry to nothing.
103
+ - Non-uniform scaling of a curved surface changes its radii — a scaled sphere is an
104
+ ellipsoid, which is the cheapest way to get one.
105
+
106
+ ---
107
+
108
+ ## `mirror`
109
+
110
+ Reflects geometry in the plane through the origin with the given normal.
111
+
112
+ ```hcl
113
+ mirror { normal = [1, 0, 0] bracket { } } // reflect across the YZ plane
114
+ ```
115
+
116
+ #### Attributes
117
+
118
+ | Attribute | Type | Default | Description |
119
+ | --- | --- | --- | --- |
120
+ | `normal` | vector of the child's dimensionality | **required** | The normal of the mirror plane. Normalised first, so its length does not matter |
121
+
122
+ #### Caveats
123
+
124
+ - The plane always passes through the **origin**. To mirror about some other plane,
125
+ translate to the origin, mirror, and translate back.
126
+ - In 2D, `normal` describes a line rather than a plane.
127
+ - `mirror` replaces the geometry with its reflection; it does not keep the original. For a
128
+ symmetric pair, union both:
129
+
130
+ ```hcl
131
+ union {
132
+ bracket { }
133
+ mirror { normal = [1, 0, 0] bracket { } }
134
+ }
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Usage
140
+
141
+ ### Rotating in place
142
+
143
+ The origin is the pivot, so centre first:
144
+
145
+ ```hcl
146
+ // ✗ the box sweeps around the origin
147
+ rotate { z = 30 box { size = [40, 10, 4] } }
148
+
149
+ // ✓ centred, so it turns on the spot
150
+ rotate { z = 30 box { size = [40, 10, 4] center = true } }
151
+
152
+ // ✓ or move it back afterwards
153
+ translate { offset = [20, 5, 0] rotate { z = 30 box { size = [40, 10, 4] center = true } } }
154
+ ```
155
+
156
+ ### Order of operations
157
+
158
+ Blocks nest inside-out: the transform closest to the shape is applied first.
159
+
160
+ ```hcl
161
+ translate { // …then moved 50 along X
162
+ offset = [50, 0, 0]
163
+ rotate { // the box is rotated first…
164
+ z = 45
165
+ box { size = [20, 4, 4] center = true }
166
+ }
167
+ }
168
+ ```
169
+
170
+ Swapping the two gives a different result — rotating a translated box sweeps it around the
171
+ origin on an arc of radius 50.
172
+
173
+ ### Radial patterns
174
+
175
+ Rotate a translated copy, once per step:
176
+
177
+ ```hcl
178
+ for i in range(0, 8) {
179
+ rotate {
180
+ z = i * 45
181
+ translate { offset = [30, 0, 0] cylinder { radius = 3 height = 10 } }
182
+ }
183
+ }
184
+ ```
185
+
186
+ Because identity is content identity, this costs **one** cylinder and eight transforms — the
187
+ cylinder node is the same node every time round the loop.
188
+
189
+ ### Mirrored pairs
190
+
191
+ ```hcl
192
+ component "hinge_leaf" { ... }
193
+
194
+ model "hinge" {
195
+ part "left" { hinge_leaf { } }
196
+ part "right" { mirror { normal = [1, 0, 0] hinge_leaf { } } }
197
+ }
198
+ ```
199
+
200
+ ### Ellipsoids and tapers
201
+
202
+ Non-uniform `scale` is the simplest route to shapes the primitives do not offer:
203
+
204
+ ```hcl
205
+ scale { factor = [1, 0.6, 1] sphere { radius = 20 segments = 64 } }
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Troubleshooting
211
+
212
+ ### `translate: "offset" must be a number or an array of 3 numbers`
213
+
214
+ The offset has the wrong number of components for its child. 2D children want two, 3D
215
+ children want three — and a 2D shape stays 2D until it is extruded:
216
+
217
+ ```hcl
218
+ translate { offset = [1, 2, 3] rect { size = [4, 4] } } // ✗ rect is 2D
219
+ translate { offset = [1, 2] rect { size = [4, 4] } } // ✓
220
+ ```
221
+
222
+ ### `rotate: unknown attribute "angle"`
223
+
224
+ `angle` is the 2D spelling. A 3D child takes `x`, `y`, `z` or `angles`:
225
+
226
+ ```hcl
227
+ rotate { angle = 45 box { size = [1, 1, 1] } } // ✗
228
+ rotate { z = 45 box { size = [1, 1, 1] } } // ✓
229
+ ```
230
+
231
+ The reverse — `z` on a 2D child — fails the same way.
232
+
233
+ ### `"translate" needs at least one shape inside it`
234
+
235
+ Transforms need something to act on. Often the body contains only a
236
+ [`part`](part.md), which contributes no geometry to its parent, or an `if` that took no
237
+ branch.
238
+
239
+ ### My rotated part flew off across the scene
240
+
241
+ Rotation is about the origin. A shape 200 mm out rotates around a 200 mm arc. Centre it,
242
+ rotate, then move it where it belongs.
243
+
244
+ ### My mirrored copy vanished
245
+
246
+ `mirror` replaces rather than adds. Union the original with the reflection if you want both.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The reference manual, bundled and searchable.
3
+ *
4
+ * The manual is the language's own documentation, copied into the package by
5
+ * `scripts/sync-reference.mjs` rather than paraphrased, so an answer from here is the same
6
+ * answer a person reading the published docs would get. Search is deliberately simple —
7
+ * substring matching over headings and body text, ranked — because the corpus is 28 pages
8
+ * and anything cleverer would be a dependency and a source of surprise.
9
+ */
10
+ import { readFile, readdir } from 'node:fs/promises';
11
+ import { join, relative } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ /** Where the copied manual lives inside the package. */
15
+ const ROOT = fileURLToPath(new URL('./reference/', import.meta.url));
16
+
17
+ /** How many characters of context a search hit carries. */
18
+ const SNIPPET = 320;
19
+
20
+ /**
21
+ * The manual's pages, in reading order rather than directory order.
22
+ *
23
+ * Written out because a listing would put `align` before `param`, which is not the order
24
+ * anyone learns this in. A page on disk that is missing from here is a failure the tests
25
+ * catch, so the list cannot silently fall behind the manual.
26
+ *
27
+ * @type {Array<{ path: string, title: string, about: string }>}
28
+ */
29
+ export const PAGES = [
30
+ { path: 'README.md', title: 'Reference index', about: 'Every construct, listed and linked' },
31
+ { path: 'language/overview.md', title: 'Document structure', about: 'The four top-level blocks, the pipeline, nesting, dimensionality' },
32
+ { path: 'language/syntax.md', title: 'Syntax', about: 'Comments, numbers, strings, identifiers, labels, the newline rule, the grammar' },
33
+ { path: 'language/expressions.md', title: 'Expressions', about: 'Values, operators, truthiness, interpolation, member access' },
34
+ { path: 'language/scope.md', title: 'Scope and names', about: 'What is in scope where, var., shadowing, component isolation' },
35
+ { path: 'language/control-flow.md', title: 'Control flow', about: 'for and if / else if / else' },
36
+ { path: 'reference/param.md', title: 'param', about: 'Inputs, their metadata, and what is advisory' },
37
+ { path: 'reference/local.md', title: 'local', about: 'Named values, ordering, and how they differ from params' },
38
+ { path: 'reference/component.md', title: 'component', about: 'Reusable parameterised geometry' },
39
+ { path: 'reference/model.md', title: 'model', about: 'Renderable scenes, model params, several models in one document' },
40
+ { path: 'reference/shapes-2d.md', title: '2D shapes', about: 'rect, rounded_rect, circle, ellipse, regular_polygon, stadium, polygon' },
41
+ { path: 'reference/shapes-3d.md', title: '3D shapes', about: 'box, sphere, cylinder, cone, torus' },
42
+ { path: 'reference/booleans.md', title: 'Booleans', about: 'union, difference, intersection' },
43
+ { path: 'reference/transforms.md', title: 'Transforms', about: 'translate, rotate, scale, mirror' },
44
+ { path: 'reference/conversions.md', title: '2D to 3D and back', about: 'extrude, revolve, project, slice' },
45
+ { path: 'reference/refinement.md', title: 'Refinement', about: 'offset, hull, refine, simplify, smooth, trim' },
46
+ { path: 'reference/align.md', title: 'align', about: 'Placing geometry by measuring its bounding box' },
47
+ { path: 'reference/part.md', title: 'part', about: 'Coloured pieces of the rendered scene' },
48
+ { path: 'reference/functions.md', title: 'Functions', about: 'Every builtin function and constant' },
49
+ { path: 'errors.md', title: 'Errors', about: 'Every error message, what raises it, and what to change' },
50
+ { path: 'api/render.md', title: 'render', about: 'JavaScript API: compile a document and evaluate a model' },
51
+ { path: 'api/describe-parameters.md', title: 'describeParameters', about: 'JavaScript API: read a document’s params without rendering' },
52
+ { path: 'api/evaluation-context.md', title: 'EvaluationContext', about: 'JavaScript API: the digest cache and WASM ownership' },
53
+ { path: 'api/geometry-node.md', title: 'GeometryNode', about: 'JavaScript API: the content-addressed geometry IR' },
54
+ { path: 'api/kernel.md', title: 'Kernel', about: 'JavaScript API: loadKernel, setQuality, resetQuality' },
55
+ { path: 'api/export.md', title: 'Export', about: 'JavaScript API: toRenderMesh and toBinarySTL' },
56
+ { path: 'api/values.md', title: 'Values', about: 'JavaScript API: Vector, Angle, Transform' },
57
+ { path: 'api/low-level.md', title: 'Low-level API', about: 'JavaScript API: Program, Evaluator, parse, tokenize, the registries' },
58
+ ];
59
+
60
+ /** The pages, by path, so a lookup does not scan the list. */
61
+ const BY_PATH = new Map(PAGES.map((page) => [page.path, page]));
62
+
63
+ /**
64
+ * Every page on disk, whether or not {@link PAGES} names it.
65
+ *
66
+ * Used by the tests to prove the two agree; nothing at runtime reads it.
67
+ *
68
+ * @returns {Promise<string[]>} Paths relative to the manual's root, sorted.
69
+ */
70
+ export async function discoverPages() {
71
+ /** @type {string[]} */
72
+ const found = [];
73
+ const walk = async (directory) => {
74
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
75
+ const child = join(directory, entry.name);
76
+ if (entry.isDirectory()) await walk(child);
77
+ else if (entry.name.endsWith('.md')) found.push(relative(ROOT, child));
78
+ }
79
+ };
80
+ await walk(ROOT);
81
+ return found.sort((a, b) => a.localeCompare(b));
82
+ }
83
+
84
+ /**
85
+ * Reads one page.
86
+ *
87
+ * @param {string} path The page, as {@link PAGES} spells it.
88
+ * @returns {Promise<{ path: string, title: string, markdown: string }>} The page.
89
+ * @throws {Error} If no such page exists.
90
+ */
91
+ export async function readPage(path) {
92
+ const normalised = path.replace(/^\.?\//, '');
93
+ const page = BY_PATH.get(normalised);
94
+ if (!page) {
95
+ throw new Error(
96
+ `no reference page "${path}" — the pages are ${PAGES.map((each) => each.path).join(', ')}`,
97
+ );
98
+ }
99
+ return { path: page.path, title: page.title, markdown: await readFile(join(ROOT, page.path), 'utf8') };
100
+ }
101
+
102
+ /**
103
+ * The heading a character offset falls under, so a hit can say where in the page it is.
104
+ *
105
+ * @param {string} markdown The page.
106
+ * @param {number} offset Where the match was found.
107
+ * @returns {string} The nearest heading above it, or the page's title.
108
+ */
109
+ function headingAt(markdown, offset) {
110
+ const before = markdown.slice(0, offset);
111
+ const headings = before.match(/^#{1,4} .*$/gm);
112
+ return headings?.length ? headings.at(-1).replace(/^#+ /, '') : '';
113
+ }
114
+
115
+ /**
116
+ * Searches the manual.
117
+ *
118
+ * Every term must appear somewhere in the page; pages are ranked by how often the terms
119
+ * occur, with a heading match worth more than a body one.
120
+ *
121
+ * @param {string} query What to look for.
122
+ * @param {number} [limit] How many hits to return.
123
+ * @returns {Promise<Array<{ path: string, title: string, heading: string, snippet: string }>>}
124
+ * The hits, best first.
125
+ */
126
+ export async function searchReference(query, limit = 6) {
127
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
128
+ if (!terms.length) return [];
129
+
130
+ /** @type {Array<{ score: number, path: string, title: string, heading: string, snippet: string }>} */
131
+ const hits = [];
132
+
133
+ for (const page of PAGES) {
134
+ const markdown = await readFile(join(ROOT, page.path), 'utf8');
135
+ const lower = markdown.toLowerCase();
136
+ if (!terms.every((term) => lower.includes(term))) continue;
137
+
138
+ const headings = (lower.match(/^#{1,4} .*$/gm) ?? []);
139
+ const score = terms.reduce((total, term) => {
140
+ const body = lower.split(term).length - 1;
141
+ const named = headings.filter((heading) => heading.includes(term)).length;
142
+ return total + body + named * 8;
143
+ }, 0);
144
+
145
+ const at = lower.indexOf(terms[0]);
146
+ const from = Math.max(0, at - SNIPPET / 4);
147
+ hits.push({
148
+ score,
149
+ path: page.path,
150
+ title: page.title,
151
+ heading: headingAt(markdown, at),
152
+ snippet: markdown.slice(from, from + SNIPPET).trim(),
153
+ });
154
+ }
155
+
156
+ return hits
157
+ .toSorted((a, b) => b.score - a.score)
158
+ .slice(0, limit)
159
+ .map(({ score: _score, ...hit }) => hit);
160
+ }