Introduction

Engineering is about solving problems, given some constraints. My name is Adam, and I'm a software engineer. I write code. Many of my colleagues at Zoo are hardware engineers. They design and build real-world objects. Some people see software and hardware engineering as two different disciplines. After all, software can't be touched or seen. It can't keep the two ends of a bridge together. And hardware -- everything from bridges to buildings to clamps to rockets -- can. Physical and software systems seem very separate. But that's not how I see it. Both coders and Formula 1 car designers are fundamentally engineers. We're both trying to solve difficult problems, with limited resources and various constraints. We're both trying to satisfy some requirement (like function) while minimizing some measurements (like cost or size) and still making the final result comprehensible (to our colleagues, to our users, or to our future self who revisits this project in two years).

Zoo started when Jess, a software engineer, called Jordan, an aerospace engineer, about her struggles with a really complicated CAD model. The two realized they had a lot more in common than they thought. They realized that if software and hardware engineers take the best parts of each other's practices and tools, we could get our jobs done quicker and with less stress. Every day at Zoo, we put bright engineers from all disciplines together and let them learn from each other.

These days, hardware engineers need to use software. The days of hand-drawing all your designs on a drafting table are over. But hardware engineers often find their software frustrating. Software engineers understand -- ranting about how much you hate software is a time-honored tradition among software engineers. The difference is, when software engineers don't like their tools, they know how to take them apart and make new ones. We can find the source code for our software, fork it, and add new features. Or we could even write our own version from scratch, that works exactly how we'd like it to. Unfortunately, when hardware engineers don't like their software, they're stuck. They don't usually know enough programming to edit their engineering software. So they're at the mercy of some software engineer at a different company, who doesn't understand the problem well enough. Our goal at Zoo is to put the software and hardware engineers in the same room, so that when hardware engineers complain about their tool, the software people are listening and can quickly improve it. Experimenting with ideas from both worlds will be the key to modern, 21st century engineering.

KCL is one of the first successes from this hardware-software-engineer collaboration. We hope it will make CAD both easier to understand and more powerful. KCL, or the KittyCAD Language, is a programming language for CAD.

Why a programming language?

Zoo's programmers -- experts in programming languages -- listened to Zoo's aerospace engineers, and concluded that what they needed was... a programming language! Surprise surprise. No, wait, don't run away aerospace engineers! It's not that bad, I promise!

Hardware engineers often shudder when I tell them we're building a programming language for CAD. I understand why! CAD is complicated. Programming is complicated. Simplifying CAD with programming is like solving your rampaging boar problem by introducing rampaging lions to control them. But we really believe in this approach. We've seen first-hand the benefits of code-driven CAD. We know other software has tried to combine them, but we think they started with three fundamental flaws:

  1. The code has to be fundamental. You can't build a non-code CAD suite and then slap code on afterwards. Otherwise, there'll be gaps between these two halves -- things you can do in code but not in the "normal software", and vice-versa.
  2. Not everyone will want to code -- and that's OK. If you don't want to learn KCL, you don't have to! You can still use a traditional mouse-based, point-and-click workflow if you're more comfortable there. Every time you click a button, Zoo Design Studio is actually generating KCL under the hood. You've been writing KCL without knowing it! Or more accurately, telling the computer to write the KCL for you. If you decide to learn KCL later, you can open up your existing models and view the KCL.
  3. Reusing existing languages. JavaScript, C, Python etc are all great languages. But they were designed for software engineering, and the problems that software engineers solve. KCL is designed for engineering real world objects, not software. So it makes different choices. Existing languages require you to learn a bunch of little details that matter a lot to programmers, but aren't really important to mechanical engineers. KCL doesn't have any of those. Instead, it has built-in features that match how mechanical engineers think.

Learning to code takes work. Why bother learning KCL if you can just use the point-and-click UI instead? There's a few reasons.

Firstly, KCL lets you read the fundamental model underlying your designs. In normal CAD software, if you want to understand your model, you have to spin it around in the UI, look at different parts, maybe hide or show various faces that would block your view of its internals. This is because you never really access the model directly. Instead, you view a rendering of the model. Feature trees help, but they only show a subset of the information connecting your model. KCL lets you directly read the exact same code that our CAD suite is executing. If you want to know why a hole has a certain diameter, you can just go to the line of code which defines that hole, and see where it gets its length. Is it a direct measurement, handwritten like length = 2mm? Is it a parameter from a parametric design? Is it the result of a calculation, like length = totalHeight * 0.3? Code makes it easy to see exactly where your measurements come from.

Secondly, KCL is the interface between human and computer. Programming languages aren't really built for computers. Computers use binary instructions like 1101010101010100000011. The first computers had to be programmed in binary, and coders would look up each instruction carefully in a huge reference manual to find which 0s and 1s each instruction needed. This was obviously very tedious, so programming languages were invented instead. Both humans and machines can read code like let x = y + 3. The human knows what it means, and the computer knows how to execute it.

Lastly, KCL is the interface between humans and other humans. Let's say you're collaborating on a CAD model with a coworker. They send you the latest revision of some part, and you open it up. What's changed? Impossible to tell. You'd have to open up the old revision and glance back-and-forth between the two to find the difference. This is much easier when your model is stored as code! You can easily see the exact lines of code that changed -- green for new lines added, red for old lines removed, yellow for changes. And your coworker can leave comments in the code, so that anyone reading it understands exactly what has changed. As a bonus, this works even for solo engineers. If you want to see how your model has changed since last year, you can open up last year's file and run the same line-by-line visual comparison.

By learning KCL, you're developing a skill that can open up massive improvements to your design and engineering skills. It's a new skill, and like all new skills it takes time and practice to develop. But it's worth it. Code-driven CAD can become your superpower and let you simplify your designs and design quicker. I'm excited to guide you on this journey. Let's get started.

Installation

There are two main ways to work with KCL. You can use Zoo Design Studio, our all-in-one application built specifically for KCL designs, or you can use a traditional programming interface with your own text editor. We definitely recommend the Zoo Design Studio.

Zoo Design Studio

You can download Zoo Design Studio, then open it and get started. There's a KCL code pane -- click the Code Editor button on the left edge of the screen. That's it! Get ready to design in KCL.

You'll note that when you write KCL code, the live 3D view updates, showing the model you've defined. And if you use the point-and-click buttons (to draw lines, or to select faces for extruding, or edges for filletting), the corresponding lines of code get highlighted. This two-way communication between the code and visuals is the key reason we think KCL will succeed where other code-CAD solutions failed. It's easy to tell exactly what part of the model your code corresponds to.

Traditional code editing

You can download the Zoo CLI, which lets you execute KCL programs and download some sort of visualization. You can download your KCL models as 3D files or as 2D images. Use zoo kcl --help to learn more.

You can edit KCL in whatever text editor you prefer. If you're using VSCode you can use our VSCode extension. For all other editors, our LSP is available. We hope to one day supply more developer tools, like a Treesitter grammar, but for now it's not a high priority. We'd happily work with anyone who'd like to contribute an open-source implementation though!

Contributing to KCL

Our GitHub for Zoo Design Studio has all of our developer tooling and the language runtime. Please take a look there if you'd like to open any bugs or contribute to KCL! This book itself is available on GitHub too. Feel free to open issues or PRs.

Variables

Let's get comfortable with basic KCL first, before we start designing parts. Don't worry, we'll get to real mechanical engineering very soon. For now, let's start with some math.

Here's a simple KCL program. Open up the Zoo Design Studio, make a new project, then open the KCL code panel (on the left). Enter this text in:

width = 1
height = 2
area = width * height

This simple program declares three variables. Variables are little bits of data you can define or calculate. Here we define width and height and assign their value immediately. We write their value right in there, as width = 1 and height = 2. You can see that a variable declaration has the variable's name (e.g. width), then an equals sign (=) and then its value (e.g. 1).

The area variable is very similarly, except instead of defining the exact number, we define it as a calculation. We define it as width * height. We could have defined area as just area = 2, but this way, anyone else who reads the code can understand why the area is 2 -- it's because we're calculating some rectangular area with a width and height.

In this simple case, we can calculate the area in our head. It's going to be 2. But what if you're calculating something more complex? Well, take a look at the Variables panel (on the left).

Result of running program 1

This panel shows every variable and its value. You can look up the value of area here. It's 2, just like we expected. For this simple example it's not necessary to look it up, but for more complicated cases it can be very helpful! This way, you can do all your engineering calculations in KCL. You can treat it like a really advanced calculator, where big equations can be broken into smaller named variables, and their value can be inspected independently.

Note that once you declare a variable, you cannot redeclare it, or change its value.

Basic data types

All the variables in the previous section stored numbers. But KCL can store other types of data too. Let's see some examples. These aren't all the types of data KCL can store, but it's a good starting point. We'll learn more data types later in this book as we get into more specialized features for designing parts.

Number

You just saw how basic numbers work in the example above. Numbers can also be fractional or negative.

Examples

  • width = 1
  • diameter = 1.5
  • offset = -2.3

Booleans

A boolean value is either true, or false. That's it! Just those two choices. Booleans are useful for changing details of KCL functions, like changing whether a semicircle is drawing clockwise or not.

Examples

  • clockwise = false
  • isConstructionGeometry = true

String

A string stores text. "String" is the software-engineering term for text. We probably should have called this just "Text" in KCL, but oh well. You can think of it as "stringing" several letters together to make words. They're not currently used very often in KCL, except to set colours (with the hexadecimal colour codes you might see in Photoshop, Figma or Canva). In the future, you'll be able to use strings to embed text into your models (e.g. for engraving text into your objects).

Examples

  • textToEngrave = "My Phone"
  • red = "#FF0000"

Collection types

All the previous data types stored basically one piece of data. It might be a number, or text, or a true/false value, but it's basically a single piece of data. KCL variables can also store multiple pieces of data, kept together under a single variable name. Let's see some examples.

Arrays

An array is a list of data, like the four numbers [1, 2, 3, 4] or these three colours ["#ff0000", "#cccc00", "#44ff00"]. These arrays contain other data. We say that arrays contain items. The two previous example arrays had 4 items and 3 items respectively. Sometimes the items of an array are called their elements. The terms "elements" and "items" are synonyms, you can use them interchangeably.

To access the items in an array, you use square brackets and the number item you want. For example, myArray[0] will get the first item from the array, myArray[1] will get the second, and so on. Yes, that's right, the first item is item 0, not item 1! This might be strange for new programmers, but it's how almost every programming language works, so we felt it was important to stick with that convention, so that your KCL code works like similar code in Python, JavaScript, C or other languages.

If you try to access an item beyond what the array contains -- for example, the fifth element of [1, 2] -- you'll get an error and the KCL program will stop.

Arrays can also be defined as a range of values, for example, [1..5] is a shorthand for the array [1, 2, 3, 4, 5]. Note that the range is inclusive at both ends (it includes both the start and end of the range in the array).

Examples

  • colors = ["#ff0000", "#cccc00", "#44ff00"]
  • red = colors[0]
  • sizes = [33.5, 31.5, 30]
  • smallest = sizes[2]
  • arrayOfArrays = [[1, 2, 3], [1, 4, 6]]
  • firstFiveNumbers = [1..5]

Points

To properly dimension and sketch out your designs, you'll frequently need to select specific points on a plane. In KCL, points can be stored in variables and used just like any other data type. We actually store points as arrays. An array with 2 elements Arrays are really important in KCL, because we use them to represent 2D points on a plane (e.g. the origin [0, 0]) or 3D points in space.

Examples

  • origin = [0, 0]
  • myPoint = [4, 0, 0]
  • myPointX = myPoint[0]
  • myPointY = myPoint[1]
  • myPointZ = myPoint[2]

Objects

Sometimes, you need to store several pieces of related data together. KCL has objects which contain several fields. Fields have a key, which is always text (a string), and a value, which can be any kind of KCL value. Even another object!

Examples

  • sphere = { radius = 4, center = [0, 0, 3.2] }
  • wires = { positive = [1, 2], negative = [3, 4], resistance = 0.3 }
  • components = { name = "Flange", holes = { inner = [[0, 0], [1, 0]], outer = [[4, 4]] } }

More info

We used several different operators so far, including + and -, but KCL supports a lot of other operators. You can find a full list in the operator docs.

Calling functions

In the last chapter, we looked at different data types that KCL can store. Now let's look at how to actually use them for more complex calculations. We use KCL functions for nearly everything, including all our mechanical engineering tools, so they're very important.

Data in, data out

Let's look at a really simple function call.

smallest = min([1, 2, 3, 0, 4])

This is a variable declaration, just like the variables we declared in the previous chapter. But the right-hand side -- the value the variable is defined as -- looks different. This is a function call. The function's name is min, as in "minimum".

Functions have inputs and outputs. This function has just one input, an array of numbers. When you call a function, you pass it inputs in between the parentheses/round brackets. Then KCL calculates its output. You can check its output by looking up smallest in the Variables panel. Spoiler: it's 0. Which is, as you'd expect, the minimum value in that array.

If you hover your mouse cursor over the function name min, you'll find some helpful documentation about the function. You can also look up all the possible functions at https://docs.zoo.dev. That page shows every function, and if you click it, you can see the function's name, inputs, outputs and some helpful examples of how to use it.

All functions take some data inputs and return an output. The inputs can be variables, just like you used in the previous chapter:

