KittyCAD / modeling-app

The KittyCAD modeling app.
https://kittycad.io/modeling-app/download
MIT License
357 stars 29 forks source link

If expressions, range expressions #3677

Open adamchalmers opened 2 weeks ago

adamchalmers commented 2 weeks ago

Motivation

@jgomez720 describes this KCL user story: you're a mechanical engineer trying to design a wheel or something. The wheel needs holes for screws. The wheel is parametric, so its radius is given by the user. The screw size needs to vary with the wheel's radius too.

Right now, the only way to do this in KCL is to make the screw size a function of the wheel radius, e.g. let screwSize = someRatio * radius.

Problem

Screw sizes are not continuous! Not all numbers are valid screw sizes. In practice, MEs have a finite set of screw sizes to choose from, e.g. a set of 10 screws with known sizes like 0.18. 0.1825, 0.1850, etc etc.

Currently, there's no way to express the logic "if the wheel radius is between M and N, choose screw number 4. If it's greater than N, choose screw number 5."

Solution 1: if expressions

Add a new if cond { x } else { y } syntax and AST node. Also add if-else expressions like if cond0 { x } else if cond1 { y} else { z }. This would let the user solve the screw problem:

let screwDiameter = 
  if holeDiameter > 0.19 {
    0.1875 // screw size #10
  } else if holeDiameter > 0.18 {
    0.1800 // #12
  } else if holeDiameter > 0.17 {
    0.1700 // #13
  } else {
    error("invalid size, we only support holes bewteen 0.19 and 0.17 in diameter")
  }

Solution 2: selectFromRange

The if-else chain above has two problems:

So we should consider a dedicated syntax for this, as it's likely to be a common pattern. One hypothetical example syntax:

let screwDiameter = selectFromRange(holeDiameter, "screw size", [
    minimum 0.16,
    up to 0.17 => 0.1875,
    up to 0.18 => 0.1900,
    up to 0.19 => 0.1920,
    maximum 0.19
])

the KCL executor would check that all the selections are in the right order, and that all possible values are covered. It forces users to explicitly consider the minimum/maximum ranges they support. It'd output a nicely-formatted error if the user goes above/below the range, e.g. if you put in 0.11 it'd error with

Cannot select a screw size because the holeDiameter of 0.11 is too small (minimum is 0.16)

We'd need some way to distinguish <= and <, e.g. "up to 0.18" vs. "up to but not 0.18"

jtran commented 2 weeks ago

Would solution 2 support split range? I.e. a low range works and a high range works, but in the middle, it's an error.

let screwDiameter = selectFromRange(holeDiameter, "screw size", [
  minimum 0.16,
  up to 0.17 => 0.1875,
  up to 0.18 => 0.1920,
  up to 0.19 => 0.1900,
  maximum 0.19
  minimum 0.21,
  up to 0.22 => 0.22,
  maximum 0.22
])

Would solution 2 support variables, or do they need to be constant literals? For example, could I write this?

fn thing = (holeDiameter) => {
  let a = 0.16
  let increment = holeDiameter / 3
  let screwDiameter = selectFromRange(holeDiameter, "screw size", [
    minimum a,
    up to a + 1 * increment => 0.1875,
    up to a + 2 * increment => 0.1900,
    up to a + 3 * increment => 0.1920,
    maximum a + 4 * increment
  ])
}

Solution 2 feels very specialized to me. Based on the above, I'm wishing that there were some more primitive concepts that we could build solution 2 out of.

If it were a functional language, the primitives would be pattern matching combined with special underderstanding of how numbers work. You'd pattern match on holeDiameter, and the implementation could detect that you've covered all the cases, possibly with a catch-all. Maybe we can add syntax sugar if this is common.

let screwDiameter = select(holeDiameter, "screw size", [
  0.16 .. 0.17 => 0.1875,
       .. 0.18 => 0.1920,
       .. 0.19 => 0.1900,
])

In the above, to the left of => is any pattern, so the name selectFromRange doesn't need to be a range anymore, even though we've used ranges here.

The above would be a "compile" error (or whatever phase we can check, ideally before runtime) because it's non-exhaustive.

let screwDiameter = select(holeDiameter, "screw size", [
  0.16 .. 0.17 => 0.1875,
       .. 0.18 => 0.1920,
       .. 0.19 => 0.1900,
  _            => error
])

This uses a catch-all to make the match exhaustive. In that case, it evaluates to an error. It's actually a value that select recognizes and converts into a real runtime error with context of "screw size" and the runtime value of holeDiameter.

These are all just half-baked ideas off the top of my head.