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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +52 -0
- package/src/bin.js +30 -0
- package/src/catalogue.js +703 -0
- package/src/document.js +285 -0
- package/src/examples.js +212 -0
- package/src/guide.js +313 -0
- package/src/reference/README.md +153 -0
- package/src/reference/api/describe-parameters.md +167 -0
- package/src/reference/api/evaluation-context.md +221 -0
- package/src/reference/api/export.md +185 -0
- package/src/reference/api/geometry-node.md +215 -0
- package/src/reference/api/kernel.md +182 -0
- package/src/reference/api/low-level.md +239 -0
- package/src/reference/api/render.md +217 -0
- package/src/reference/api/values.md +223 -0
- package/src/reference/errors.md +253 -0
- package/src/reference/language/control-flow.md +160 -0
- package/src/reference/language/expressions.md +206 -0
- package/src/reference/language/overview.md +156 -0
- package/src/reference/language/scope.md +184 -0
- package/src/reference/language/syntax.md +210 -0
- package/src/reference/reference/align.md +189 -0
- package/src/reference/reference/booleans.md +231 -0
- package/src/reference/reference/component.md +248 -0
- package/src/reference/reference/conversions.md +269 -0
- package/src/reference/reference/functions.md +314 -0
- package/src/reference/reference/local.md +197 -0
- package/src/reference/reference/model.md +213 -0
- package/src/reference/reference/param.md +230 -0
- package/src/reference/reference/part.md +229 -0
- package/src/reference/reference/refinement.md +284 -0
- package/src/reference/reference/shapes-2d.md +309 -0
- package/src/reference/reference/shapes-3d.md +279 -0
- package/src/reference/reference/transforms.md +246 -0
- package/src/reference.js +160 -0
- package/src/server.js +219 -0
- package/src/tools.js +523 -0
- package/src/workspace.js +204 -0
package/src/catalogue.js
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server knows about every construct in the language.
|
|
3
|
+
*
|
|
4
|
+
* The descriptions and the attribute tables are written here; the structural facts — which
|
|
5
|
+
* blocks exist, what dimensionality each produces and takes, which functions exist and with
|
|
6
|
+
* what arity — are read from the package's own registries at load time and merged in. Where
|
|
7
|
+
* the two disagree, the registry wins and `catalogueDrift` says so, which is what
|
|
8
|
+
* `test/catalogue.test.js` asserts is empty. A block added to the language therefore cannot
|
|
9
|
+
* ship without this file learning about it.
|
|
10
|
+
*/
|
|
11
|
+
/** @import { BlockDefinition } from 'forma-dsl' */
|
|
12
|
+
|
|
13
|
+
import { BLOCKS, CONSTANTS, FUNCTIONS } from 'forma-dsl';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} Attribute
|
|
17
|
+
* @property {string} name How it is written.
|
|
18
|
+
* @property {string} type What kind of value it takes.
|
|
19
|
+
* @property {string} [default] The value used when it is not given, or `required`.
|
|
20
|
+
* @property {string[]} [values] The accepted values, for an enum.
|
|
21
|
+
* @property {string} description What it does.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Entry
|
|
26
|
+
* @property {string} name The block's name.
|
|
27
|
+
* @property {string} group Which section of the language it belongs to.
|
|
28
|
+
* @property {string} summary One line, for a listing.
|
|
29
|
+
* @property {Attribute[]} attributes What it accepts.
|
|
30
|
+
* @property {string} example A snippet that renders.
|
|
31
|
+
* @property {string[]} [notes] The things that surprise people.
|
|
32
|
+
* @property {2 | 3} [dim] Output dimensionality, where it is fixed.
|
|
33
|
+
* @property {2 | 3 | 'same'} [takes] What its children must be.
|
|
34
|
+
* @property {boolean} [leaf] True for a shape, which takes no children.
|
|
35
|
+
* @property {boolean} [ordered] True where the order of children changes the result.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** A vector attribute sized by whatever the children are. */
|
|
39
|
+
const SAME_VECTOR = 'vector matching the children (2 numbers in 2D, 3 in 3D)';
|
|
40
|
+
|
|
41
|
+
/** The `center` attribute, which several shapes share verbatim. */
|
|
42
|
+
const CENTER = {
|
|
43
|
+
name: 'center', type: 'bool', default: 'false',
|
|
44
|
+
description: 'true centres the shape on the origin; otherwise its minimum corner sits there',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The `segments` attribute, which every curved shape shares verbatim. */
|
|
48
|
+
const SEGMENTS = {
|
|
49
|
+
name: 'segments', type: 'integer', default: '0',
|
|
50
|
+
description: 'Straight edges used to approximate the curve; 0 lets the kernel decide',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every block, described.
|
|
55
|
+
*
|
|
56
|
+
* @type {Entry[]}
|
|
57
|
+
*/
|
|
58
|
+
const BLOCK_ENTRIES = [
|
|
59
|
+
// --- 2D shapes ------------------------------------------------------------------------
|
|
60
|
+
{
|
|
61
|
+
name: 'rect', group: '2D shapes', summary: 'An axis-aligned rectangle.',
|
|
62
|
+
attributes: [
|
|
63
|
+
{ name: 'size', type: '2-vector', default: 'required', description: 'Width and height' },
|
|
64
|
+
CENTER,
|
|
65
|
+
],
|
|
66
|
+
example: 'rect { size = [40, 20] center = true }',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'rounded_rect', group: '2D shapes',
|
|
70
|
+
summary: 'A rectangle with true arcs at the corners.',
|
|
71
|
+
attributes: [
|
|
72
|
+
{
|
|
73
|
+
name: 'size', type: '2-vector', default: 'required',
|
|
74
|
+
description: 'Width and height, measured over the rounded corners',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'radius', type: 'number', default: '1',
|
|
78
|
+
description: 'Corner radius, clamped to half the shorter side so it cannot invert',
|
|
79
|
+
},
|
|
80
|
+
{ ...SEGMENTS, description: 'Segments per corner arc; 0 lets the kernel decide' },
|
|
81
|
+
CENTER,
|
|
82
|
+
],
|
|
83
|
+
example: 'rounded_rect { size = [62, 13] radius = 6.5 center = true }',
|
|
84
|
+
notes: ['`rounded_rect { size = [10, 10] radius = 5 }` is a circle of diameter 10 — `size` is the outer size.'],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'circle', group: '2D shapes', summary: 'A circle, from a radius or a diameter.',
|
|
88
|
+
attributes: [
|
|
89
|
+
{ name: 'radius', type: 'number', default: '1', description: 'Centre to edge' },
|
|
90
|
+
{ name: 'diameter', type: 'number', description: 'An alternative spelling of the same thing' },
|
|
91
|
+
SEGMENTS,
|
|
92
|
+
],
|
|
93
|
+
example: 'circle { diameter = 24 segments = 64 }',
|
|
94
|
+
notes: [
|
|
95
|
+
'Give `radius` or `diameter`, never both.',
|
|
96
|
+
'Always centred; there is no `center` attribute.',
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'ellipse', group: '2D shapes', summary: 'A circle scaled to two exact radii.',
|
|
101
|
+
attributes: [
|
|
102
|
+
{ name: 'radii', type: '2-vector', default: 'required', description: 'Semi-axes along X and Y' },
|
|
103
|
+
{ ...SEGMENTS, description: 'Segments around the unit circle before it is scaled' },
|
|
104
|
+
],
|
|
105
|
+
example: 'ellipse { radii = [30, 18] }',
|
|
106
|
+
notes: ['Always centred; there is no `center` attribute.'],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'regular_polygon', group: '2D shapes',
|
|
110
|
+
summary: 'An n-gon, sized across flats, across corners, or by radius.',
|
|
111
|
+
attributes: [
|
|
112
|
+
{ name: 'sides', type: 'integer', default: 'required', description: 'At least 3' },
|
|
113
|
+
{
|
|
114
|
+
name: 'width_across_flats', type: 'number',
|
|
115
|
+
description: 'Distance between opposite flats — how a nut or a hex key is specified',
|
|
116
|
+
},
|
|
117
|
+
{ name: 'width_across_corners', type: 'number', description: 'Distance between opposite corners' },
|
|
118
|
+
{ name: 'radius', type: 'number', default: '1', description: 'Centre to corner' },
|
|
119
|
+
{ name: 'diameter', type: 'number', description: 'Twice the radius' },
|
|
120
|
+
{
|
|
121
|
+
name: 'rotation', type: 'angle', default: '0',
|
|
122
|
+
description: 'Where the first corner sits, in degrees',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
example: 'regular_polygon { sides = 6 width_across_flats = 5.5 }',
|
|
126
|
+
notes: ['A hexagon with no `rotation` has a corner on +X, not a flat. Add `rotation = 30` to turn a flat to the front.'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'stadium', group: '2D shapes',
|
|
130
|
+
summary: 'A rectangle fully rounded on its short axis.',
|
|
131
|
+
attributes: [
|
|
132
|
+
{
|
|
133
|
+
name: 'size', type: '2-vector', default: 'required',
|
|
134
|
+
description: 'Width and height, over the rounded ends',
|
|
135
|
+
},
|
|
136
|
+
{ ...SEGMENTS, description: 'Segments per end arc' },
|
|
137
|
+
CENTER,
|
|
138
|
+
],
|
|
139
|
+
example: 'stadium { size = [30, 6] center = true }',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'polygon', group: '2D shapes',
|
|
143
|
+
summary: 'An explicit outline, or several contours with holes.',
|
|
144
|
+
attributes: [
|
|
145
|
+
{
|
|
146
|
+
name: 'points', type: 'list', default: 'required',
|
|
147
|
+
description: 'A list of [x, y] points, or a list of such lists for several contours',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'fill_rule', type: 'enum', default: '"NonZero"',
|
|
151
|
+
values: ['EvenOdd', 'NonZero', 'Positive', 'Negative'],
|
|
152
|
+
description: 'How overlapping contours are resolved',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
example: 'polygon { points = [[0, 0], [26, 0], [24, 6], [0, 74]] }',
|
|
156
|
+
notes: ['At least 3 points. A nested list is read as several contours, which is how a hole is written without a `difference`.'],
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// --- 3D shapes ------------------------------------------------------------------------
|
|
160
|
+
{
|
|
161
|
+
name: 'box', group: '3D shapes', summary: 'An axis-aligned cuboid.',
|
|
162
|
+
attributes: [
|
|
163
|
+
{ name: 'size', type: '3-vector', default: 'required', description: 'X, Y and Z extents' },
|
|
164
|
+
CENTER,
|
|
165
|
+
],
|
|
166
|
+
example: 'box { size = [40, 20, 10] center = true }',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'sphere', group: '3D shapes', summary: 'A sphere, centred on the origin.',
|
|
170
|
+
attributes: [
|
|
171
|
+
{ name: 'radius', type: 'number', default: '1', description: 'Centre to surface' },
|
|
172
|
+
{ name: 'diameter', type: 'number', description: 'An alternative spelling of the same thing' },
|
|
173
|
+
{ ...SEGMENTS, description: 'Segments around the equator; 0 lets the kernel decide' },
|
|
174
|
+
],
|
|
175
|
+
example: 'sphere { radius = 12 segments = 48 }',
|
|
176
|
+
notes: ['Give `radius` or `diameter`, never both.'],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'cylinder', group: '3D shapes', summary: 'A cylinder, or a frustum from two radii.',
|
|
180
|
+
attributes: [
|
|
181
|
+
{ name: 'height', type: 'number', default: 'required', description: 'Along Z' },
|
|
182
|
+
{ name: 'radius', type: 'number', description: 'Both ends alike' },
|
|
183
|
+
{ name: 'diameter', type: 'number', description: 'An alternative spelling of `radius`' },
|
|
184
|
+
{ name: 'bottom_radius', type: 'number', description: 'The end at Z = 0' },
|
|
185
|
+
{ name: 'top_radius', type: 'number', description: 'The end at Z = height' },
|
|
186
|
+
SEGMENTS,
|
|
187
|
+
{
|
|
188
|
+
...CENTER,
|
|
189
|
+
description: 'true centres it on Z = 0; otherwise it stands on the XY plane',
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
example: 'cylinder { radius = 5 height = 20 }',
|
|
193
|
+
notes: ['Give `radius`/`diameter`, or both `bottom_radius` and `top_radius`. One end radius on its own is an error.'],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'cone', group: '3D shapes', summary: 'A cone, or a truncated cone.',
|
|
197
|
+
attributes: [
|
|
198
|
+
{ name: 'height', type: 'number', default: 'required', description: 'Along Z' },
|
|
199
|
+
{ name: 'radius', type: 'number', default: '1', description: 'The base, at Z = 0' },
|
|
200
|
+
{ name: 'diameter', type: 'number', description: 'An alternative spelling of `radius`' },
|
|
201
|
+
{ name: 'top_radius', type: 'number', default: '0', description: '0 gives a point' },
|
|
202
|
+
SEGMENTS,
|
|
203
|
+
{ ...CENTER, description: 'As for `cylinder`' },
|
|
204
|
+
],
|
|
205
|
+
example: 'cone { radius = 10 height = 25 }',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'torus', group: '3D shapes', summary: 'A ring, optionally a partial arc.',
|
|
209
|
+
attributes: [
|
|
210
|
+
{
|
|
211
|
+
name: 'radius', type: 'number', default: 'required',
|
|
212
|
+
description: 'Centre of the ring to centre of the tube',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'tube_radius', type: 'number', default: 'required',
|
|
216
|
+
description: 'Radius of the tube itself',
|
|
217
|
+
},
|
|
218
|
+
{ name: 'angle', type: 'angle', default: '360', description: 'How far round to sweep, in degrees' },
|
|
219
|
+
{ ...SEGMENTS, description: 'Segments around the ring' },
|
|
220
|
+
{
|
|
221
|
+
name: 'tube_segments', type: 'integer', default: '0',
|
|
222
|
+
description: "Segments around the tube's cross-section",
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
example: 'torus { radius = 20 tube_radius = 4 }',
|
|
226
|
+
notes: ['The outer radius of the result is `radius + tube_radius`.'],
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
// --- booleans -------------------------------------------------------------------------
|
|
230
|
+
{
|
|
231
|
+
name: 'union', group: 'Booleans', summary: 'Everything added together.',
|
|
232
|
+
attributes: [],
|
|
233
|
+
example: 'union { box { size = [10, 10, 10] } sphere { radius = 7 } }',
|
|
234
|
+
notes: ['Rarely needed: several children in a row are unioned anyway. Write it when the grouping makes the document read better.'],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'difference', group: 'Booleans', ordered: true,
|
|
238
|
+
summary: 'The first child, with every later child cut out of it.',
|
|
239
|
+
attributes: [],
|
|
240
|
+
example: 'difference {\n box { size = [20, 20, 10] }\n cylinder { radius = 5 height = 30 }\n}',
|
|
241
|
+
notes: [
|
|
242
|
+
'The only block where the order of children matters.',
|
|
243
|
+
'A cutter should overshoot the body at both ends, or the coincident faces make a zero-thickness skin.',
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'intersection', group: 'Booleans', summary: 'Only what every child shares.',
|
|
248
|
+
attributes: [],
|
|
249
|
+
example: 'intersection { sphere { radius = 10 } box { size = [14, 14, 14] center = true } }',
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// --- transforms -----------------------------------------------------------------------
|
|
253
|
+
{
|
|
254
|
+
name: 'translate', group: 'Transforms', summary: 'Moves geometry.',
|
|
255
|
+
attributes: [
|
|
256
|
+
{
|
|
257
|
+
name: 'offset', type: SAME_VECTOR, default: 'required',
|
|
258
|
+
description: 'How far to move along each axis',
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
example: 'translate { offset = [0, 0, 10] box { size = [4, 4, 4] } }',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'rotate', group: 'Transforms', summary: 'Turns geometry about the origin.',
|
|
265
|
+
attributes: [
|
|
266
|
+
{ name: 'angles', type: '3-vector', description: '3D only. Degrees about X, Y and Z at once' },
|
|
267
|
+
{ name: 'x', type: 'number', default: '0', description: '3D only. Degrees about X' },
|
|
268
|
+
{ name: 'y', type: 'number', default: '0', description: '3D only. Degrees about Y' },
|
|
269
|
+
{ name: 'z', type: 'number', default: '0', description: '3D only. Degrees about Z' },
|
|
270
|
+
{
|
|
271
|
+
name: 'angle', type: 'angle', default: 'required',
|
|
272
|
+
description: '2D only. Degrees, counter-clockwise',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
example: 'rotate { z = 45 box { size = [20, 4, 4] } }',
|
|
276
|
+
notes: [
|
|
277
|
+
'3D rotations apply X, then Y, then Z, matching OpenSCAD.',
|
|
278
|
+
'Rotation is about the origin, not about the shape. Centre it first, or translate afterwards.',
|
|
279
|
+
'`angle` is the 2D spelling and `x`/`y`/`z`/`angles` are the 3D ones. Using the wrong one is `rotate: unknown attribute`.',
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'scale', group: 'Transforms', summary: 'Scales geometry about the origin.',
|
|
284
|
+
attributes: [
|
|
285
|
+
{
|
|
286
|
+
name: 'factor', type: SAME_VECTOR, default: 'required',
|
|
287
|
+
description: 'Multiplier per axis. A single number scales every axis alike',
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
example: 'scale { factor = [1, 1, 2] sphere { radius = 10 } }',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'mirror', group: 'Transforms',
|
|
294
|
+
summary: 'Reflects geometry in a plane through the origin.',
|
|
295
|
+
attributes: [
|
|
296
|
+
{
|
|
297
|
+
name: 'normal', type: SAME_VECTOR, default: 'required',
|
|
298
|
+
description: 'Normal of the mirror plane; normalised first, so its length does not matter',
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
example: 'mirror { normal = [1, 0, 0] bracket { } }',
|
|
302
|
+
notes: ['A mirror replaces the geometry rather than adding to it. For a symmetric pair, union the original with the mirrored copy.'],
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// --- 2D <-> 3D ------------------------------------------------------------------------
|
|
306
|
+
{
|
|
307
|
+
name: 'extrude', group: '2D to 3D',
|
|
308
|
+
summary: 'Sweeps a 2D profile along +Z, with optional twist and taper.',
|
|
309
|
+
attributes: [
|
|
310
|
+
{ name: 'height', type: 'number', default: 'required', description: 'How far to sweep, along +Z' },
|
|
311
|
+
{ name: 'twist', type: 'angle', default: '0', description: 'Degrees of rotation from bottom to top' },
|
|
312
|
+
{
|
|
313
|
+
name: 'scale_top', type: '2-vector', default: '1',
|
|
314
|
+
description: 'Size of the top face relative to the bottom; a single number scales both axes',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'divisions', type: 'integer', default: '0',
|
|
318
|
+
description: 'Intermediate layers, needed for a smooth twist or taper',
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
...CENTER,
|
|
322
|
+
description: 'true centres the result on Z = 0; otherwise it stands on the XY plane',
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
example: 'extrude { height = 20 rounded_rect { size = [40, 20] radius = 4 center = true } }',
|
|
326
|
+
notes: ['`height = 0` produces empty geometry rather than an error.'],
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: 'revolve', group: '2D to 3D',
|
|
330
|
+
summary: 'Turns a 2D profile about the Y axis into a solid of revolution.',
|
|
331
|
+
attributes: [
|
|
332
|
+
{ name: 'angle', type: 'angle', default: '360', description: 'How far round to sweep, in degrees' },
|
|
333
|
+
{ ...SEGMENTS, description: 'Segments around; 0 lets the kernel decide' },
|
|
334
|
+
],
|
|
335
|
+
example: 'revolve { polygon { points = [[0, 0], [26, 0], [24, 6], [0, 74]] } }',
|
|
336
|
+
notes: [
|
|
337
|
+
"The profile is written in the XY plane and its Y becomes the result's Z, so a profile drawn upwards from [0, 0] stands on the XY plane.",
|
|
338
|
+
'A profile that crosses X = 0 produces a spike through the middle. Keep every point at X ≥ 0.',
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: 'project', group: '3D to 2D', summary: 'The shadow of a solid on the XY plane.',
|
|
343
|
+
attributes: [],
|
|
344
|
+
example: 'extrude { height = 2 project { bracket { } } }',
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: 'slice', group: '3D to 2D', summary: 'The cross-section of a solid at one Z.',
|
|
348
|
+
attributes: [{ name: 'z', type: 'number', default: '0', description: 'The plane to cut at' }],
|
|
349
|
+
example: 'slice { z = 12 bracket { } }',
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// --- refinement -----------------------------------------------------------------------
|
|
353
|
+
{
|
|
354
|
+
name: 'offset', group: 'Refinement', summary: 'Grows or shrinks a 2D outline.',
|
|
355
|
+
attributes: [
|
|
356
|
+
{ name: 'amount', type: 'number', default: 'required', description: 'Positive grows, negative shrinks' },
|
|
357
|
+
{
|
|
358
|
+
name: 'join', type: 'enum', default: '"Round"', values: ['Square', 'Round', 'Miter'],
|
|
359
|
+
description: 'How convex corners are treated',
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: 'miter_limit', type: 'number', default: '2',
|
|
363
|
+
description: 'How far a mitred corner may extend before it is squared off',
|
|
364
|
+
},
|
|
365
|
+
{ ...SEGMENTS, description: 'Segments per rounded corner' },
|
|
366
|
+
],
|
|
367
|
+
example: 'extrude { height = 3 offset { amount = 2 rect { size = [40, 20] } } }',
|
|
368
|
+
notes: ['2D only. To shell a solid, offset the profile and extrude, or subtract a scaled copy.'],
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'hull', group: 'Refinement', summary: 'The convex hull of everything inside.',
|
|
372
|
+
attributes: [],
|
|
373
|
+
example: 'hull {\n cylinder { radius = 5 height = 4 }\n translate { offset = [40, 0, 0] cylinder { radius = 8 height = 4 } }\n}',
|
|
374
|
+
notes: ['A hull has no concavities, so it swallows holes. Cut them afterwards.'],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: 'refine', group: 'Refinement', summary: 'Subdivides edges longer than a limit.',
|
|
378
|
+
attributes: [
|
|
379
|
+
{
|
|
380
|
+
name: 'edge_length', type: 'number', default: 'required',
|
|
381
|
+
description: 'No edge longer than this survives',
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
example: 'smooth { min_sharp_angle = 40 refine { edge_length = 1 bracket { } } }',
|
|
385
|
+
notes: ['Triangle count grows with the square of the reduction. Refine the smallest piece that needs it.'],
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'simplify', group: 'Refinement', summary: 'Removes vertices within a tolerance.',
|
|
389
|
+
attributes: [
|
|
390
|
+
{ name: 'tolerance', type: 'number', default: '1e-6', description: 'How far a vertex may move' },
|
|
391
|
+
],
|
|
392
|
+
example: 'simplify { tolerance = 0.05 shell { } }',
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: 'smooth', group: 'Refinement', summary: 'Rounds edges below a sharpness threshold.',
|
|
396
|
+
attributes: [
|
|
397
|
+
{
|
|
398
|
+
name: 'min_sharp_angle', type: 'number', default: '60',
|
|
399
|
+
description: 'Degrees. Edges sharper than this stay sharp',
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'min_smoothness', type: 'number', default: '0',
|
|
403
|
+
description: 'How strongly to smooth, from 0 to 1',
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
example: 'smooth { min_sharp_angle = 40 refine { edge_length = 1 body { } } }',
|
|
407
|
+
notes: ['Smoothing moves existing vertices, so it does nothing on a solid with no vertices to move. `refine` first.'],
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: 'trim', group: 'Refinement', summary: 'Cuts a solid with a half-space.',
|
|
411
|
+
attributes: [
|
|
412
|
+
{
|
|
413
|
+
name: 'normal', type: '3-vector', default: 'required',
|
|
414
|
+
description: "The plane's normal; everything on the normal's side is removed",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'offset', type: 'number', default: '0',
|
|
418
|
+
description: 'How far the plane sits along the normal from the origin',
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
example: 'trim { normal = [0, 0, 1] offset = 10 sphere { radius = 15 } }',
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
// --- align ----------------------------------------------------------------------------
|
|
425
|
+
{
|
|
426
|
+
name: 'align', group: 'Placement',
|
|
427
|
+
summary: 'Moves geometry so a measured feature of its bounding box lands where you say.',
|
|
428
|
+
takes: 'same',
|
|
429
|
+
attributes: [
|
|
430
|
+
{
|
|
431
|
+
name: 'x', type: '"min" | "center" | "max" | number', description: 'What to do along X',
|
|
432
|
+
},
|
|
433
|
+
{ name: 'y', type: '"min" | "center" | "max" | number', description: 'Along Y' },
|
|
434
|
+
{ name: 'z', type: '"min" | "center" | "max" | number', description: 'Along Z; 3D children only' },
|
|
435
|
+
],
|
|
436
|
+
example: 'align { x = "center" y = "center" z = 0 composed_thing { } }',
|
|
437
|
+
notes: [
|
|
438
|
+
'The keywords put a named feature at 0; a number puts the *minimum* face at that coordinate. `z = 5` means "sit this on a surface at height 5".',
|
|
439
|
+
'The only block that measures, so it costs a kernel round-trip. Prefer `center = true` on a single shape.',
|
|
440
|
+
'An axis that is not given is left alone. The spelling is `"center"`.',
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
// --- scene ----------------------------------------------------------------------------
|
|
445
|
+
{
|
|
446
|
+
name: 'part', group: 'Scene',
|
|
447
|
+
summary: 'A separately coloured piece of the rendered scene. Belongs directly in a model.',
|
|
448
|
+
takes: 3,
|
|
449
|
+
attributes: [
|
|
450
|
+
{ name: 'color', type: 'string', default: '"#b8c4d0"', description: 'Any CSS colour; usually #rrggbb' },
|
|
451
|
+
{
|
|
452
|
+
name: 'opacity', type: 'number', default: '1',
|
|
453
|
+
description: 'Display only; 0 is invisible, 1 is opaque',
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
example: 'part "body" { color = "#6f7d8c" box { size = [40, 20, 10] } }',
|
|
457
|
+
notes: [
|
|
458
|
+
'The label is an expression, so a part in a loop can name itself: `part "leg_${i}"`.',
|
|
459
|
+
'A part inside a transform or a boolean is silently dropped. Put the transform inside the part.',
|
|
460
|
+
'A part cannot contain another part, cannot be empty, and cannot come out of a component.',
|
|
461
|
+
'Every part must be 3D by the time it renders.',
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The four top-level declarations, which are grammar rather than blocks and so are not in
|
|
468
|
+
* the block registry.
|
|
469
|
+
*
|
|
470
|
+
* @type {Entry[]}
|
|
471
|
+
*/
|
|
472
|
+
const DECLARATIONS = [
|
|
473
|
+
{
|
|
474
|
+
name: 'param', group: 'Declarations',
|
|
475
|
+
summary: 'A declared input, read as `var.NAME`. What the editor turns into a control.',
|
|
476
|
+
attributes: [
|
|
477
|
+
{ name: 'default', type: 'any', description: 'The value used when the caller supplies none. Without it the param is required' },
|
|
478
|
+
{
|
|
479
|
+
name: 'type', type: 'enum', values: ['number', 'string', 'bool', 'vector', 'list', 'angle'],
|
|
480
|
+
description: 'Which control to build. Written bare, without quotes. Inferred from `default` when omitted',
|
|
481
|
+
},
|
|
482
|
+
{ name: 'min', type: 'number', description: 'Lower bound of the control. Advisory — never enforced' },
|
|
483
|
+
{ name: 'max', type: 'number', description: 'Upper bound of the control. Advisory — never enforced' },
|
|
484
|
+
{ name: 'step', type: 'number', description: 'Granularity of the control. Advisory' },
|
|
485
|
+
{ name: 'description', type: 'string', description: 'Help text' },
|
|
486
|
+
{ name: 'options', type: 'list', description: 'A fixed set of choices, for a dropdown. Advisory' },
|
|
487
|
+
],
|
|
488
|
+
example: 'param height { type = number default = 20 min = 8 max = 40 step = 1 }\nparam wall = 2.5',
|
|
489
|
+
notes: [
|
|
490
|
+
'This is the one block that does not reject attributes it does not understand — a param block is metadata.',
|
|
491
|
+
'`min`, `max`, `step` and `options` shape the editor control and nothing else. Clamp in the model if a bound matters.',
|
|
492
|
+
"A default may refer to params declared above it by their bare name, but never to `var` — `var` is assembled after every param resolves.",
|
|
493
|
+
'A param declared inside a `model` must have a default and cannot be set from outside.',
|
|
494
|
+
'`param s` on its own is a syntax error; write `param s { }` for a required one.',
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: 'local', group: 'Declarations', summary: 'A named value, computed once.',
|
|
499
|
+
attributes: [],
|
|
500
|
+
example: 'local sizes = [1.5, 2, 2.5, 3]\nlocal radius = var.diameter / 2',
|
|
501
|
+
notes: [
|
|
502
|
+
'Top-level locals are resolved strictly in source order; a local inside a body is not ordered.',
|
|
503
|
+
'Locals hold values, never geometry. To name geometry, declare a `component`.',
|
|
504
|
+
'Locals are not in `var`. Read one by its bare name.',
|
|
505
|
+
'A duplicate local silently replaces the first, everywhere.',
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: 'component', group: 'Declarations',
|
|
510
|
+
summary: 'A reusable parameterised block, callable exactly like a builtin.',
|
|
511
|
+
attributes: [],
|
|
512
|
+
example: 'component "rounded_box" {\n param size = [20, 20, 10]\n param radius = 3\n extrude {\n height = size.z\n rounded_rect { size = [size.x, size.y] radius = radius center = true }\n }\n}',
|
|
513
|
+
notes: [
|
|
514
|
+
'Params are read by their bare name inside the body; there is no `var.` for them.',
|
|
515
|
+
'Only declared params may be passed. There is no pass-through.',
|
|
516
|
+
'A component body cannot see the call site — it is evaluated in a fresh scope rooted at the document.',
|
|
517
|
+
'A component cannot declare parts, cannot shadow a builtin, and cannot recurse.',
|
|
518
|
+
'Reuse is free: the same call with the same arguments is one node in the tree, evaluated once.',
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: 'model', group: 'Declarations',
|
|
523
|
+
summary: 'Something to render. A document may declare several; `render` builds one.',
|
|
524
|
+
attributes: [],
|
|
525
|
+
example: 'model "riser" {\n part "body" {\n color = "#6f7d8c"\n box { size = [40, 20, 10] }\n }\n}',
|
|
526
|
+
notes: [
|
|
527
|
+
'With no name, the first model in the document is built.',
|
|
528
|
+
'Geometry written outside any part becomes one default part named after the model.',
|
|
529
|
+
'A model must produce geometry, and every part must be 3D.',
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The two control-flow constructs, which appear where a block appears.
|
|
536
|
+
*
|
|
537
|
+
* @type {Entry[]}
|
|
538
|
+
*/
|
|
539
|
+
const CONTROL_FLOW = [
|
|
540
|
+
{
|
|
541
|
+
name: 'for', group: 'Control flow', summary: 'Repeats a body once per item in a list.',
|
|
542
|
+
attributes: [],
|
|
543
|
+
example: 'for i, size in sizes {\n translate { offset = [i * 8, 0, 0] cylinder { radius = size / 2 height = 10 } }\n}',
|
|
544
|
+
notes: [
|
|
545
|
+
'One name binds the value; two bind the index and then the value.',
|
|
546
|
+
'Everything every iteration produces is handed to the enclosing block.',
|
|
547
|
+
'The sequence must be a list. `range(a, b)` excludes `b`, and is empty rather than infinite when it runs backwards.',
|
|
548
|
+
'There is no break, no continue and no accumulator.',
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'if', group: 'Control flow', summary: 'Includes a body only when a condition holds.',
|
|
553
|
+
attributes: [],
|
|
554
|
+
example: 'if var.reinforced {\n box { size = [40, 40, 4] }\n} else {\n box { size = [40, 40, 2] }\n}',
|
|
555
|
+
notes: [
|
|
556
|
+
'Judged by truthiness: `0`, `""`, `[]`, `null` and `false` are falsy.',
|
|
557
|
+
'`else if` chains. An `if` that takes no branch produces nothing, which is an error where something was required.',
|
|
558
|
+
'Not an expression. For a value that depends on a condition, use `cond ? a : b`.',
|
|
559
|
+
],
|
|
560
|
+
},
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Merges the structural facts from the block registry into the described entries.
|
|
565
|
+
*
|
|
566
|
+
* @param {Entry} entry The described entry.
|
|
567
|
+
* @returns {Entry} The entry, with `dim`, `takes` and `leaf` taken from the registry.
|
|
568
|
+
*/
|
|
569
|
+
function withRegistry(entry) {
|
|
570
|
+
/** @type {BlockDefinition | undefined} */
|
|
571
|
+
const definition = BLOCKS[entry.name];
|
|
572
|
+
if (!definition) return entry;
|
|
573
|
+
return {
|
|
574
|
+
...entry,
|
|
575
|
+
...(definition.dim ? { dim: definition.dim } : {}),
|
|
576
|
+
...(definition.takes ? { takes: definition.takes } : {}),
|
|
577
|
+
...(definition.leaf ? { leaf: true } : {}),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Every construct the language has, by name. */
|
|
582
|
+
export const CONSTRUCTS = new Map(
|
|
583
|
+
[...DECLARATIONS, ...CONTROL_FLOW, ...BLOCK_ENTRIES.map(withRegistry)]
|
|
584
|
+
.map((entry) => [entry.name, entry]),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* What each builtin function is for. The arity comes from the registry.
|
|
589
|
+
*
|
|
590
|
+
* @type {Record<string, string>}
|
|
591
|
+
*/
|
|
592
|
+
const FUNCTION_DESCRIPTIONS = {
|
|
593
|
+
abs: 'Magnitude, discarding the sign',
|
|
594
|
+
floor: 'Rounds towards −∞',
|
|
595
|
+
ceil: 'Rounds towards +∞',
|
|
596
|
+
round: 'Rounds to the nearest integer, halves upwards',
|
|
597
|
+
sqrt: 'Square root. A negative input gives NaN, which fails where it is used',
|
|
598
|
+
sign: '-1, 0 or 1',
|
|
599
|
+
pow: 'pow(x, y) — x raised to y',
|
|
600
|
+
atan2: 'atan2(y, x) — degrees, in the correct quadrant',
|
|
601
|
+
log: 'Natural logarithm',
|
|
602
|
+
exp: 'e raised to x',
|
|
603
|
+
sin: 'Takes degrees, returns a ratio',
|
|
604
|
+
cos: 'Takes degrees, returns a ratio',
|
|
605
|
+
tan: 'Takes degrees, returns a ratio',
|
|
606
|
+
asin: 'Takes a ratio, returns degrees',
|
|
607
|
+
acos: 'Takes a ratio, returns degrees',
|
|
608
|
+
atan: 'Takes a ratio, returns degrees',
|
|
609
|
+
min: 'The smallest value. Flattens, so min(list) and min(a, b) both work',
|
|
610
|
+
max: 'The largest value. Flattens, so max(list) and max(a, b) both work',
|
|
611
|
+
sum: 'Everything added together; 0 for no arguments',
|
|
612
|
+
len: 'Length of a list or a string; 0 for anything else',
|
|
613
|
+
concat: 'Every argument flattened into one list',
|
|
614
|
+
reverse: 'A list, back to front',
|
|
615
|
+
contains: 'contains(list, value) — whether the list holds it',
|
|
616
|
+
join: 'join(separator, list) — the items as one string',
|
|
617
|
+
str: 'The value as a string',
|
|
618
|
+
num: 'The value as a number; non-numeric text gives NaN',
|
|
619
|
+
range: 'range(end), range(start, end) or range(start, end, step). Excludes the end; capped at 100000 values',
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/** Every builtin function, with its arity and what it does. */
|
|
623
|
+
export const LANGUAGE_FUNCTIONS = Object.keys(FUNCTIONS)
|
|
624
|
+
.sort((a, b) => a.localeCompare(b))
|
|
625
|
+
.map((name) => ({
|
|
626
|
+
name,
|
|
627
|
+
arity: FUNCTIONS[name].arity === null ? 'variadic' : FUNCTIONS[name].arity,
|
|
628
|
+
description: FUNCTION_DESCRIPTIONS[name] ?? '',
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
/** Every named constant, with its value. */
|
|
632
|
+
export const LANGUAGE_CONSTANTS = Object.entries(CONSTANTS)
|
|
633
|
+
.map(([name, value]) => ({ name, value }));
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Where the descriptions here and the registries in the package disagree.
|
|
637
|
+
*
|
|
638
|
+
* A block gained, lost or renamed in the library shows up here rather than as a confidently
|
|
639
|
+
* wrong answer to a caller. The test asserts this is empty, so the two cannot drift apart
|
|
640
|
+
* without the build saying so.
|
|
641
|
+
*
|
|
642
|
+
* @returns {string[]} One line per disagreement; empty when they agree.
|
|
643
|
+
*/
|
|
644
|
+
export function catalogueDrift() {
|
|
645
|
+
/** @type {string[]} */
|
|
646
|
+
const drift = [];
|
|
647
|
+
|
|
648
|
+
const described = new Set(BLOCK_ENTRIES.map((entry) => entry.name));
|
|
649
|
+
for (const name of Object.keys(BLOCKS)) {
|
|
650
|
+
if (!described.has(name)) drift.push(`block "${name}" exists in the library but is not described here`);
|
|
651
|
+
}
|
|
652
|
+
for (const name of described) {
|
|
653
|
+
// `align` and `part` are evaluator constructs rather than registry entries, and are
|
|
654
|
+
// described here because a caller has no way to tell the difference.
|
|
655
|
+
if (!BLOCKS[name] && !['align', 'part'].includes(name)) {
|
|
656
|
+
drift.push(`block "${name}" is described here but no longer exists in the library`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
for (const name of Object.keys(FUNCTIONS)) {
|
|
661
|
+
if (!FUNCTION_DESCRIPTIONS[name]) drift.push(`function "${name}" exists in the library but is not described here`);
|
|
662
|
+
}
|
|
663
|
+
for (const name of Object.keys(FUNCTION_DESCRIPTIONS)) {
|
|
664
|
+
if (!FUNCTIONS[name]) drift.push(`function "${name}" is described here but no longer exists in the library`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return drift;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Renders one construct as Markdown, which is what a tool result carries.
|
|
672
|
+
*
|
|
673
|
+
* @param {Entry} entry The construct.
|
|
674
|
+
* @returns {string} The description.
|
|
675
|
+
*/
|
|
676
|
+
export function formatConstruct(entry) {
|
|
677
|
+
const lines = [`## \`${entry.name}\``, '', entry.summary, ''];
|
|
678
|
+
|
|
679
|
+
/** @type {string[]} */
|
|
680
|
+
const facts = [];
|
|
681
|
+
if (entry.dim) facts.push(`produces ${entry.dim}D`);
|
|
682
|
+
if (entry.leaf) facts.push('a leaf — it takes no children');
|
|
683
|
+
else if (entry.takes === 'same') facts.push("takes 2D or 3D children, passing the dimensionality through");
|
|
684
|
+
else if (entry.takes) facts.push(`takes ${entry.takes}D children`);
|
|
685
|
+
if (entry.ordered) facts.push('the order of its children matters');
|
|
686
|
+
if (facts.length) lines.push(`*${facts.join('; ')}.*`, '');
|
|
687
|
+
|
|
688
|
+
if (entry.attributes.length) {
|
|
689
|
+
lines.push('| Attribute | Type | Default | Description |', '| --- | --- | --- | --- |');
|
|
690
|
+
for (const attribute of entry.attributes) {
|
|
691
|
+
const type = attribute.values
|
|
692
|
+
? attribute.values.map((value) => `\`"${value}"\``).join(' ')
|
|
693
|
+
: attribute.type;
|
|
694
|
+
lines.push(`| \`${attribute.name}\` | ${type} | ${attribute.default ?? '—'} | ${attribute.description} |`);
|
|
695
|
+
}
|
|
696
|
+
lines.push('');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
lines.push('```hcl', entry.example, '```', '');
|
|
700
|
+
if (entry.notes?.length) lines.push(...entry.notes.map((note) => `- ${note}`), '');
|
|
701
|
+
|
|
702
|
+
return lines.join('\n');
|
|
703
|
+
}
|