myNumbers = [1, 2, 3, 0, 4]
smallest = min(myNumbers)

A function's inputs are also called its arguments. A function's output is also called its return value.

Here are some other simple functions you can call:

absoluteValue = abs(-3)
roundedUp = ceil(12.5)
shouldBe2 = log10(100)

Labeled arguments

The min function takes just one argument: an array of numbers. But most KCL functions take in multiple arguments. When there's many different arguments, it can be confusing to tell which argument means what. For example, what does this function do?

x = pow(4, 2)

If you mouse over the docs for pow (or look them up at the KCL website) you'll see it's short for power, as in raising a number to some power (like squaring it, or cubing it). But, does pow(4, 2) mean 4 to the power of 2, or 2 to the power of 4? You could look up the docs, but that gets annoying quickly. Instead, KCL uses labels for the parameters. The real pow call looks like this:

x = pow(4, exp = 2)

Now you can tell that 2 is the exponent (i.e. the power), not the base. If a KCL function has multiple arguments, only the first argument can be unlabeled. All the following arguments need a label. Here are some other examples.

oldArray = [1, 2, 3]
newArray = push(oldArray, item = 4)

Here, we make a new array by pushing a new item onto the end of the old array. The old array is the first argument, so it doesn't need a label. The second argument, item, does need a label.

Combining functions

Functions take inputs and produce an output. The real power of functions is: that output can become the input to another function! For example:

x = 2
xSquared = pow(x, exp = 2)
xPow4 = pow(xSquared, exp = 2)

That's a very simple example, but it shows that you can assign the output of a function call to a variable (like xSquared) and then use it as the input to another function. Here's a more realistic example, where we use several functions to calculate the roots x0 and x1 of a quadratic equation.

a = 2
b = 3
c = 1

delta = pow(b, exp = 2) - (4 * a * c)
x0 = ((-b) + sqrt(delta)) / (2 * a)
x1 = ((-b) - sqrt(delta)) / (2 * a)

If you open up the Variables panel, you'll see this gives two roots -0.5 and -1. Combining functions like this lets you break complicated equations into several small, simple steps. Each step can have its own variable, with a sensible name that explains how it's being used.

Comments

This is a good point to introduce comments. When you start writing more complex code, with lots of function calls and variables, it might be hard for your colleagues (or your future self) to understand what you're trying to do. That's why KCL lets you leave comments to anyone reading your code. Let's add some comments to the quadratic equation code above:

// Coefficients that define the quadratic
a = 2
b = 3
c = 1

// The quadratic equation's discriminant
delta = pow(b, exp = 2) - (4 * a * c)

// The two roots of the equation
x0 = ((-b) + sqrt(delta)) / (2 * a)
x1 = ((-b) - sqrt(delta)) / (2 * a)

If you type //, any subsequent text on that line is a comment. It doesn't get executed like the rest of the code! It's just for other humans to read.

The standard library

KCL comes built-in with functions for all sorts of common engineering problems -- functions to calculate equations, sketch 2D shapes, combine and manipulate 3D shapes, etc. The built-in KCL functions are called the standard library, because it's like a big library of code you can always use.

You can create your own functions too, but we'll save that for a future chapter. You can get pretty far just using the built-in KCL functions! We're nearly ready to do some actual CAD work, but we've got to learn one more essential KCL feature first.

Pipeline syntax

When you have repeated function calls wrapping each other, it can become difficult to understand your code. KCL code relies heavily on pipelines to keep code readable. Let's get into the details and learn how to write neater, idiomatic KCL.

The |> operator

In the previous chapter we learned how to call functions: you write the function's name, then give its inputs in parentheses, like this:

x = pow(2, exp = 2)

What if you want to repeatedly call a function, then call another function on that output? Here's an example:

sqrt(sqrt(sqrt(64)))

We find the square root of 64, then pass its output as the input to another square root call. And another. And another. Eventually we've found the eighth root of 64.

This is pretty hard to read! We could make it more readable by breaking it up into single calls and assigning each to its own variable, like this:

x = 64
y = sqrt(x)
z = sqrt(y)
w = sqrt(z)

But then we have to think of meaningful names, and add a lot of variables. Now the Variables pane shows all these intermediate variables like y and z. Sometimes that's helpful, but sometimes it can be distracting.

Passing the output of a function into another function's input is a very common task in KCL code. So, KCL has a nice little feature for simplifying this common pattern. It's called a pipeline. Let's rewrite the above using pipeline syntax:

x = 64
w = sqrt(x)
  |> sqrt(%)
  |> sqrt(%)

What's going on here? Basically, if you call two functions like g(f(x)) you could rewrite it as f(x) |> g(%). Whatever is to the left of the |> gets calculated, then passed into the function on the right of |>. The % symbol basically means "use whatever was to the left of |>". The |> is basically a triangle pointing to the right, showing that the data on the left flows into the function on the right. You can think of it like an assembly line in a factory, moving parts (data) between different machines (functions) using a conveyor belt (the |> symbol).

Let's see another example. If you take a number's square root, and then square it again, it should give you the original number back. Let's test that.

x = 64
xRoot = sqrt(x)
shouldBeX = pow(xRoot, exp = 2)

Let's rewrite this using pipelines:

x = 64
shouldBeX = sqrt(x)
  |> pow(%, exp = 2)

Implicit %

All those %s can be a bit annoying to read. Remember how some KCL functions declare a special unlabeled first argument? If a function uses the special unlabeled argument, then that argument will default to %. Basically, if you use these functions in a pipeline, you can omit the % and KCL will insert the % for you.

In other words, these two programs are equivalent:

x = 8
  |> pow(%, exp = 2)

and

x = 8
  |> pow(exp = 2) // No % needed.

x equals 64 in both these programs.

Let's see another example. We could simplify this program:

x = 64
w = sqrt(x)
  |> sqrt(%)
  |> sqrt(%)

as

x = 64
w = sqrt(x)
  |> sqrt()
  |> sqrt()

Both programs work the exact same -- the first unlabeled argument in sqrt isn't given, so it defaults to %, i.e. the left-hand side of the |> symbol. This makes your code a bit cleaner and easier to read.

With that, you've learned the basics of KCL. You know how to declare data in variables, compute new data by calling functions, and join many functions together (either using pipelines or new variables). We're ready to get into mechanical engineering. In the next chapter we'll start looking at how KCL functions can define geometric shapes for your designs and models.

Sketching 2D shapes

Let's use KCL to sketch some basic 2D shapes. Sketching is a core workflow for mechanical engineers, designers, and hobbyists. The basic steps of sketching are:

  1. Choose a plane to sketch on
  2. Start sketching at a certain point
  3. Draw a line from the current point to somewhere
  4. Add new lines, joining on from the previous lines
  5. Eventually, one line loops back to the starting point.
  6. Close the sketch, creating a 2D shape.

You can do each of these steps in KCL. Let's see how!

Your first triangle

Let's sketch a really simple triangle. We'll sketch a right-angled triangle, with side lengths 3 and 4.

Just copy this code into the KCL editor:

startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> line(end = [3, 0])
  |> line(end = [0, 4])
  |> line(endAbsolute = [0, 0])
  |> close()

Your screen should look something like this:

Result of running program 1

Congratulations, you've sketched your first triangle! Rendering your first triangle is a big deal in graphics programming, and sketching your first triangle is a big deal in KCL.

Let's break this code down line-by-line and see how it corresponds to each step of sketching from above. Note that each step in creating this triangle uses the pipeline syntax |>. This means every function call is being piped into the next function call.

1: Choose a plane

In KCL, there's six basic built-in planes you can use: XY, YZ, XZ, and negative versions of each (-XY, -YZ and -XZ). You can use one of these standard planes, or define your own (we'll get to that later). Those six standard planes can be used just like normal variables you define, except they're pre-defined by KCL in its standard library. You can pass them into functions, like the startSketchOn function. So, line 1, startSketchOn(XY) is where you choose a plane, and start sketching on it.

startSketchOn takes one argument, the plane to sketch on. It's the special unlabeled first parameter. We'll go over some other planes you can sketch on in the chapter about sketch on face.

2: Start sketching

Sketches contain profiles -- basically, a sequence of lines, laid out top-to-tail (i.e. one line starts where the previous line ends). We have to start the profile somewhere, so we use startProfile(at = [0, 0]). The startProfileAt takes two parameters:

  1. The sketch we're adding a profile with. This is one of those special unlabeled first parameters, so we don't need a label. We're setting it to the sketch from startSketchOn(XY), which is being piped in via the |>. If you don't set this first parameter, it defaults to %, i.e. the previous pipeline expression. And that's exactly what we want! So we're leaving it unset.
  2. The at parameter indicates where the profile starts. For this example, we'll start at the origin of the XY plane, i.e. the point [0, 0].

3: Add paths

A profile is a sequence of paths. A path is some sort of curve between two points, possibly straight lines, circular arcs, parabolae, or something else. For this triangle, we're adding 3 paths, which are all straight lines. The line call says to draw a line starting at the previous end point. Currently, this is [0, 0] from the startProfileAt call. So this line starts at [0, 0]. Where does it end? Well, the line call says that end = [3, 0], which means "extend this line 3 units along the X axis, and 0 units along the Y axis". This is a relative distance, because it's telling you how far to move from the previous point. So, this line goes from [0, 0] to [3, 0].

4: Add more lines, joining on from previous lines.

The next call is line(end = [0, 4]). It draws a line from the previous line's end ([3, 0]), extending a distance of 0 along X and 4 along Y. So it goes from [3, 0] to [3, 4].

5: Join back to the start

Our third line heads back to the start of the profile, i.e. [0, 0]. We do this by calling line(endAbsolute = [0, 0]). Note that this uses endAbsolute =, not end = like the previous lines. The end = arguments were relative distances: they said how far away the new point is, along both X and Y axes, from the previous point. This one is different: this is an absolute point, not a relative distance. The array [0, 0] isn't saying to move 0 along X and 0 along Y. It's saying, draw a line that ends at the specific point [0, 0], i.e. the origin of the plane.

Because this is the same point that our profile starts at, this line has looped our profile back to its start.

If we stopped our program here, you could see all three lines:

Result of running program 1

Note that the relative lines (i.e. the first two line calls, with end =) have arrows showing where they're going. This last line, which ends at an absolute point, does not.

6: Close the sketch

The last function being called is close. It takes one argument, the sketch to close. As in the previous functions, it's an unlabeled first parameter, so you could write close(%), but close() will do the exact same thing.

Once we add the close() call, the rendering changes from just 3 lines (like in the second image in this page) to a filled-in shape (like in the first image on this page).

Enhancements

This code totally achieved our goal: it sketches a right-angled triangle with sides of length 3 and 4. Mission accomplished.

Of course, in programming, there's usually several different ways to achieve a goal. KCL is no different! Let's look at some different ways we could have sketched this shape.

Closing shapes

One important principle in programming is "don't repeat yourself" (DRY). Look back at this code: it uses the point [0, 0] twice. Once when we start the sketch, and once when we close the sketch. There's nothing necessarily wrong with this, but if you want to change the triangle later, you'll have to change this in two different places. And if you make a typo in one of the places, the model will break, because the sketch will be starting and finishing at a different point. This program doesn't have a bug currently, but by repeating this value twice, we introduce a potential bug in the future. I'd call this program brittle -- it's not broken, but it could break in the future. If we could define the point [0, 0] just once, the program would be more resilient, i.e. less likely to break if you change something in the future.

Here's a few ways to make this code less repetitive, less brittle, and DRY-er.

Firstly, you could replace [0, 0] with a variable like start, and use it in both places.

start = [0, 0]
startSketchOn(XY)
  |> startProfile(at = start)
  |> line(end = [3, 0])
  |> line(end = [0, 4])
  |> line(endAbsolute = start)
  |> close()

Next, we could use a helper function profileStart instead.

startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> line(end = [3, 0])
  |> line(end = [0, 4])
  |> line(endAbsolute = profileStart())
  |> close()

The profileStart function takes in the current profile, and returns its start value. It takes a single unlabeled parameter, which we're setting to % (the left-hand side of the |>). Like always, if the special unlabeled argument is set to %, you can just omit the %, because that's the default.

X and Y lines

The first line of our triangle is parallel to the X axis, and the second line is parallel to the Y axis. This means we could simplify our code somewhat by using the xLine and yLine functions:

startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> xLine(length = 3)
  |> yLine(length = 4)
  |> line(endAbsolute = profileStart())
  |> close()

xLine takes an unlabeled first parameter for the sketch (which, as before, we're setting to % and can therefore omit) and then a length parameter, which tells KCL to draw a flat line, parallel to the X axis, with the given length. Basically,xLine(length = n) is a neater way to write a horizontal line like line(end = [n, 0]). You can use whichever one you prefer. The yLine function works the same way, but for vertical lines.

These examples use relative xLine and yLine -- i.e. lines that end a certain distance away from the previous point. If you want to instead draw a line to a specific point along the X axis (like x = 3), you could use xLine(endAbsolute = 3).

Conclusion

We've written our first triangle. We learned:

  • Sketches are on some plane, and KCL includes standard planes XY, YZ and XZ (and their negative versions, which point the third axis in the opposite direction).
  • Sketches contain profiles, which are made of sequential paths. In our example, there's one profile, a triangle, made of three paths (3 straight lines).
  • Lines start at the end of the previous point (the first line starts at the startProfile(at=) point)
  • Lines can end either a certain distance away along X and Y (a relative end), or at a particular point along the plane (an absolute end)
  • The close function turns a sequence of paths that form a loop into a single 2D shape.

Sketching curved lines

In the previous chapter, we sketched a basic triangle. In this chapter, we'll look at some more interesting kinds of sketches you can do, using more interesting kinds of paths.

Pills

Let's sketch a pill shape, like a rectangle but with rounded edges. We can use tangential arcs for this. The tangentialArc function sketch a curved line -- specifically, an arc, or a subset of a circle -- starting from the previous line's end. It draws it at a smooth angle from the previous line, i.e. tangent to the previous line.

height = 4
width = 8
startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = width)
  |> tangentialArc(end = [0, height])
  |> xLine(length = -width)
  |> tangentialArc(endAbsolute = profileStart())

