Skip to content

Commit 4a356ea

Browse files
committed
Pipelines and tagging
Signed-off-by: Nick Cameron <nrc@ncameron.org>
1 parent 8bb8240 commit 4a356ea

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

pipelines.md

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# Pipelines and tags
2+
3+
This document proposes some design changes for pipelines, the pipeline operators (`|>` and `#`), and tagging.
4+
5+
In summary:
6+
7+
* Remove `#` and make the lhs of `|>` be an 'implicit' receiver argument
8+
* Remove `$` and tags as arguments; support `as` for declaring tagging variables
9+
* Support `{}` blocks in pipeline
10+
* Introduce pipeline-aware `for` expressions
11+
* Support infix operators in pipelines
12+
13+
Caveat: this proposal only makes sense in the context of other proposals around the fundamental model of KCL currently in development.
14+
15+
## Motivation
16+
17+
Pipelines are currently the defining feature of KCL: they are the most common control flow and its most unique syntax. They are fundamental for building geometry and that is fundamentally the point of KCL. They are the operation which users will read and write more than any other.
18+
19+
My understanding of the pipeline concept is that it is a first-class, ergonomic, and expressive implementation of the [builder pattern](https://en.wikipedia.org/wiki/Builder_pattern). This is useful for KCL because geometry has many, many ways it can be configured and many of the options should be ignored in many cases; and because step-by-step construction closely matches the workflow in the UI of the modelling app.
20+
21+
For both the user and the underlying engine, it is frequently required to refer to existing geometry both to transform it and to refer to it when making other changes or as a reference point for other changes. Examples:
22+
23+
* Take this sketch and extrude it
24+
* Take this edge (of a solid) and chamfer it
25+
* Take this point and draw a line to it
26+
* Take this face (of a solid) and draw a sketch on it
27+
28+
This is currently accomplished by using *tags*. A tag is essentially just a variable (note that variables in KCL are constants, not mutable, but I'm using 'variable' in the most inclusive sense of a symbol holding a place for a value represented in an abstraction of execution as a location) with some special support for naming parts of geometry and the facility to declare them and pass them as an 'out' parameter to standard library functions.
29+
30+
### Issues with the current design
31+
32+
* Pipeline syntax is noisey
33+
- We have a novel operator `|>` for connecting operations in some sense
34+
- We have a novel symbol `#` for the current target of the pipeline (a distinguished variable/tag) which appears many times in a typical pipeline
35+
- Declaring a new tag uses `$`
36+
- There are often a lot of brackets (`()` to call functions, `{}` to group arguments, `[]` to declare common types like points, vectors, etc.) and other punctuation (`:` for 'named arguments' in functions, `[]` for array indexing, typically a mix of strings, math symbols, commas, etc.)
37+
- There is lots of nesting: both deliberate (e.g., `|> hole(circle(...), ...)`, parentheses for controlling precedence in arithmetic) and incidental (e.g., grouping of arguments into objects, arrays of data, requiring multiple function calls which are conceptually related)
38+
* The syntax structure does not match the intuition of the common case, either from working things out from first principles or by reference to other PLs.
39+
- `a |> b` suggests that `a` is piped into `b` (especially since we use the terminology 'pipeline'). However, this is not done by `|>`, which is more of a sequencing operation. `#` is required to actually pipe `a` into `b` (e.g., if `b` is `f(#)`).
40+
- Many languages offer a similar `a.f()` syntax (replace `|>` with `.`, note the visual similarity in the common (in KCL) case of multiple steps) where `a` is passed to `f` in some way.
41+
* The relationship between tags and variables is fuzzy
42+
* Tagging cannot be used with user-defined functions
43+
* 'Out parameters' are a notoriously difficult feature for beginner programmers, and we rely on this semantics for tagging.
44+
* Most variables have to be declared before use, tags do not (although the first use is called a declaration, it is not a declaration that will look familiar to programmers or similar to other variable declarations)
45+
* Tagging is not expressive enough
46+
* Any non-trivial tagging is complex and unintuitive (i.e., the learning curve is not smooth).
47+
48+
This proposal will not fix all of the above, but will address some issues. In particular, I plan to think in more depth about the more complex tagging requirements.
49+
50+
# Proposed changes
51+
52+
## Core syntax and semantics
53+
54+
The pipeline operator becomes a method call operator.
55+
56+
Alternative: we could replace `|>` with `.` - this would be closer to most other languages, but I feel would lose some of KCL's character.
57+
58+
### Functions may have an explicit receiver
59+
60+
In the declaration, this is the first argument of a function and must use a distinguished keyword (most PLs use `self` or `this`) or symbol (we could reuse `#` either alone or as a decorator on a name, e.g., `#sketch`, or any other sigil). I prefer a keyword, I have no preference which. Note that in other PLs the receiver is used for dispatch of a method, not just the syntax of calls. That is not proposed yet.
61+
62+
In a call, the lhs of the `|>` operator becomes the receiver. If the function is called without the pipeline, then the argument must be specified.
63+
64+
E.g.,
65+
66+
```
67+
f = fn (this) => { ... }
68+
69+
startSketchOn(...) |> f()
70+
f(startSketchOn(...))
71+
```
72+
73+
In both calls, the result of `startSketchOn` is passed to `f` as `this`.
74+
75+
76+
### Remove `#`
77+
78+
The `#` operator is no longer supported.
79+
80+
In the common case, the use of `#` is replaced with use of a receiver (see above).
81+
82+
The other use cases for `#` I can find are for tagging (e.g., `profileStartX(%)`) and nested geometry (e.g., `hole(circle(..., #), #)``). I'm optimistic that by refining the design of std and implementing some extensions to tagging, we can reduce the need for these uses (both are future work). In the meantime, these uses can be implemented by breaking the pipeline with an intermediate variable, e.g.,
83+
84+
```
85+
// Current KCL
86+
sketch006 = startSketchOn('XZ')
87+
|> startProfileAt([0.1, 1], %)
88+
|> line([0.1, 0], %)
89+
|> angledLineToX({ angle: 10, to: 0.05 }, %)
90+
|> yLine(10, %)
91+
|> line([0.6, 0], %)
92+
|> yLine(-.05, %)
93+
|> tangentialArc({ radius: 0.6, offset: -90 }, %)
94+
|> lineTo([profileStartX(%), profileStartY(%)], %)
95+
|> close(%)
96+
|> revolve({ axis: 'y' }, %)
97+
98+
// Proposed KCL
99+
sketch006 = startSketchOn('XZ')
100+
|> startProfileAt([0.1, 1])
101+
|> line([0.1, 0])
102+
|> angledLineToX({ angle: 10, to: 0.05 })
103+
|> yLine(10)
104+
|> line([0.6, 0])
105+
|> yLine(-.05)
106+
|> tangentialArc({ radius: 0.6, offset: -90 })
107+
sketch007 = sketch006
108+
|> lineTo([profileStartX(sketch006), profileStartY(sketch006)])
109+
|> close()
110+
|> revolve({ axis: 'y' })
111+
```
112+
113+
See below ('Tagging') for a further improvement to this example.
114+
115+
#### Alternative
116+
117+
Retain `#` or replace it with a keyword (which I would prefer) for the rare cases where it is required (c.f., the current situation where it is frequently required).
118+
119+
## Tagging
120+
121+
Remove the `$` operator, remove the concept of passing tags to functions. Allow the `as` keyword to be used within pipelines to assign intermediate results to a variable. Note that this usage of `as` follows its use in `import` statements and has the same semantics of introducing a new name.
122+
123+
This syntax is easier to read and comprehend, extends to user-defined functions, and reduces the total feature count of the language (since it is already used in imports).
124+
125+
Example: `foo = ... |> bar() as baz |> qux()`, here the final result is assigned into `foo` (no change to semantics), the result of executing `|> bar()` is assigned into `baz` (equivalent to `|> bar($baz)` in the current syntax).
126+
127+
The earlier example becomes:
128+
129+
```
130+
sketch006 = startSketchOn('XZ')
131+
|> startProfileAt([0.1, 1])
132+
|> line([0.1, 0])
133+
|> angledLineToX({ angle: 10, to: 0.05 })
134+
|> yLine(10)
135+
|> line([0.6, 0])
136+
|> yLine(-.05)
137+
|> tangentialArc({ radius: 0.6, offset: -90 }) as arcSketch
138+
|> lineTo([profileStartX(arcSketch), profileStartY(arcSketch)])
139+
|> close()
140+
|> revolve({ axis: 'y' })
141+
```
142+
143+
### Alternative
144+
145+
Rather than making `as` part of the pipeline syntax, it could be allowed in any expression. The above description would still apply, but it could also be applied to sub-expressions, e.g., `hole(circle(...) as innerCircle)`.
146+
147+
This would be more flexible and would avoid learners hitting the issue of knowing where `as` can be used ("I can use it here and here, so why can't I use it here?"). However, I believe it would lead to poor programming style: it is generally better to use a variable declaration to refer to reused geometry or data rather than use `as` since it will be easier to scan code for declarations and it encourages a less nested, more straightforward coding style. On the other hand, pipelining is ergonomic and allowing `as` there prevents breaking pipelines arbitrarily.
148+
149+
The precedence in general sub-expressions may be confusing, e.g., in `a + b as c + d`, does `c` refer to the value of `b` or of `a + b`?
150+
151+
## Braced blocks in pipelines
152+
153+
Braced blocks are allowed in pipelines. The current receiver of the pipeline is piped into the *last* expression of the block (the block must have a last expression, similar to `if` blocks). The result of the block is the result of the final expression. Variables within the block are scoped to the block. (Possible extension: use `export x = ...` or `... export as x` to make variables visible in the enclosing scope).
154+
155+
```
156+
startSketchOn('XZ')
157+
|> {
158+
x = sin(42)
159+
y = cos(42)
160+
line([x, y])
161+
}
162+
|> close()
163+
164+
// Or equivalently
165+
166+
startSketchOn('XZ')
167+
|> {
168+
x = sin(42)
169+
y = cos(42)
170+
line([x, y])
171+
|> close()
172+
}
173+
```
174+
175+
This allows more precise scoping of variables (in the above example, `x` and `y` are not available outside the block), but the primary motivator is for use in `for` expressions, see below.
176+
177+
## `for` expressions
178+
179+
Syntax: `'for' expr_1 'as' id |> expr_2`. May appear within a pipeline or as an expression outside a pipeline.
180+
181+
In the simplest case (with no preceding pipeline), `expr_1` is evaluated to some kind of an object `s` with sequence type (an array or range). `expr_2` is evaluated with each item in `s` bound in turn to `id`. The result of execution is an array of the results of evaluating `expr_2` (i.e., you can think of the expression as performing 'for each' or 'map' on its input).
182+
183+
Examples:
184+
185+
```
186+
//prints "0\n1\n2\n3\n4\n"
187+
for [0..5] as i
188+
|> println(i)
189+
190+
// x has value [0, 2, 4, 6, 8]
191+
x = [0..5] as i
192+
|> i * 2
193+
194+
// Draws five objects starting at (0, 0), (1, 1), ...
195+
for [0..5] as i |> {
196+
startSketchOn('XZ')
197+
|> startProfileAt([i, i])
198+
|> line([1, 0])
199+
...
200+
|> close()
201+
}
202+
}
203+
```
204+
205+
When used in a pipeline, the input to `for` is passed into `expr_2` as the receiver for the first iteration. The result of that execution is passed into the next iteration and so forth until the final result is returned as the value of the for expression. I.e., it is a kind of reduce or fold operation. E.g.,
206+
207+
```
208+
sketch
209+
|> for [0..n] as i
210+
|> drawOneSectorOfGear(i)
211+
|> extrude(...)
212+
```
213+
214+
Here, `sketch` is the receiver of `drawOneSectorOfGear` on the first iteration, and the result of that is the receiver on the next. The final result is the extrusion of the inital sketch with `n` sectors.
215+
216+
Example with block:
217+
218+
```
219+
sketch
220+
|> for [0..n] as i |> {
221+
x = sin(i)
222+
y = cos(i)
223+
drawOneSectorOfGear(x, y)
224+
}
225+
|> extrude(...)
226+
```
227+
228+
Note that only a single pipeline stage is repeated (thus the indentation in the first example). To apply longer pipelines, use a block, e.g.,:
229+
230+
```
231+
sketch
232+
|> for [0..n] as i |> {
233+
drawOneSectorOfGear(i)
234+
|> foo()
235+
}
236+
|> extrude(...)
237+
```
238+
239+
Tagging using `as` works as normal within a block, tagging the whole block (or the whole single expression, if there is no block), produces an array of tags. E.g.,
240+
241+
```
242+
sketch
243+
|> for [0..n] as i
244+
|> drawOneSectorOfGear(i) as x
245+
|> extrude(...)
246+
247+
// or
248+
249+
sketch
250+
|> for [0..n] as i |> {
251+
x = sin(i)
252+
y = cos(i)
253+
drawOneSectorOfGear(x, y)
254+
} as x
255+
|> extrude(...)
256+
```
257+
258+
In both cases, `x` has type [T] if `drawOneSectorOfGear` returns a `T`.
259+
260+
Extension: Assumes we can use `export as` to export a variable from a block. Tagging within a for loop produces a somewhat special variable: it refers to the current iteration's tag within the loop and an array of tags outside the loop. E.g.,
261+
262+
```
263+
sketch
264+
|> for [0..n] as i |> {
265+
drawOneSectorOfGear(i) export as x
266+
|> foo(x) // x has type T
267+
}
268+
|> bar(x) // x has type [T]
269+
|> extrude(...)
270+
```
271+
272+
### Alternative syntax
273+
274+
Use `in` instead of `|>` in the for loop syntax. Examples:
275+
276+
```
277+
x = [0..5] as i in i * 2
278+
279+
sketch
280+
|> for [0..n] as i in
281+
drawOneSectorOfGear(i)
282+
|> extrude(...)
283+
284+
sketch
285+
|> for [0..n] as i in {
286+
drawOneSectorOfGear(i)
287+
|> foo()
288+
}
289+
|> extrude(...)
290+
```
291+
292+
I think this works better in the block case, but not so well in the case with a single expression.
293+
294+
Or we could make the braced block mandatory rather than optional with a single expression:
295+
296+
```
297+
x = [0..5] as i { i * 2 }
298+
299+
sketch
300+
|> for [0..n] as i {
301+
drawOneSectorOfGear(i)
302+
}
303+
|> extrude(...)
304+
305+
sketch
306+
|> for [0..n] as i {
307+
drawOneSectorOfGear(i)
308+
|> foo()
309+
}
310+
|> extrude(...)
311+
```
312+
313+
Or we could have a mandatory braced block *and* either `|>` or `in`.
314+
315+
And/or, we could use `for i in [...]` rather than `for [...] as i`. This is closer to other PLs and reads better, however, it does not reuse the `as` keyword and so requires a new keyword.
316+
317+
### Infix operators
318+
319+
Infix operators (e.g., `+`, `-`) can be used in pipelines with the lhs of the pipeline operator being used as the lhs side of the infix operator. E.g., `1 |> + 1` is equivalent to `1 + 1`. This is not really necessary by itself, but might be if in the future we support infix operators for 2D or 3D geometry, e.g., `sketch |> extrude(...) |> union something |> union somethingElse()`, assuming `a union b` is allowed.
320+
321+
I expect this part of the proposal should be low priority to implement. I'm just including it here for completeness.
322+
323+
## Alternative: rely less on pipelines
324+
325+
[Example](https://github.com/KittyCAD/modeling-app/issues/2728#issuecomment-2361355398):
326+
327+
```
328+
squareSketch = sketchOn('XY', [
329+
line(4, 0),
330+
line(0, 4),
331+
line(-4, 0),
332+
line(0, -4),
333+
])
334+
```
335+
336+
Advantages:
337+
338+
* Simpler
339+
- More declarative since we're declaring the result in one go rather than the steps to building it
340+
- Less visual noise (no `|>`, etc.)
341+
- Possibly fewer required steps
342+
* More expressive - user can manipulate arrays rather than just build up objects
343+
* Perhaps closer to the execution model of building a geometry model and sending that to the engine, rather than sending each step to the engine
344+
345+
Disadvantages (or mitigating factors to the above)
346+
347+
* Programming with arrays requires more complex programming features (good for power users, bad for non-programmers and beginners) or incremental construction of arrays, in which case we're doing the same as using pipelines for building, just with an extra step (combining geometry into an array and turning the array into geometry, vs combining geometry into geometry).
348+
* Some of the simplifications of this approach could be achieved in the pipelines approach by improving the design of the std lib functions
349+
* Encourages either more nesting of expressions (hard to read) or many variables (inconvenient, hard to read).
350+
351+
352+
# Further work
353+
354+
* Advanced tagging use cases
355+
* Ergonomics of function declaration and calls, function organisation
356+
* Arrays, collections, iteration, etc.
357+
* More syntax polishing
358+
* Rationalising std

0 commit comments

Comments
 (0)