It should look like this:

A pill-shape made from xLines and tangentialArcs

Let's analyze this! It looks very similar to the triangle we sketched previously, but we're using tangentialArc. You can see it takes a relative end, i.e. an X distance and Y distance to move from the current point. It draws a nice smooth arc there.

We wrote this arc using end, i.e. an X and Y distance. But we could have defined this arc differently, using a radius and angle instead. You can replace the tangentialArc(end = [0, height]) with tangentialArc(angle = 180, radius = height) instead, and it should draw the same thing.

The second tangentialArc call takes an absolute point. We tell it to draw an arc from the current point to the start of the profile. This should remind you of how straight lines can use either end (relative) or endAbsolute.

Spirals

We can use tangential arcs to make a spiral too.

height = 100
startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> tangentialArc(angle = 180, radius = height)
  |> tangentialArc(angle = 180, radius = height * 1.1)
  |> tangentialArc(angle = 180, radius = height * 1.2)
  |> tangentialArc(angle = 180, radius = height * 1.3)
  |> tangentialArc(angle = 180, radius = height * 1.4)
  |> tangentialArc(angle = 180, radius = height * 1.5)
  |> tangentialArc(angle = 180, radius = height * 1.6)
  |> tangentialArc(angle = 180, radius = height * 1.7)

It should look like this:

A spiral made from many tangential arcs

This works because each tangentialArc is drawing half a circle, away from the previous arc, and the circle is getting slightly larger each time.

Circles

And lastly, let's look at the humble circle.

startSketchOn(XY)
  |> circle(center = [0, 0], radius = 10)

2D fallback: A simple circle

The circle call takes center and radius arguments. Note that circle closes itself without any need for a close() call. That's because a circle is inherently closed -- it always starts and ends its own profile.

Modeling 3D shapes

Previous chapters covered designing 2D shapes. Now it's time to design 3D shapes!

3D shapes are usually made by adding depth to a 2D shape. There are two common ways engineers do this: by extruding or revolving 2D shapes into 3D. There's some less common ways too, including sweeps and lofts. In this chapter, we'll go through each of these! Let's get started with the most common method: extruding.

Extrude

Extruding basically takes a 2D shape and pulls it up, stretching it upwards into the third dimension. Let's start with our existing 2D pill shape from the previous chapter:

height = 4
width = 8
pill = startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = width)
  |> tangentialArc(end = [0, height])
  |> xLine(length = -width)
  |> tangentialArc(endAbsolute = profileStart())
  |> close()

It should look like this:

2D pill, before extruding

Now we're going to extrude it up into the third axis, making a 3D solid.

height = 4
width = 8

// Add this line!
depth = 10

pill = startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = width)
  |> tangentialArc(end = [0, height])
  |> xLine(length = -width)
  |> tangentialArc(endAbsolute = profileStart())
  |> close()

  // Add this line!
  // This line transforms the 2D sketch into a 3D solid.
  |> extrude(length = depth)

You should see something like this:

The extrude function takes a distance, which is how far along the third axis to extrude. Every plane has a normal, or an axis which is tangent to the plane. For the plane XZ, this is the Y axis. This normal, or tangent, or axis perpendicular to the plane, is the direction that extrudes go along.

Sweep

An extrude takes some 2D sketch and drags it up in a straight line along the normal axis. A sweep is like an extrude, but the shape isn't just moved along a straight line: it could be moved along any path. Let's reuse our previous pill-shape example, but this time we'll sweep it instead of extruding it. First, we have to define a path that the sweep will take. Let's add one:

height = 4
width = 8
depth = 10
pill = startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = width)
  |> tangentialArc(end = [0, height])
  |> xLine(length = -width)
  |> tangentialArc(endAbsolute = profileStart())
  |> close()


// Create a path for the sweep.
sweepPath = startSketchOn(XZ)
  |> startProfile(at = [0.05, 0.05])
  |> line(end = [0, 7])
  |> tangentialArc(angle = 90, radius = 5)
  |> line(end = [-3, 0])
  |> tangentialArc(angle = -90, radius = 5)
  |> line(end = [0, 7])

2D fallback: A 2D pill shape and a path we're going to sweep it along

Now we'll add the sweep call, like swept = sweep(pill, path = sweepPath), which will drag our 2D pill sketch along the path we defined.

height = 4
width = 8
depth = 10
pill = startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = width)
  |> tangentialArc(end = [0, height])
  |> xLine(length = -width)
  |> tangentialArc(endAbsolute = profileStart())
  |> close()


// Create a path for the sweep.
sweepPath = startSketchOn(XZ)
  |> startProfile(at = [0.05, 0.05])
  |> line(end = [0, 7])
  |> tangentialArc(angle = 90, radius = 5)
  |> line(end = [-3, 0])
  |> tangentialArc(angle = -90, radius = 5)
  |> line(end = [0, 7])

// Sweep the pill along the path
swept = sweep(pill, path = sweepPath)

The sweep call has several other options you can set, so read its docs page for more information.

Revolve

Revolves are the other common way to make a 3D shape. Let's start with a 2D shape, like a basic circle.

startSketchOn(XZ)
  |> circle(center = [-200, 0], radius = 100)

2D fallback: A 2D circle before revolving.

The revolve function takes a shape and revolves it, dragging it around an axis. Let's revolve it around the Y axis (which is perpendicular to XZ, the plane we're sketching on), to make a donut shape.

startSketchOn(XZ)
  |> circle(center = [-200, 0], radius = 100)
  |> revolve(axis = Y)

There's an optional argument called angle. In the above example, we didn't provide it, so it defaulted to 360 degrees. But we can set it to 240 degrees, and get two thirds of a donut:

startSketchOn(XZ)
  |> circle(center = [-200, 0], radius = 100)
  |> revolve(axis = Y, angle = 240)

Spheres

You can make a sphere by revolving a semicircle its full 360 degrees. First, let's make a semicircle:

radius = 10
startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> yLine(length = radius * 2)
  |> arc(angleStart = 90, angleEnd = 270, radius = radius)

2D fallback: Sketching a semicircle

Then we can close() it and add a call to revolve(axis = Y, angle = 360) to revolve it into a sphere:

radius = 10
startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> yLine(length = radius * 2)
  |> arc(angleStart = 90, angleEnd = 270, radius = radius)
  |> close()
  |> revolve(axis = Y, angle = 360)

2D fallback: Revolving a semicircle makes a sphere

Lofts

All previous methods -- extrudes, sweeps, revolves -- took a single 2D shape and made a single 3D solid. Lofts are a little different -- they take multiple 2D shapes and join them to make a single 3D shape. A loft interpolates between various sketches, creating a volume that smoothly blends from one shape into another. Let's see an example:

// Sketch a square on the XY plane
squareSketch = startSketchOn(XY)
  |> startProfile(at = [-100, 200])
  |> line(end = [200, 0])
  |> line(end = [0, -200])
  |> line(end = [-200, 0])
  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
  |> close()

// Start a second sketch, 200 units above the XY plane.
circleSketch = startSketchOn(offsetPlane(XY, offset = 200))
  |> circle(center = [0, 100], radius = 50)

// Loft the square up and into the circle.
loft([squareSketch, circleSketch])

Note that we used the offsetPlane function to start the circle sketch 200 units above the XY plane. We'll cover offsetPlane more in the chapter on sketch on face. The loft function has a few other advanced options you can set. One of these is vDegree, which affects how smoothly KCL interpolates between the shapes. Take a look at these two examples, which are identical except for vDegree. This example uses vDegree = 1:

// Circle, 200 units below the XY plane.
circ0 = startSketchOn(offsetPlane(XY, offset = -200))
  |> circle(center = [0, 100], radius = 50)

// Square on the XY plane
squareSketch = startSketchOn(XY)
  |> startProfile(at = [-100, 200])
  |> line(end = [200, 0])
  |> line(end = [0, -200])
  |> line(end = [-200, 0])
  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
  |> close()

// Second circle, 200 units above the XY plane.
circ1 = startSketchOn(offsetPlane(XY, offset = 200))
  |> circle(center = [0, 100], radius = 50)

loftedSolid = loft([circ0, squareSketch, circ1], vDegree = 1)

The following loft is identical, but we set vDegree = 2. That's actually the default, so we don't need to set it, but for the sake of example we'll explicitly set it there.

// Circle, 200 units below the XY plane.
circ0 = startSketchOn(offsetPlane(XY, offset = -200))
  |> circle(center = [0, 100], radius = 50)

// Square on the XY plane
squareSketch = startSketchOn(XY)
  |> startProfile(at = [-100, 200])
  |> line(end = [200, 0])
  |> line(end = [0, -200])
  |> line(end = [-200, 0])
  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
  |> close()

// Second circle, 200 units above the XY plane.
circ1 = startSketchOn(offsetPlane(XY, offset = 200))
  |> circle(center = [0, 100], radius = 50)

loftedSolid = loft([circ0, squareSketch, circ1], vDegree = 2)

As you can see, the vDegree makes a big difference. You can view other options on the loft docs page.

Tags, Fillets and Chamfers

Motivation: Applying a fillet

When you manufacture a part, you often want to smooth off its sharp edges, so they're rounded and won't accidentally cut someone who holds it. Let's say we're modeling a cube, like this:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0])
  |> line(end = [0, length])
  |> line(end = [-length, 0])
  |> line(end = [0, -length])
  |> close()
  |> extrude(length = length)

It produces a cube like this:

What if we want to fillet one of its sides? Let's start simple and refer to one of the four bottom edges. Those edges were made by the four line calls. How can we refer back to them? Usually, to use some data in an operation, we just put that data into a variable, and pass it into a function. That won't work here, because the data is in a pipeline. So what do we do?

Tagging edges

Simple: we tag the line. A tag is a reference to some data. Let's declare our first tag. We'll modify the above program by adding a tag to one of the lines, like this:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $side)     // <- Add the `tag` argument here!
  |> line(end = [0, length])
  |> line(end = [-length, 0])
  |> line(end = [0, -length])
  |> close()
  |> extrude(length = length)

You declare a tag with a dollar sign, followed by its name, like $side. This is a new data type, called a TagDeclarator. TagDeclarators can be passed around just like any other kind of data (number, string, etc). Tagging a line is very similar to declaring a variable. Both tags and variables store data, which can be referenced later. Many KCL functions have an optional tag argument, including all the path-creating functions we've seen, like line, tangentialArc, xLine, etc.

Let's use this tag to make a fillet. Add the line |> fillet(radius = 5, tags = [side]) to the end of the previous program:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $side)
  |> line(end = [0, length])
  |> line(end = [-length, 0])
  |> line(end = [0, -length])
  |> close()
  |> extrude(length = length)
  |> fillet(radius = 5, tags = [side])

The fillet function accepts an argument tags, which expects an array of one or more tags. Note that we passed in side, not $side. The latter would be declaring a new tag, but we actually want to reference an existing tag. So we didn't use the $.

That program should produce a cube with one filleted edge, like this:

Nice! We could tag and fillet all four sides if we wanted to:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> fillet(radius = 5, tags = [a, c, b, d])

Relationships between edges

We've seen how to tag edges, and reference those tags later to alter edges. What about edges we don't create directly? For example, we've already filleted the four bottom edges, but how do we fillet the top four edges? We aren't creating them via line calls. They're created by the CAD engine in the extrude call. If we didn't explicitly create them with a sketch function, how do we tag them? Here's the secret --- you don't. KCL has a few helpful functions to access edges that you didn't create directly. Because we tagged the bottom edges, we can use helper functions like getOppositeEdge to reference the top edges, like this:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $side)
  |> line(end = [0, length])
  |> line(end = [-length, 0])
  |> line(end = [0, -length])
  |> close()
  |> extrude(length = length)
  |> fillet(radius = 5, tags = [side, getOppositeEdge(side)])

We can fillet all four top edges by tagging all four bottom edges, and then using getOppositeEdge on each:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> fillet(radius = 5, tags = [a, c, b, d, getOppositeEdge(a), getOppositeEdge(c), getOppositeEdge(b), getOppositeEdge(d)])

So, we've filleted the bottom horizontal edges, and the top horizontal edges. What about the vertical side edges, which connect the top and bottom face? We can use getNextAdjacentEdge and getPreviousAdjacentEdge to reference them:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> fillet(
       radius = 2,
       tags = [
         a,
         getNextAdjacentEdge(a),
         getPreviousAdjacentEdge(a)
       ],
     )

Here, we filleted the bottom side a just like we did before. But we've also filleted the sides adjacent to it. We can use a similar trick to fillet all four vertical side edges:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> fillet(
       radius = 2,
       tags = [
         getNextAdjacentEdge(a),
         getPreviousAdjacentEdge(a),
         getNextAdjacentEdge(c),
         getPreviousAdjacentEdge(c),
       ],
     )

Chamfers

A chamfer is just like a fillet, except that fillets smooth away an edge to make it round, but chamfers just make a single cut across an edge. Here's an example of the difference. Compare this chamfered cube with the filleted cubes above:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> chamfer(
       length = 2,
       tags = [
         getOppositeEdge(a),
       ],
     )

So we've learned to use tags to reference the lines we create, then use helper functions like getOppositeEdge to reference other geometry elsewhere in the model. But tags aren't just used for altering edges. They provide a valuable way to query and measure your models. Let's see how.

Measuring with tags

Let's say you've got a triangle, like this:

length = 20
startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0])
  |> line(end = [length, length * 2])
  |> line(endAbsolute = profileStart())

Let's ask a simple question. How long is each side of the triangle?

It sounds simple, but to actually calculate it, you'd have to break out a pencil and paper, then do some trigonometry. The problem is, the length doesn't appear anywhere in the line call. The lines are defined by their start and end points, and the length is an implicit property of those. Defining lines as a start and end is helpful, but it means important properties, like length, can't be read from our source code.

However, tags give us a simple way to refer to each line, and then query them for properties like length with the segLen function. Let's update our program:

length = 20
startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [length, length * 2], tag = $b)
  |> line(endAbsolute = profileStart(), tag = $c)

lenA = segLen(a)
lenB = segLen(b)
lenC = segLen(c)

Now you can open up the Variables pane and look at the lenA, lenB and lenC variables to find each side's length. That's pretty useful! And if you want to use those lengths elsewhere in your code, you can! You could start drawing lines where the end is [lenA, 0] for example, or plug those lengths into other calculations.

KCL has several other helper functions, like segAng, which helps you find the angle between two lines. Let's measure the angles in a right-angle triangle:

startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> xLine(length = 20)
  |> yLine(length = 10, tag = $b)
  |> line(endAbsolute = profileStart(), tag = $c)

angleB = segAng(b)
angleC = segAng(c)

You can open up the Variables panel and view the relevant angles! There are other helpers too, like segStart and segEnd to find a line's start and end, respectively. Take a look at the KCL standard library docs to find them all.

KCL's tagging system is simple, but powerful. It lets you build up a model (like a cube) from a simple flat shape (your square) and a transformation (like extrusion). Although the transformations create a lot of geometry (for instance, this single extrude call creates 8 edges and five faces), you don't need verbose, complicated labels for all of these features. Instead, you can tag the geometry you've explicitly created, and use simple functions like getOppositeEdge to reference related geometry. This is much easier than trying to label every edge and face in a model. In the next chapter, we'll explore more interesting uses of tags, like starting new sketches from existing 3D models.

Sketch on face

In the previous chapter, we looked at how KCL lets you tag edges. Tags let you query your edges (to find their length, or angle with the previous edge), or apply an edge cut (like a fillet or chamfer). But you can tag more than just edges! In this chapter, we'll learn how to tag faces, and how that lets you build more complicated 3D models.

Side faces

Let's start with a simple example. First, we'll sketch and extrude a triangle. We'll tag its second edge as b.

length = 20
triangle = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0])
  |> line(end = [length, length * 2], tag = $b)
  |> line(endAbsolute = profileStart())
  |> close()
  |> extrude(length = 40)

When our triangle is extruded, its 3 edges create 3 new side faces, one for each original edge. I like to imagine extrusion like an invisible hand grabbing the flat sketch and pulling it upwards into the third dimension, slowly stretching each edge until they expand to become faces. So, each new side face corresponds to an existing edge. And crucially, the faces share a tag with their parent edge. This means the face which grew out of the edge tagged b also has the tag b. We can use this to reference this face in our 3D model.

Now, if we want to start a new sketch on that face, we can do so!

sketch001 = startSketchOn(triangle, face = b)

Note that we previously passed a plane (like XY or YZ) into startSketchOn. But now, we're passing a solid (our extruded triangle) instead. The solid has five faces (three side faces, a bottom, and a top), so we tell startSketchOn which face in particular we want to sketch on. The face is tagged b (because it was created from an edge which was tagged b), so we just pass that in too. Now we can start sketching on this face, and even extrude that sketch too.

length = 20
triangle = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0])
  |> line(end = [length, length * 2], tag = $b)
  |> line(endAbsolute = profileStart())
  |> close()
  |> extrude(length = 40)

cylinder = startSketchOn(triangle, face = b)
  |> circle(radius = 10, center = [0, 15])
  |> extrude(length = 40)

Great! We extruded a solid (the triangle), and could sketch on one of its faces, even extruding that sketch.

Note: When you sketch on a face, the sketch uses the global coordinate system. This means when you use 2D points in your sketches, they're relative to the overall global scene, and not the face you're sketching on.

Sketching on faces is a really common pattern when designing real-world objects. A LEGO brick is a good example -- first you'd sketch the rectangular brick, then you'd sketch on its top face, adding the little bumps on top. But wait a second. How would we tell startSketchOn to sketch on the top face of the brick? That face isn't created from any particular edge. So we can't tag its line call and then reuse that tag for the face. What should we do?

Standard faces

There's a simple solution to sketching on the top face. KCL has some built-in identifiers for the top and bottom face, START and END. We prefer the terms "start" and "end" to "top" and "bottom" because the latter depend on your camera angle, so they can be ambiguous. "Start" always refers to the original face from your 2D sketch. "End" always refers to the new face created at the end of the extrusion. Let's use them!

length = 20
triangle = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0])
  |> line(end = [length, length * 2])
  |> line(endAbsolute = profileStart())
  |> close()
  |> extrude(length = 40)

cylinder = startSketchOn(triangle, face = END)
  |> circle(radius = 3, center = [0, -10])
  |> extrude(length = 40)

box = startSketchOn(triangle, face = START)
  |> polygon(radius = 8, numSides = 4, center = [0, -15])
  |> extrude(length = 10)

Great! These built-in face identifiers are always available on solids. We've learned how to sketch on the top, bottom and side faces. That covers all possible faces, right? Right? Not exactly! There's one more kind of face we haven't talked about yet.

Sketch on chamfer

When you chamfer an edge, it creates a new face, which can also be sketched on! Consider this chamfered cube from the previous chapter:

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> chamfer(
       length = 2,
       tags = [
         getOppositeEdge(a),
       ],
     )

The chamfer produced a new face, and we can sketch on it too. Firstly, we add a tag to the chamfer call, and then we can sketch on it like any other tagged face.

length = 20
cube = startSketchOn(XY)
  |> startProfile(at = [-length, -length])
  |> line(end = [length, 0], tag = $a)
  |> line(end = [0, length], tag = $b)
  |> line(end = [-length, 0], tag = $c)
  |> line(end = [0, -length], tag = $d)
  |> close()
  |> extrude(length = length)
  |> chamfer(
       length = 2,
       tags = [
         getOppositeEdge(a),
       ],
       tag = $chamferedFace
     )

startSketchOn(cube, face = chamferedFace)
  |> circle(radius = 1, center = [-length/2, 0])
  |> extrude(length = 40)

So far, we've sketched on standard planes (like XY), on tagged faces, and on standard faces like END. There's one more place you can start sketching on: a custom plane. Let's learn how.

Defining new planes

When you call startSketchOn(XY), you're passing a plane as the first argument. XY is a standard, built-in plane (remember, there are six -- XY, YZ, XZ, -XY, -YZ and -XZ). But you can easily define your own planes too! There's two ways:

Offset planes

You can use the offsetPlane function to copy any other plane, but moved some direction up or down the third axis. For example, let's draw a small circle on XY, a medium circle on a plane 10 units above it, and a big circle 20 units above it.

r = 10

startSketchOn(XY)
  |> circle(center = [0, 0], radius = r)
  |> extrude(length = 1)
  
startSketchOn(offsetPlane(XY, offset = 10))
  |> circle(center = [0, 0], radius = 2 * r)
  |> extrude(length = 1)
  
startSketchOn(offsetPlane(XY, offset = 20))
  |> circle(center = [0, 0], radius = 3 * r)
  |> extrude(length = 1)

Offset planes are a quick and easy way to create new planes by using some other plane as a template. But what if you want to create a plane that actually points in a different direction, i.e. has different axes? What if you wanted to create a plane that was pointing at an unusual angle from the global X Y and Z axes? Let's try it.

Custom planes

You can define your own plane with your own axes like this:

customPlane = {
  origin = { x = 0, y = 1, z = 0},
  xAxis = { x = 1, y = 0, z = 0 },
  yAxis = { x = 0, y = 0, z = 1 },
}

Note the custom plane has a few properties:

  • An origin, which is a 3D point in space, using the global coordinate system (i.e. it's relative to the overall scene)
  • X and Y axes, which are defined as vectors

The plane's Z axis is the cross product of its X and Y axes. It's uniquely determined, so you don't need to specify it.

Now let's use this custom plane in a sketch. We'll build two identical cylinders, but one is on the standard XY plane, and one is on the custom plane we defined above.

r = 10
startSketchOn(XY)
  |> circle(center = [100, 0], radius = r)
  |> extrude(length = 10)

customPlane = {
  origin = {
    x = 0,
    y = 0,
    z = 0
  },
  xAxis = { x = 1, y = 0.5, z = 0 },
  yAxis = { x = 0, y = 0.5, z = 1 }
}

startSketchOn(customPlane)
  |> circle(center = [0, 0], radius = r)
  |> extrude(length = 10)

Great! Custom planes give you a lot of power and flexibility. You can draw sketches in any orientation now. But they can be a bit verbose and complicated to define, so you should use offsetPlane if you've already defined a plane on the same X and Y axis. You can even use offsetPlane to offset a custom plane, like this:

// Make a custom plane.
customPlane = {
  origin = { x = 0, y = 1, z = 0},
  xAxis = { x = 1, y = 0, z = 0 },
  yAxis = { x = 0, y = 0, z = 1 },
}
// Now offset it 20 up its normal axis.
newPlane = offsetPlane(customPlane, offset = 20)

Now we've learned how to sketch on all sorts of things:

  • Standard planes like XY or -XZ
  • Tagged faces of existing solids
  • Top or bottom faces of solids, using START and END
  • Chamfered faces cut out of solids, by tagging the chamfer call
  • Custom planes (truly custom, or just offset from an existing plane)

This gives you a lot of flexibility in building your solids. Now it's time to learn what else we can do with these solids. The next chapter will teach you how to combine and transform them!

Transforming 3D solids

We've covered many different ways to create 3D solids from 2D sketches, but what can we do with our solids afterwards? In this chapter we'll cover how to combine them via union, intersection and subtraction. This is sometimes called constructive solid geometry. We'll also look at how to scale, rotate or translate them. But before we get to that, let's start with something a little fun:

Colour

So far, all our models have used the standard shiny grey metal appearance. But you can customize this! Let's change the texture. We'll make three cubes: one with the normal color, one green, and one a shiny metallic green.

offset = 25

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)

greenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset])
  |> extrude(length = 10)
  // The appearance call lets you set a color using hexadecimal notation.
  |> appearance(color = "#00ff00")
  
greenCubeShiny = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset * 2])
  |> extrude(length = 10)
  // You can also set the metalness and roughness, as percentages between 0 and 100.
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

2D fallback: Three cubes with different textures

The appearance call takes in three arguments, each of which is optional. You can provide:

  • A color as a hexadecimal number like #0044ff. The first two digits represent red, the next two green, and the last two blue. You can use an online color picker to play with the format.
  • A metalness percentage, which is a number between 0 and 100.
  • A roughness percentage, which is a number between 0 and 100.

This is helpful for making your different solids stand out from each other. We'll be using the appearance call in our examples to help make it clear which KCL snippets correspond to which objects in the rendered images.

Translation

We can transform solids, keeping them basically the same -- the same number of sides, edges, and faces -- but changing some of their other properties.

Firstly, we can translate them (shifting them around in their coordinate system), like this:

offset = 25

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)

brightGreenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset])
  |> extrude(length = 10)
  // Shift the shape's position along X, Y and Z.
  |> translate(x = 4, y = -4, z = 10)
  |> appearance(color = "#00ff00")
  
greenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset * 2])
  |> extrude(length = 10)
  // The translation axes are optional.
  // If you don't set X or Y, its X and Y position will remain the same.
  |> translate(z = -10)
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

2D fallback: Three translated cubes

2D fallback: Three translated cubes

The translate call takes three arguments, x, y and z. Each of them is optional. If you provide one, it'll shift the solid along that axis. If you don't provide an axis, it'll remain unchanged.

Scale

Next, we can scale them, making them bigger or smaller.

offset = 25

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)

brightGreenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset])
  |> extrude(length = 10)
  // Scale all three axes, shrinking the cube
  |> scale(x = 0.5, y = 0.5, z = 0.5)
  |> appearance(color = "#00ff00")
  
greenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset * 2])
  |> extrude(length = 10)
  // Expand the cube along one axis, shrink it across another, and leave
  // the third axis unchanged.
  |> scale(z = 0.25, y = 2)
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

2D fallback: Three scaled cubes

The scale call works similarly. You provide one or more axes -- if you don't provide an axis, it's left unchanged. Numbers less than 1 will shrink the solid (e.g. 0.25 means 1/4th its original size). Numbers larger than 1 will expand the solid (e.g. 4 means 4 times its original size).

Rotation

Lastly, we can rotate them. The rotate call is similar to translate and rotate: it takes a number of properties -- different ways to rotate -- all of which are optional, and if you don't provide one, it stays unchanged. These properties are roll, pitch and yaw.

Roll: Imagine spinning a pencil on its tip - that's a roll movement. Pitch: Think of a seesaw motion, where the object tilts up or down along its side axis. Yaw: Like turning your head left or right, this is a rotation around the vertical axis

Let's see an example:

offset = 25

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)

brightGreenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> translate(z = offset)
  |> rotate(roll= 45)
  |> appearance(color = "#00ff00")
  
greenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> translate(z = 2 * offset)
  |> rotate(pitch = 45)
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

blueCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> translate(z = 3 * offset)
  |> rotate(yaw = 45)
  |> appearance(color = "#0000ff", metalness = 90, roughness = 10)

2D fallback: Four rotated cubes

Note that these rotations are all around their own center (not the center of the plane).

Roll, pitch and yaw are one valid way to represent a rotation, but there are other ways too. You could also choose an axis, and rotate around that axis. For example, let's put 4 cubes at the same point, and then rotate them each a little bit around the axis.

angle = 15

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
    |> appearance(color = "#33ff00")

green1 = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> rotate(axis = [1, 0, 0], angle = angle)
  |> appearance(color = "#337700")

green2 = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> rotate(axis = [1, 0, 0], angle = angle * 2)
  |> appearance(color = "#334400")

green3 = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)
  |> rotate(axis = [1, 0, 0], angle = angle * 3)
  |> appearance(color = "#332200")

2D fallback: Four cubes rotated around the same axis

Using transformations

You can combine multiple transformations, for example a translate and scale: |> translate(x = 10) |> scale (y = 20). This can really simplify your mechanical engineering. For example, if you need to produce two cubes, rotated at different angles, which of these approaches sounds easier?

  1. Make one cube using polygon with 4 sides, and then design the other cube from scratch using line calls that join the 4 rotated points
  2. Make one cube using polygon, and then make a second cube by copying the first cube and adding a rotationcall

These transformations make your job easier by letting you reuse work from previous designs. Once you know how to sketch a cube, you don't need to recalculate your cube every time it needs to grow, rotate or get moved over. You can just use our simple transformation functions. Recalculating a cube each time is annoying, but possible. For more complicated geometry, with weird curves and many edges, redoing all your calculations to handle different scales and rotations can be very difficult and waste a lot of time! So don't recalculate them. Just reuse your work and transform it.

Combining 3D solids

We've seen how to make a lot of different solids. You could transform a 2D shape into a 3D solid. From there, you can copy and transform that 3D solid by rotating, translating or rotating it. Now it's time to learn a third way to build 3D solids: by combining other 3D solids. This is sometimes called constructive solid geometry and it's a very powerful tool for any serious mechanical engineering work.

Constructive solid geometry

Remember in school, when you learned about Venn diagrams? How you can take the union, the intersection or the difference of two shapes? If you need a quick recap, here's a screenshot from Wikipedia's article on boolean operations.

Union, intersection and complement on 2D circles

We can perform similar operations on 3D solids in KCL. Let's see how. Here's two cubes.

length = 20
cubeGreen = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> extrude(length = length)
  |> appearance(color = "#229922")

cubeBlue = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> translate(x = 10, z = 10)
  |> extrude(length = length)
  |> appearance(color = "#222299")

2D fallback: One green and one blue cube

That's what it looks like before we apply any CSG operations. Now let's see what happens when we use KCL's union, intersect and subtract functions on these. Firstly, let's do a union. This should create a new solid which combines both input solids.

length = 20
cubeGreen = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> extrude(length = length)
  |> appearance(color = "#229922")

cubeBlue = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> translate(x = 10, z = 10)
  |> extrude(length = length)
  |> appearance(color = "#222299")

// Boolean operations on the two cubes
both = union([cubeGreen, cubeBlue])

2D fallback: Two gray cubes just like the previous picture

Of course, this union of our two cubes has the exact same dimensions and position as the two cubes. So it looks the exact same. What's the point of doing this? Well, for a start, we can use transforms like appearance or rotate on the single unified shape. Previously we needed to transform each part separately, which can get annoying. Now that it's a single shape, transformations will apply to the whole thing -- both the first cube's volume, and the second cube's.

Note: Instead of writing union([cubeGreen, cubeBlue]) you can use the shorthand cubeGreen + cubeBlue or cubeGreen | cubeBlue. This is a nice little shorthand you can use if you want to.

Let's try an intersection. This combines both cubes, but leaves only the volume from where they overlapped.

length = 20
cubeGreen = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> extrude(length = length)
  |> appearance(color = "#229922")

cubeBlue = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> translate(x = 10, z = 10)
  |> extrude(length = length)
  |> appearance(color = "#222299")

// Boolean operations on the two cubes
both = intersect([cubeGreen, cubeBlue])

2D fallback: Intersection of the two cubes

This keeps only the small cube shape from where the previous two intersected. This is a new solid, so it can be transformed just like any other solid.

Note: Instead of writing intersect([cubeGreen, cubeBlue]) you can use the shorthand cubeGreen & cubeBlue. This is a nice little shorthand you can use if you want to.

Lastly, let's try a subtract call:

length = 20
cubeGreen = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> extrude(length = length)
  |> appearance(color = "#229922")

cubeBlue = startSketchOn(XY)
  |> polygon(radius = length, numSides = 4, center = [0, 0])
  |> translate(x = 10, z = 10)
  |> extrude(length = length)
  |> appearance(color = "#222299")

// Boolean operations on the two cubes
both = subtract(cubeGreen, tools=[cubeBlue])

2D fallback: Green cube with blue cube subtracted

Note that the syntax for subtract is a little different. The first argument is the solid which will have some volume carved out. The second argument is a list of solids to cut out. You can think of these as "tools" -- you're basically passing tools of various shapes which can carve out special volumes.

NOTE: Currently only one tool can be passed in, but we're nearly finished supporting multiple tools here.

Patterns

Real-world objects often have repeated parts. Consider a LEGO brick, which has a lot of repeated bumps on its top face. Or a table, with four repeated legs. KCL would be a very tedious language if we made you define each leg, or each LEGO bump, over and over again every time your model needed one. Luckily, there's a simple way to repeat geometry in your model. It's called a pattern. There are several ways to use patterns. Let's learn how they work!

Basic patterns

Let's start simple. We can use patterns to replicate our geometry, copying it into our scene several times. Let's take this simple cylinder, and copy it 4 times.

cylinders = startSketchOn(XY)
  |> circle(radius = 4, center = [0, 0])
  |> extrude(length = 10)
  |> patternLinear3d(instances = 4, distance = 10, axis = [1, 0, 0])

The patternLinear3d function takes 4 args:

  • A solid to pattern (the unlabeled first arg, which is implicitly set to % and therefore gets the cylinder piped in)
  • The total number of instances you want (i.e. how many total copies of the solid there should be)
  • How far apart each instance of the pattern should be
  • The axis along which to place the copies.

In our above example, [1, 0, 0] is the X axis, so it places 4 instance along the X axis, each 10 units apart.

Circular patterns

You can also use patterns to replicate something and lay them out in an arc around a point. We'll use the patternCircular3d function. Here's an example where we put 12 cubes in a circle:

offset = 40
cubes = startSketchOn(XZ)
  |> polygon(numSides = 4, radius = 10, center = [0, offset])
  |> extrude(length = 10)
  |> patternCircular3d(
       instances = 12,
       axis = [0, 1, 0],
       center = [0, 0, 0],
       arcDegrees = 360,
       rotateDuplicates = false,
     )

Here, the center of the pattern is [0, 0, 0]. We drew the first cube at the northernmost position (12 o'clock) and all the other instances were patterned around that center. Nice!

Notice that we used rotateDuplicates = false. As the name implies, this argument controls whether the duplicates get rotated, so that they're always facing the same way with regards to the center. If we set it to true, we get this:

offset = 40
cubes = startSketchOn(XZ)
  |> polygon(numSides = 4, radius = 10, center = [0, offset])
  |> extrude(length = 10)
  |> patternCircular3d(
       instances = 12,
       axis = [0, 1, 0],
       center = [0, 0, 0],
       arcDegrees = 360,
       rotateDuplicates = true,
     )

Of course, if we change the arcDegrees argument, we could pattern around only part of the circle instead. Let's do two thirds of the circle:

offset = 40
cylinders = startSketchOn(XZ)
  |> polygon(numSides = 4, radius = 10, center = [0, offset])
  |> extrude(length = 10)
  |> patternCircular3d(
       instances = 12,
       axis = [0, 1, 0],
       center = [0, 0, 0],
       arcDegrees = 240,
       rotateDuplicates = true,
     )

You can use patterns and sketch on face together, patterning an extrusion upon some base.

base = startSketchOn(XZ)
  |> circle(radius = 50, center = [0, 0])
  |> extrude(length = 10)

offset = 30
boxes = startSketchOn(base, face = END)
  |> circle(radius = 5, center = [0, offset])
  |> extrude(length = 10)
  |> patternCircular3d(
       instances = 6,
       axis = [0, 1, 0],
       center = [0, 0, 0],
       arcDegrees = 360,
       rotateDuplicates = true,
     )

Transform patterns

Circular and linear patterns cover a lot of really common use-cases for mechanical engineers. But sometimes you want to do more complicated patterns, in more complicated shapes. We can't add a dedicated pattern function for every single shape our users can think of -- that would be ridiculous. Instead, we've got a powerful, flexible interface for patterning solids in any arrangement you can think of. It's called a transform pattern. They're created with the patternTransform function. It takes a familiar instances arg, which controls how many total copies of the shape you want. But it takes a new argument, called transform. This is a custom function. We'll dive deeper into those in the following chapters, but for now, they're basically just a way to calculate how to transform each replica in the pattern.

When might you need a pattern transform? Here's one use: to do a 2D pattern, like tiling a grid. Let's use a pattern transform to make a 5 by 5 grid.

n = 5
width = 10
gap = 1.5 * width

// Transform function
fn grid(@i) {
  column = rem(i, divisor = n)
  row = floor(i / n)
  return { translate = [column * gap, row * gap, 0] }
}

startSketchOn(XY)
  |> polygon(numSides = 4, radius = width, center = [0, 0])
  |> extrude(length = 2)
  |> rotate(yaw = 45)
  |> patternTransform(instances = n * n, transform = grid)

We've defined a custom function called grid. This function will get called once for every replica in the pattern, and it tells KCL how each replica should be transformed. Specifically it:

  • Takes a single argument called i. It's used to indicate which number replica it is. The first copy made will set i to 1, the second copy will set i to 2, etc etc. The argument i is prefixed with @ to indicate it's this function's special first unlabeled arg, so if you call it, you'd call it like grid(1) or grid(2), not grid(i = 1).
  • Returns a list of different properties to transform in each replica.

In this example, we declare a function grid which tells patternTransform to translate each replica by a certain amount column * gap along X axis, row * gap along the Y axis, and to stay on the same Z axis (i.e. move exactly 0 along that axis).

The specific value of row and column changes every time the grid function is called, because these variables are calculated from the input argument i. Remember, i represents which number replication we're transforming. To calculate column and row we're going to use a few new KCL functions we haven't seen before.

Firstly, rem. The value rem(i, divisor = n) will divide i by n and return the remainder. This means that for i = 0, 1, 2, 3, 4, x will equal 0, 1, 2, 3 and 4. But when i = 5 (i.e. the fifth copy is being calculated), x will be 0. We're calling this function 25 times, and over those calls, x will step from 0 to 4, jump back down to 0, and begin stepping up again. This means x is a good way to calculate the columns, which range from column 0 to column 4 (a total of 5 columns).

The floor function takes a fractional number, and rounds it down to the nearest integer. For example, floor(3.6) is 3. This means it's a good way to calculate the row, because the first five times it's called, row will always equal 0. It'll round down (i / n) from 0/5, 1/5, 2/5, 3/5, 4/5 all down to 0. Then the sixth time it's called, it will receive 5/5, which is 1, and round it down to 1. These neat little mathematical tricks mean we can calculate the row and column from the repetition number i.

The final result speaks for itself:

We can transform each replica in other ways, too. For example, we can skip a replica altogether! Let's make a chessboard pattern, where we skip every second tile.

n = 5
width = 10
gap = 1.5 * width

// Transform function
fn chessboard(@i) {
  row = rem(i, divisor = n)
  column = floor(i / n)
  isEven = rem(i, divisor = 2) == 0
  return [{ translate = [row * gap, column * gap, 0], replicate = isEven }]
}

startSketchOn(XY)
  |> polygon(numSides = 4, radius = width, center = [0, 0])
  |> extrude(length = 2)
  |> rotate(yaw = 45)
  |> patternTransform(instances = n * n, transform = chessboard)

In this example, we use a very similar transform function. The only difference is, we're setting the replicate property on the final transform too. And we're setting it to the variable isEven. This variable is a boolean value -- it's true if i divided by 2 has a remainder of 0, which is the definition of an even number (it's divisible by 2). This should skip every second replication. Let's try it out!

Here's another example, with some different transform properties being set.

width = 20

fn transform(@i) {
  return {
    // Move down each time.
    translate = [0, 0, -i * width],
    // Make the cube longer, wider and flatter each time.
    scale = [
      pow(1.1, exp = i),
      pow(1.1, exp = i),
      pow(0.9, exp = i)
    ],
    // Turn by 15 degrees each time.
    rotation = { angle = 15 * i, origin = "local" }
  }
}

cube = startSketchOn(XY)
|> startProfile(at = [0, 0])
|> polygon(numSides = 4, radius = width, center = [width, width])
|> extrude(length = width)

cube |> patternTransform(instances = 25, transform = transform)

In this example, we make 25 cubes, slightly transforming each one. Each cube gets translated (moving down along the Z axis), and scaled (becoming longer, wider and flatter), as well as rotating 15 degrees around its own center (i.e. its local origin). We could rotate them around the scene's center by using origin = "global". Here's the result.

The transform functions we've used so far each return a single transform. But if you'd like, they can return an array of transforms. Each transform in the array will get executed in order. This is helpful for simplifying some of your math calculations. Sometimes it's easier to formulate a transformation as a rotate, then a translate, then rotating back, rather than trying to calculate the perfect translation all at once.

Pattern transforms are a very powerful tool. They're definitely one of the most complex function in KCL, but that complexity gives you a lot of flexibility. Any mathematical curve you can formulate can be used to pattern your instances, by just calculating it in a transform function. The same goes for tiling or grid arrangements. For more examples, you can read the full patternTransform docs.

2D patterns and holes

So far all of the patterns we've used have replicated 3D solids. But you can use patterns to replicate 2D sketches too. The patternLinear2d, patternCircular2d and patternTransform2d functions work like their 3D variants, except they take 2D axes and 2D points. Here's a simple example:

manyCircles = startSketchOn(XZ)
  |> circle(radius = 4, center = [50, 0])
  |> patternCircular2d(
       center = [0, 0],
       instances = 12,
       arcDegrees = 360,
       rotateDuplicates = true,
     )

Now, you could use these 2D patterns as the basis for 3D solids, by extruding or revolving them. You can see this by adding the line extrude(manyCircles, length = 10) to the end of the above KCL program. But it's not a good idea, because it produces the exact same model as you would have gotten from making a single 3D solid, then using 3D patterns on that. The only difference is, extruding a 2D pattern is much slower than patterning a 3D solid. So, can we do anything useful with 2D patterns?

Yes! One important use case is putting holes into 2D sketches. We have a special subtract2d function for this. Let's take the pattern from above, and use it to cut holes into another sketch.

manyCircles = startSketchOn(XZ)
  |> circle(radius = 4, center = [50, 0])
  |> patternCircular2d(
       center = [0, 0],
       instances = 12,
       arcDegrees = 360,
       rotateDuplicates = true,
     )

base = startSketchOn(XZ)
  |> circle(radius = 60, center = [0, 0])
  |> subtract2d(tool = manyCircles)
  |> extrude(length = 10)

This could be done with CSG, but it's faster to produce the 2D sketch you want, then do a simple extrude, rather than doing the extrude and then many CSG operations. Full 3D CSG operations are mathematically difficult to calculate compared to simple 2D operations, so if you see the chance to use a simple subtract2d, you should consider it.

Functions and parametric design

In mechanical engineering, parametric design is a key tool that helps you avoid redundant work when you're designing the same object over and over again with slight tweaks. In software engineering, functions are a key tool that help you avoid redundant work when you're designing the same software over and over again with slight tweaks.

That's right -- breaking a mechanical engineering project into several key parametric designs is basically the same as breaking a software engineering project into several key functions. KCL makes parametric design easy and convenient with functions. You'll declare functions to represent parametric designs, and you'll call those functions with specific arguments to produce specific designs with the right parameters. Let's see how.

Function declaration

We briefly looked at function declarations when we covered pattern transforms. Let's write an example function declaration and analyze its parts.

fn add(a, b) {
  sum = a + b
  return sum
}

A function declaration has a few key parts. Let's look at each one, in the order they appear:

  • The fn keyword
  • The function's name
  • Round parentheses ( and )
  • Within those parentheses, a list of argument names
  • Curly brackets { and }
  • Within those brackets, KCL code, which may end with a return statement.

This function takes two arguments, a and b, adds them, and returns their sum as the function's output. When a function executes the return statement, it evaluates the expression after return, stops executing, and outputs that value. You can call our example function like this:

sum = add(a = 1, b = 2)

Functions can also declare one unlabeled arg. If you do want to declare an unlabeled arg, it must be the first arg declared. When declaring an unlabeled arg, prefix it with @, like here:

// The @ indicates an argument can be used without a label.
// Note that only the first argument can use @.
fn increment(@x) {
  return x + 1
}

fn add(@x, delta) {
  return x + delta
}

two = increment(1)
three = add(1, delta = 2)

Mechanical engineering with functions

Let's use functions to build a parametric pipe flange. We can start with a specific design, with specific direct measurements. Then we'll learn how to parameterize it. Then we can easily make a lot of similar pipe flanges with different parameters.

Here's a specific model. It's got 8 unthreaded holes, each with a radius of 4, and the overall model has a radius of 60. It's 10mm thick.

holes = startSketchOn(XZ)
  |> circle(radius = 4, center = [50, 0])
  |> patternCircular2d(
       center = [0, 0],
       instances = 8,
       arcDegrees = 360,
       rotateDuplicates = true,
     )

base = startSketchOn(XZ)
  |> circle(radius = 60, center = [0, 0])
  |> subtract2d(tool = holes)
  |> extrude(length = 10)

Its specific measurements, like number of holes, radius, thickness etc were chosen somewhat arbitrarily. What if we want to make another pipe flange in the future, with different measurements? We can turn this specific flange model into a parametric design by making it into a function. We'll define a function flange which takes in several parameters. Let's see:

// Define a parametric flange
fn flange(numHoles, holeRadius, radius, thickness, holeEdgeGap) {
  holes = startSketchOn(XZ)
    |> circle(radius = holeRadius, center = [radius - holeEdgeGap, 0])
    |> patternCircular2d(
         center = [0, 0],
         instances = numHoles,
         arcDegrees = 360,
         rotateDuplicates = true,
       )

  return startSketchOn(XZ)
    |> circle(radius = radius, center = [0, 0])
    |> subtract2d(tool = holes)
    |> extrude(length = thickness)
}

We can get our original flange by calling the parametric flange with the right parameters:

// Call our parametric flange function, passing in specific parameter values, to make a specific flange.
flange(
  numHoles = 8,
  holeRadius = 5,
  radius = 60,
  thickness = 10,
  holeEdgeGap = 10,
)

But we can also make a range of other flanges! Here's one:

flange(
  numHoles = 4,
  holeRadius = 15,
  radius = 60,
  thickness = 20,
  holeEdgeGap = 20,
)

A different instantiation of the parametric flange

And let's try one more:

flange(
  numHoles = 20,
  holeRadius = 3,
  radius = 90,
  thickness = 20,
  holeEdgeGap = 15,
)

Another pipe flange

Replacing specific KCL code for a specific design with a parametric function gives you the flexibility to generate a lot of very similar designs, varying their parameters by passing in different arguments to suit whatever your project's requirements are.

Repeating geometry with functions

Functions can also be used to avoid writing the same code over and over again, in a single model. In an earlier chapter we modeled three cubes in one scene, like this:

offset = 25

greyCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, 0])
  |> extrude(length = 10)

greenCube = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset])
  |> extrude(length = 10)
  // The appearance call lets you set a color using hexadecimal notation.
  |> appearance(color = "#00ff00")
  
greenCubeShiny = startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> polygon(radius = 10, numSides = 4, center = [0, offset * 2])
  |> extrude(length = 10)
  // You can also set the metalness and roughness, as percentages between 0 and 100.
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

2D fallback: Three cubes with different textures

This code works fine, but it's got one small problem. We're repeating the code for "sketch a cube" three times. This makes it a bit annoying to read, and also, if we want to tweak the cubes (making them larger, or rotating them), we'd have to update them in three different places. We could improve this code by making a function for the cube, and calling that function three times.

fn cube(offset) {
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(radius = 10, numSides = 4, center = [0, offset])
    |> extrude(length = 10)
}

greyCube = cube(offset = 0)

greenCube = cube(offset = 25)
  |> appearance(color = "#00ff00")
  
greenCubeShiny = cube(offset = 50)
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

This code produces the exact same model as the above code, but it's shorter and easier to read. It's also more maintainable! If we wanted to change the cubes to be flatter, we only have to change one part of our code, instead of changing all three.

fn cube(offset) {
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(radius = 10, numSides = 4, center = [0, offset])
    // Change the extrude length from 10 to 2, shortening the cubes.
    |> extrude(length = 2)
}

greyCube = cube(offset = 0)

greenCube = cube(offset = 25)
  |> appearance(color = "#00ff00")
  
greenCubeShiny = cube(offset = 50)
  |> appearance(color = "#00ff00", metalness = 90, roughness = 10)

2D fallback: Three flat cubes with different textures

If we hadn't made the cube into a function, we would have had to change every extrude call separately. By putting the details of "what does a cube look like" in a single function, we make our code both more readable, and easier to change in the future.

Iterating with map and reduce

Every programming language has a way to do iteration: to repeat the same task many times. Traditional programming languages like Python, JavaScript or C use loops. KCL doesn't have any loops, but we have something very similar: arrays, and two helper functions called map and reduce. Let's see how they can solve problems.

Transforming arrays with map

The map function lets you transform an array by calling a function on every element. For example:

inputArray = [1, 2, 3, 4]
fn squareNumber(@x) { return x * x }
outputArray = map(inputArray, f = squareNumber)

The map function takes an input array as its first argument, then a function (its label is abbreviated to just f). It calls the function on every element of the input array, and returns it. If you open the Variables pane, you'll see that outputArray is [1, 4, 9, 16], just as we expected.

You can use map to create geometry too! For example, let's make 3 cubes, next to each other.

fn cube(@offset) {
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(radius = 10, numSides = 4, center = [0, offset])
    |> extrude(length = 10)
}

offsets = [0, 25, 50]
cubes = map(offsets, f = cube)

2D fallback: Three mapped cubes

We created an array of offsets, then called the cube function on each offset in the array. The final result is an array of cubes. Calling the cube function drew the three cubes, each at their own offset.

So far so good. But this is basically just a 3D pattern. We can make this more interesting by making each cube a different color. Instead of an array of offsets, we'll store an array of offsets and colors. To do this, we'll make a KCL object. An object has multiple properties, each with its own label and value. For example:

myObject = {
  offset = 25,
  color = "#00ff00",
}

This object has two fields, offset and color. You could access them by calling myObject.offset and myObject.color. Let's see how we can use this with map:

fn cube(@params) {
  offset = params.x
  color = params.color
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(radius = 10, numSides = 4, center = [0, offset])
    |> extrude(length = 10)
    |> appearance(color = color)
}

offsets = [
  { x = 0, color = "#99ff99" },  // Dark green
  { x = 25, color = "#00ff00" }, // Bright green
  { x = 50, color = "#002200" }, // Pale green
]

map(offsets, f = cube)

2D fallback: Three mapped cubes of different colors

Remember, map takes in an array, and outputs an array. The arrays always have the same length. Item x in the input array will be f(x) in the output array, where f is whichever function you pass in.

Anonymous functions

It can get annoying defining a new function every time you want to use map. For instance, in the earlier example where we defined a fn squareNumber to use in a map -- is that really necessary? If you have a lot of map calls, you'll slowly find your code becoming littered with tiny functions that you only use in a map.

KCL supports a nice little feature that can simplify this: anonymous functions. They're functions that don't have a name. You declare them where you need them, they're passed into mapand they aren't available after. Let's have a look:

inputArray = [1, 2, 3, 4]
outputArray = map(inputArray, f = fn (@x) { return x * x })

In this variation, we're passing in an anonymous function as the argument f of map. Just like before, it takes a single input argument x, squares it, and returns it. It should produce the exact same output as the earlier example with a function named squareNumber.

You can choose to use either named or anonymous functions with map. Neither is better or worse, you can use whichever you prefer. Generally, if a function is only a single line long, and you're only going to call it once (in a map or something similar), then you should consider making it anonymous and passing it as an argument directly.

Consuming arrays with reduce

The map function lets you iterate over an array, producing another array with the same length. But what if you don't want to get an array out? For example, what if you want to sum an array, or find the average?

The answer is: use the reduce function. This function, like map, takes an array and a function, then it calls the function on every element in the array. The difference is:

  • In map, the function argument f takes a single arg: the array's item being processed, often called i.
  • In reduce, the function argument f takes two args: the array's item being processed i as well as a second value that accumulates across the array. It's called accum, short for reduce.

Let's see an example:

inputArray = [1, 2, 3, 4]
sum = reduce(inputArray, initial = 0, f = fn(@i, accum) { return i + accum }) 

If you open sum in the Variables pane, you'll see it's 10, as we expect. How does this work? Let's break it down and see what happens in each step of the reduce.

  • The reduce starts. It sets accum to its initial value, which is the initial = 0 arg. So, accum starts at 0.
  • Reduce starts iterating over the array.
  • The first item is 1. Reduce calls f, passing i=1 and accum=0. Then f returns 1+0, or 1. This becomes the new value of accum.
  • The next item is 2. Reduce calls f, passing i=2 and accum=1. Then f returns 2+1, or 3. This becomes the new value of accum.
  • The next item is 3. Reduce calls f, passing i=3 and accum=3. Then f returns 3+3, or 6. This becomes the new value of accum.
  • The next item is 4. Reduce calls f, passing i=4 and accum=6. Then f returns 4+6, or 10. This becomes the new value of accum.
  • There's no more array items to handle, so reduce returns the last accumulated value, 10.

That's how reduce can take a long list of items and reduce it to a single item, accumulating the answer as it goes through the array.

What are some other things we can do with reduce? We could calculate the product of an array:

reduce(inputArray, initial = 1, f = fn(@i, accum) { return i * accum})

In the next chapter we'll cover one of the most powerful uses for reduce: dynamically building up geometry.

Reduce and geometry

The reduce function lets us iterate over an array, consuming its contents and reducing them down to one single item. Reduce is a very powerful, flexible tool. It can be complex too, but that complexity lets us do some very interesting things.

For example: how would you write a KCL function that produces an n-sided polygon? This is an ambitious project, so let's start with something simpler, and build back up to an n-sided polygon.

Sketching a square with reduce

Can we use reduce to make a square function, first? Once we've done that, we can make a parametric sketchPolygon function that works like square when the number of sides is 4, but can just as easily produce hexagons, octagons, triangles, etc.

fn square(sideLength) {
  emptySketch = startSketchOn(XY)
    |> startProfile(at = [0, 0])
  angle = 90
  fn addOneSide(@i, accum) {
    return angledLine(accum, angle = i * angle, length = sideLength)
  }
  return reduce([1..4], initial = emptySketch, f = addOneSide)
    |> close()
}

square(sideLength = 10) |> extrude(length = 1)

What's going on here? Let's break it down. We declare fn square which takes one argument, the sideLength. We create an initial empty sketch (at [0, 0] on the XY plane), and declare that the angle is 90.

Next, we declare a fn addOneSide. It takes in two arguments: i, which represents the index of which side we're currently adding, and accum, which is the sketch we're adding it to. This function adds one angled line to the sketch. The line's side is whatever side length was given, and its angle is 90 times i. So, the first line will have an angle of 90, the second 180, the third 270, and the last 360.

Then we call reduce, passing in the array [1, 2, 3, 4], setting the initial accumulator value to the empty sketch we started above, and calling addOneSide every time the reduce handles an array item. When reduce runs, it:

  • Starts accum as the empty sketch
  • Handles the first item, i = 1, calls addOneSide, which takes the previous accumulated sketch (currently empty) and adds an angled line at 90 degrees. This becomes the next accumulated sketch.
  • Handles the second item, i = 2, calls addOneSide, which takes the previous accumulated sketch (with a single line) and adds an angled line at 180 degrees. This becomes the next accumulated sketch.
  • For i = 3, it takes the accumulated sketch with two lines, and adds a third line, similar to the previous step.
  • For i = 4, it takes the accumulated sketch with three lines, and adds a fourth line, similar to the previous step.

Thus it builds up a square.

Sketching a parametric polygon with reduce

OK! We've seen how to use reduce to add lines to an empty sketch. We're ready to make our polygon function. Although, KCL already has a polygon function in the standard library. So, to avoid clashing with the existing name, we'll call ours sketchPolygon.

We can start with our square function and generalize it. First, we'll add an argument for the number of lines.

fn sketchPolygon(@numLines, sideLength) {
}

We can use the same initial empty sketch. We'll have to change angle, because it won't be 90 anymore. The angle now depends on how many edges the shape has:

angle = 360 / numLines

And lastly, our reduce call will need to take an array of numbers from 1 to numLines, not 1 to 4. So we'll use [1..numLines] as the first argument to reduce.

Let's put all that together:

fn sketchPolygon(@numLines, sideLength) {
  initial = startSketchOn(XY)
    |> startProfile(at = [0, 0])
  angle = 360 / numLines
  fn addOneSide(@i, accum) {
    return angledLine(accum, angle = i * angle, length = sideLength)
  }
  finished = reduce([1..numLines], initial = initial, f = addOneSide)
  return finished |> close()
}

sketchPolygon(7, sideLength = 10) |> extrude(length = 1)

Reduce can be a very powerful tool for repeating paths in a sketch. We hope to simplify this in the future. It's easy to dynamically repeat 2D shapes or 3D solids with pattern2D and pattern3D, so we hope to add a pattern1D eventually, so that these complicated reduces won't be necessary. Until then, reduce can be a good way to implement tricky functions like sketchPolygon.

Repeating geometry with reduce

Let's look at another way to use reduce. Say you're modeling a comb, with a parametric number of teeth. We can use reduce to solve this again:

fn comb(teeth, sideLength) {
  toothAngle = 80
  handleHeight = 4
  initial = startSketchOn(XY)
    |> startProfile(at = [0, 0])

  // Sketches a single comb tooth
  fn addTooth(@i, accum) {
    // Line going up
    return angledLine(accum, angle = toothAngle, length = sideLength)
      // Line going down
      |> angledLine(angle = -toothAngle, length = sideLength)
  }
  allTeeth = reduce([1..teeth], initial = initial, f = addTooth)
  finalComb = allTeeth
    // Add the handle: a line down, across, and back up to the start.
    |> yLine(length = -handleHeight)
    |> xLine(endAbsolute = 0)
    |> yLine(endAbsolute = 0)
    |> close()
  return finalComb
}

comb(teeth = 10, sideLength = 10) |> extrude(length = 1)

We write a function addTooth which adds a tooth (going up, then back down) to a sketch. Using reduce, we can call that function teeth times. Each time, the new tooth gets appended to the end of the sketch path. Once we've drawn all the teeth, we draw a simple handle leading back to the start.

To wrap up, reduce is a powerful way to make parametric designs, repeating geometric features as many times as you need. You can design parametric polygons with a variable number of sides, or repeat geometry linearly (like we did for our comb). You can even make parametric gears, take a look at the KCL samples for examples.

Checking your work

Correctness matters a lot in engineering. KCL tries to give you the tools you need to check your own work, and make sure your designs do exactly what you expect. In this chapter we'll explore ways to verify your KCL code does what you intended it to do.

Querying calculations

We've already covered this one, so I'll keep this brief. If you want to double-check your calculations, you can always assign some intermediate value to a variable, then view its value in the Variables panel in Zoo Design Studio. We saw an example of this earlier, where we broke up the quadratic equation into parts and inspected them. You can break complicated calculations into smaller parts, storing them in variables so they can be inspected individually.

Querying geometry

You can also query distances and angles, by measuring them with tags. Then you can assign those values to variables and read them off the Variables panel. We covered this previously, so refer back to that link for more detail.

Comments

Putting a comment in your code helps explain why you're doing something. Why was this edge double the size of the previous edge? Why was this particular curvature or angle chosen? Leaving comments in your code, like // Edge must be 1cm longer than front edge to accommodate button, helps explain to your colleagues why a certain decision was made. That way, when someone revisits the design in the future, they know if your original assumptions still hold, or if they can be changed.

Asserts

KCL's assert function lets you check that a certain variable has the expected value. In other words, you're asserting that the variable actually has the value you expect. If this assertion is wrong, KCL will stop executing and explain why. For example, let's add asserts to our quadratic equation calculations. We can calculate that the quadratic 2x^2 + 3x + 1 should have roots -0.5 and -1. Let's calculate it, and asserts to make sure we got the calculations correct.

// Coefficients that define the quadratic
a = 2
b = 3
c = 1

delta = pow(b, exp = 2) - (4 * a * c)
x0 = ((-b) + sqrt(delta)) / (2 * a)
x1 = ((-b) - sqrt(delta)) / (2 * a)

// Assert the two roots are what we expect.
assert(x0, isEqualTo = -0.5)
assert(x1, isEqualTo = -1)

This program was written correctly, so the KCL program runs just fine. But let's pretend we made a typo. We'll change c = 1 to c = 0, and rerun the program. You'll see the assert has an error now: "assert failed: Expected 0 to be equal to -0.5 but it wasn't".

Asserts work pretty similarly to looking up a variable in the Variables panel. Both let you check a value is what you expect. But asserts have two big advantages over checking the Variables panel.

Firstly, asserts are automatic. You can add asserts to your KCL code, and then every time the program runs, the asserts will get checked. You don't have to remember which values need checking. If you send your KCL file to a coworker, you don't have to explain "Hey, make sure to check that maximumStress is 1.25". You can just add the assert to your code, and all your coworkers will benefit from having the assertion checked. This is especially helpful for iterating on your design. You can start your design work by adding some asserts to check key requirements, and then as you keep tweaking and improving your design, KCL will automatically run all the asserts, and make sure the same properties are always checked.

Secondly, asserts are more flexible. You can check many different properties besides just checking two numbers are equal. For example, you can compare them and check if one number is greater or less than your variable:

wallMountingHoleDiameter = .625
wallMountLength = 2.25
assert(wallMountLength, isGreaterThanOrEqual = wallMountingHoleDiameter * 3)

You can add custom error messages to explain why this assertion is important:

wallMountingHoleDiameter = .625
wallMountLength = 2.25
assert(wallMountLength, isGreaterThanOrEqual = wallMountingHoleDiameter * 3, error = "This doesn't leave enough room for a hole. Either decrease hole diameter or increase wallMountLength")

You can also add a tolerance to your equality checks, to ensure that tiny little differences caused by floating-point math or approximation calculations don't trigger the error.

assert(1.0000001214, isEqualTo = 1.0, tolerance = 0.001)

Asserts and parametric design

For a realistic example of asserts, see this bracket we modelled in KCL. You can see the mechanical engineer designed this parametrically. They designed the bracket in terms of parameters like width, p (the force on the shelf), shelfMountLength etc. From these initial parameters, they calculate other quantities, like the moment or the thickness. Then they use asserts to make sure that when the parameters are changed, the results are still sensible. For example, they check that the parameters leave enough of a gap between the holes and the bracket's edge. They're also checking that the bracket is strong enough, by checking the actual stress on the model for these parameters (actualSigma) is below the maximum allowed stress, via assert(actualSigma, isLessThanOrEqual = sigmaAllow).

// Shelf Bracket
// This is a bracket that holds a shelf. It is made of aluminum and is designed to hold a force of 300 lbs. The bracket is 6 inches wide and the force is applied at the end of the shelf, 12 inches from the wall. The bracket has a factor of safety of 1.2. The legs of the bracket are 5 inches and 2 inches long. The thickness of the bracket is calculated from the constraints provided.

// Set units
@settings(defaultLengthUnit = in, kclVersion = 1.0)

// Define parameters
sigmaAllow = 35000 // psi (6061-T6 aluminum)
width = 5.0
p = 300 // Force on shelf - lbs
fos = 1.2 // Factor of safety of 1.2
shelfMountLength = 5.0
wallMountLength = 2.25
shelfDepth = 12 // Shelf is 12 inches deep from the wall
shelfMountingHoleDiameter = .50
wallMountingHoleDiameter = .625

// Calculated parameters
moment = shelfDepth * p // assume the force is applied at the end of the shelf
thickness = sqrt(moment * fos * 6 / (sigmaAllow * width)) // required thickness for two brackets to hold up the shelf
bendRadius = 0.25
extBendRadius = bendRadius + thickness
filletRadius = .5
shelfMountingHolePlacementOffset = shelfMountingHoleDiameter * 1.5
wallMountingHolePlacementOffset = wallMountingHoleDiameter * 1.5

// Compute bending stress, rectangular section. 
// See: https://www.omnicalculator.com/construction/bending-stress
momentOfInertia = (width * thickness^3)/12 // b.d^3 / 12
perpDistNeutralAxis = thickness/2 // c

actualSigma = (moment * perpDistNeutralAxis) / momentOfInertia

assert(actualSigma * fos, isLessThanOrEqual = sigmaAllow)

// Add checks to ensure bracket is possible. These make sure that there is adequate distance between holes and edges.
assert(wallMountLength, isGreaterThanOrEqual = wallMountingHoleDiameter * 3, error = "Holes not possible. Either decrease hole diameter or increase wallMountLength")
assert(shelfMountLength, isGreaterThanOrEqual = shelfMountingHoleDiameter * 5.5, error = "wallMountLength must be longer for hole sizes to work. Either decrease mounting hole diameters or increase shelfMountLength")
assert(width, isGreaterThanOrEqual = shelfMountingHoleDiameter * 5.5, error = "Holes not possible. Either decrease hole diameter or increase width")
assert(width, isGreaterThanOrEqual = wallMountingHoleDiameter * 5.5, error = "Holes not possible. Either decrease hole diameter or increase width")

// Create the body of the bracket
bracketBody = startSketchOn(XZ)
  |> startProfile(at = [0, 0])
  |> xLine(length = shelfMountLength - thickness, tag = $seg01)
  |> yLine(length = thickness, tag = $seg02)
  |> xLine(length = -shelfMountLength, tag = $seg03)
  |> yLine(length = -wallMountLength, tag = $seg04)
  |> xLine(length = thickness, tag = $seg05)
  |> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $seg06)
  |> close()
  |> extrude(length = width)

// Add mounting holes to mount to the shelf
shelfMountingHoles = startSketchOn(bracketBody, face = seg03)
  |> circle(
       center = [
         -(bendRadius + shelfMountingHolePlacementOffset),
         shelfMountingHolePlacementOffset
       ],
       radius = shelfMountingHoleDiameter / 2,
     )
  |> patternLinear2d(instances = 2, distance = -(extBendRadius + shelfMountingHolePlacementOffset) + shelfMountLength - shelfMountingHolePlacementOffset, axis = [-1, 0])
  |> patternLinear2d(instances = 2, distance = width - (shelfMountingHolePlacementOffset * 2), axis = [0, 1])
  |> extrude(%, length = -thickness - .01)

// Add mounting holes to mount to the wall
wallMountingHoles = startSketchOn(bracketBody, face = seg04)
  |> circle(
       center = [
         wallMountLength - wallMountingHolePlacementOffset - bendRadius,
         wallMountingHolePlacementOffset
       ],
       radius = wallMountingHoleDiameter / 2,
     )
  |> patternLinear2d(instances = 2, distance = width - (wallMountingHolePlacementOffset * 2), axis = [0, 1])
  |> extrude(%, length = -thickness - 0.1)

// Apply bends
fillet(bracketBody, radius = extBendRadius, tags = [getNextAdjacentEdge(seg03)])
fillet(bracketBody, radius = bendRadius, tags = [getNextAdjacentEdge(seg06)])

// Apply corner fillets
fillet(
  bracketBody,
  radius = filletRadius,
  tags = [
    seg02,
    getOppositeEdge(seg02),
    seg05,
    getOppositeEdge(seg05)
  ],
)

We could add more detailed checks by asserting that the parameters meet basic logical requirements -- for example, the width must be greater than zero to be meaningful. So you could add assert(width, isGreaterThan = 0).

By adding these asserts and comments, your CAD files become self-documenting. You don't need to email a PDF to your colleagues explaining why you chose the parameters you chose, or why certain lengths are they way they are. Your assertions both indicate what measurements or properties are important, and prove that your design has those important properties.

Summary

KCL helps you automatically check your work. You should be able to analyze your engineering designs as early as possible in the design process -- ideally, within Zoo Design Studio, long before you send the part away for manufacturing or analysis. You can check your work in the Variables panel, but we recommend adding assert statements to your KCL. You can use asserts to:

  • Double-check your calculations, especially when transcribing engineering calculations into KCL
  • Validate parameters in parametric design, like ensuring a radius or length is positive
  • Check that your part fulfills its requirements, for example by calculating the maximum force it can tolerate, and asserting that maximum force is above your required minimum
  • Query geometry like the angle between two lines with segAng and ensure it's what you expect
  • Clearly explain what important requirements your design has, and how to correct mistakes if the assertion fails.

Types and Units

KCL tracks the type of each variable, and can help you avoid bugs by noticing when your types don't match. You may have noticed the very basic type checking already, for example trying to run 3 * true will cause an error. But KCL supports more helpful type checks. Let's see how!

Function signatures

Consider this KCL example.

// Could take either two strings, or two numbers.
fn makeMessage(prefix, suffix) {
  return prefix + suffix
}

msg1 = makeMessage(prefix = "hello", suffix = " world")
msg2 = makeMessage(prefix = 1, suffix = 3)

In this example, makeMessage takes two arguments and adds them together with +. If the two arguments are both strings, like in msg1, this will produce one concatenated string (like "hello world"). If the two args are numbers (like in msg2) this will add them together (in the example, producing 4).

This might not be the goal, though! Maybe you designed makeMessage to only work with numbers, or only work with strings. You can add a type to the argument, and KCL will make sure it's only called with those arguments. Like this:

// Note the types of arguments are given now.
fn makeMessage(prefix: string, suffix: string) {
  return prefix + suffix
}

msg1 = makeMessage(prefix = "hello", suffix = " world")
msg2 = makeMessage(prefix = 1, suffix = 3)

In this example, msg1 is defined successfully as "hello world", but KCL will exit with an error while running msg2, because the arguments being passed to it don't match the types the function declares.

Units of measurement

KCL tracks the units that each distance uses. This can help you accurately translate your engineering requirements or formula into KCL, without pulling out a calculator to convert between inches and centimeters.

For example, you can put a unit like 20cm or 20in as the length of a line. Here's three different lines of length 20 centimeters, inches and millimeters.

startSketchOn(XY)
  |> startProfile(at = [0, -100])
  |> xLine(length = 20mm)

startSketchOn(XY)
  |> startProfile(at = [0, 0])
  |> xLine(length = 20cm)

startSketchOn(XY)
  |> startProfile(at = [0, 100])
  |> xLine(length = 20in)

2D fallback: Three lines of length 20 mm and 20 cm and 20 inches

Other suffixes include metres (m), feet (ft) and yards (yd).

In the previous examples, before this chapter, we always used general-purpose numbers with no units (like length = 20). Each KCL file has a default unit. You can set it by adding @settings(defaultLengthUnit = in) at the top of your KCL file. It has to go at the very top, before any code (although comments are permitted before it). If you don't set the default in @settings, your user- or project-level settings might set it. Otherwise, if you truly don't set anything, it'll default to millimeters.

You can also set the units for angle measurements. Here's two toruses, one of which revolves 6 degrees (very little) and the other, 6 radians (almost a full revolution).

// Revolve 6 degrees
startSketchOn(XZ)
  |> circle(center = [-200, -200], radius = 50)
  |> revolve(axis = Y, angle = 6deg)

// Revolve 6 radians
startSketchOn(XZ)
  |> circle(center = [200, 200], radius = 50)
  |> revolve(axis = Y, angle = 6rad)

Mixing units

When you're doing arithmetic in KCL, you can mix and match numbers:

y = 3cm // roughly 1.18 inches
x = 10in - y

If you open the Variables pane, you'll see that x is 8.818 inches. KCL can track the numbers and types involved, letting you flexibly compare different units. However, KCL is not perfect at this (yet -- we're working on it!) For example, right now, KCL cannot anticipate what 30 inches multiplied by 2 centimeters is. If you try 30in * 2cm, you'll get a warning: Multiplying numbers which have unknown or incompatible units. KCL warns you so that you can explicitly provide units if you want.

// This causes a warning that KCL doesn't know what units `z` uses
z = 10in * 3cm

Units and function signatures

You can combine units of measurement and types! There are 3 kinds of number type:

  • A specific unit, like number(mm) or number(deg).
  • Some kind of number, like number(Length), which accepts centimeters or inches, but not degrees or radians. Similarly, number(Angle) accepts degrees or radians, but not centimeters or inches.
  • Any kind of number, i.e. just number.

These can be used in function signatures. If you define a function that accepts, say, centimeters, and someone passes in inches, the inches will automatically be converted to centimeters. This works just like the above example of 10in - 3cm, with the same limitations (sometimes you'll need to provide the type, like x: number(cm)).

Here's a user-defined function f that accepts some angle -- either degrees or radians, but not centimeters (which is a length, not an angle).

fn f(theta: number(Angle)) {
  return cos(theta)
}

xArg = 360deg
x = f(theta = xArg)
yArg = (2 * PI): number(rad)
y = f(theta = yArg)

If you try to run f(theta = 2in) you'll see an error that explains you're using the wrong type of number. But both x and y will correctly be 1 if you open the Variables panel, showing that their units are being tracked correctly.

Other types

Arrays and functions have their own type. You can look up these details at the types page in our docs.

Modules

So far, all the KCL examples we've seen have been fairly small. But as you start modeling larger projects, you'll find that your code no longer neatly fits into one file. Organizing your code into smaller modules can really help. In this chapter, we'll explain how to break your code into smaller modules, which let you break your one big KCL file into several smaller ones. This can help your models render much faster, by executing different modules in parallel.

Splitting code into modules

So far, all our KCL examples have been a single file -- main.kcl. That's the default name that Zoo Design Studio and other KCL tools (like our command-line interface) use. But what happens when main.kcl gets too big?

Say we have a KCL file like this, which defines a cube function, a sphere function, and then models several cubes and spheres.

fn cube() {
  sideLength = 10
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(numSides = 4, radius = sideLength, center = [0, 0])
    |> extrude(length = sideLength)
}

fn sphere() {
  radius = 10
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> yLine(length = radius * 2)
    |> arc(angleStart = 90, angleEnd = 270, radius = radius)
    |> close()
    |> revolve(axis = Y)
}

// Draw ten spheres and ten cubes.
map([1..10], f = fn(@i) { return cube() |> translate( x = i * 20)})
map([1..10], f = fn(@i) { return sphere() |> translate(y = i * 20) })

We can split this file into two separate files, cubes.kcl and spheres.kcl. Here's cubes.kcl:

fn cube() {
  sideLength = 10
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(numSides = 4, radius = sideLength, center = [0, 0])
    |> extrude(length = sideLength)
}

map([1..10], f = fn(@i) { return cube() |> translate( x = i * 20)})

And here's spheres.kcl:

fn sphere() {
  radius = 10
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> yLine(length = radius * 2)
    |> arc(angleStart = 90, angleEnd = 270, radius = radius)
    |> close()
    |> revolve(axis = Y)
}

map([1..10], f = fn(@i) { return sphere() |> translate(y = i * 20) })

To tell main.kcl to execute these two files, we use the import keyword with each file's filepath.

import "cubes.kcl"
import "spheres.kcl"

If you open this file, you'll see the same image as before (10 spheres and 10 cubes). But grouping related code into its own file can make it easier to read.

The other big change in our new code is that each module executes in parallel. This means the cubes and spheres will be drawn simultaneously, instead of drawing all the cubes and then all the spheres. Splitting your big KCL files into smaller modules can therefore be really helpful for speeding up large models.

Each of your .kcl files is a KCL module. Files must all be in the same directory -- we don't currently support importing KCL modules from other directories. Import statements have to be at the top of a file -- they can't be nested within something like a function definition.

Importing and exporting specific items

In the previous example, we just imported an entire file, causing KCL to run all its code. But what if I want to use values from one KCL file (perhaps a variable like radius = 20in or a function like cube) in another file? You can export and import specific variables between KCL modules. Let's see how.

Here's a simple example. Let's export some constants from one file (car_constants.kcl) and import them into another (car_wheel.kcl).

// car_constants.kcl
export wheelDiameter = 15in
export wheelDepth = 4in
export axleLength = 2in

Here, we export 3 different variables from car_constants.kcl. Next, let's import and use some of them.

// car_wheel.kcl
import wheelDepth, wheelDiameter from "car_constants.kcl"
startSketchOn(XY)
|> circle(radius = wheelDiameter / 2, center = [5, 5])
|> extrude(length = wheelDepth)

You can export any variable, not just simple numbers. For example, we could export fn cube(@sideLength) from cube.kcl, and then import it in main.kcl and use it to draw several cubes. Alternatively, cube.kcl could export an actual cube, not just a function to create one. Here's an example showing both of these:

// cube.kcl
export fn cube(sideLength) {
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(numSides = 4, radius = sideLength, center = [0, 0])
    |> extrude(length = sideLength)
}

export mySpecificCube = cube(sideLength = 20)

Now in main.kcl we can access mySpecificCube, and translate or rotate it. We can also use the cube function to make more cubes.

// main.kcl
import mySpecificCube, cube from "cube.kcl"

mySpecificCube |> translate(x = 50) |> rotate(pitch = 40)
secondCube = cube(sideLength = 7)

The imported specific cube and a second cube created from the imported fn cube

Default export

Here's a little time-saving feature for KCL exports. The last expression or variable declared in a KCL module is its default export. This means we could shorten our program

export fn cube(sideLength) {
  return startSketchOn(XY)
    |> startProfile(at = [0, 0])
    |> polygon(numSides = 4, radius = sideLength, center = [0, 0])
    |> extrude(length = sideLength)
}

// This is the last expression in the module, so it's the _default export_.
cube(sideLength = 20)

and use it in main.kcl like this:

// main.kcl
import cube from "cube.kcl"
// Let's use the default export, and give it a name.
import "cube.kcl" as mySpecificCube

mySpecificCube |> translate(x = 50) |> rotate(pitch = 45)
secondCube = cube(sideLength = 7)

For more details, you can read the modules reference in the KCL docs.

Interop with other CAD programs

KCL tries to work well with the rest of the CAD ecosystem. That means you can use other CAD files and import them into KCL, or export your KCL to other formats for use with other CAD software. You can use the Zoo API or CLI to drive these conversions. Let's see how.

Importing other files into KCL

The import statement lets you load models from other CAD files and use them in your KCL. Once imported, they can be translated, rotated, cloned etc. For example, let's import a shape from some CAD file.

import "motor.step" as motor

Once you've imported the geometry, it'll be placed in your scene. You can then modify it like any other KCL solid. For example, let's make two motors:

import "motor.step" as motor

motor
  |> translate(x=10)
clone(motor)
  |> translate(x=20)

Exporting KCL into other formats

If you're writing KCL in the Zoo Design Studio, you can export your design into many different formats. Bring up the export menu via the Command Palette: just type Cmd+K on MacOS, or Ctrl+K on Windows/Linux. Type Export and press enter to choose the Export command. Then you can choose a format, and download your model! From there, you could import it into another CAD program, or send to a 3D printer or manufacturing service.

You can also use the Zoo CLI: just run

zoo kcl export --output-format gltf main.kcl model
Wrote file: model/output.gltf

Currently Zoo supports exporting and importing fbx, glb, gltf, obj, ply, step, and stl files.