The idea behind Katana

This is my first devlog post. I’m starting this because I think some of the thoughts I have are nice enough to be shared. I’m not going to do this regularly though, only when I feel like it and when I have time :D

I’m a hobbyist by heart. It started with coding, then evolved into building stuff with Arduino (I once built a MIDI player using an Arduino and a bunch of floppy disk drives), then 3D printing, then woodworking (by far the more expensive of the hobbies hehe).

A few years ago I even got the idea to build a 3D printer from scratch, bought a bunch of aluminum profiles and NEMA motors, then realized how insanely complex that project was. Add to that the fact that I’m a workaholic and it turns out I don’t have the time and energy necessary to pursue such kinds of megalomaniac projects.

This year though, I decided to unite 2 things into one - exercise my Rust muscles a little bit, and explore the idea of building a slicer from scratch.

The initial proof of concept

To start, I structured the project in a main slicing engine (katana-core), a CLI utility to do quick tests (katana-cli) using clap, and a UI interface that I’d work on later (katana-viewer). This would allow me to compartmentalize the complexity of the slicing from the rendering shenanigans. The initial logic I did produced layer SVGs from a given STL and a couple parameters, that’s the simplest MVP I could picture, no toolpathing or anything fancy.

STL encoding

First I had to do some research into how STLs are encoded. Did you know there are 2 ways of doing it? Plain text (ASCII) and binary. STL is very simple, really, it’s just a collection of triangles (facets with a normal vector and 3 points in space). In ASCII you can even read it:

solid <name of the thing>
   facet normal x y z
      outer loop
         vertex x y z <-- point 1
         vertex x y z <-- point 2
         vertex x y z <-- point 3
      endloop
   endfacet
   ... N-1 facets
endsolid

The binary version is basically the same stuff, but binary encoded, with an 80-byte metadata header + 4 bytes for the triangle count (N), and then N chunks of 50 bytes for the triangles (3 xyz points and 1 xyz normal vec, 4 * 3 * 4b = 48b, + 2 bytes for “attribute byte count”, which is usually zero).

Decoding is then straightforward, we can take a file path and turn it into a Vec<Triangle>:

pub struct Triangle {
    pub vertices: [Point3<f32>; 3],
    pub normal: Vector3<f32>,
}

We can even play with this a bit and build a function that calculates the volume of a solid (assuming it’s watertight), using Gauss’s theorem, which I’ve never thought I’d use in real life.

Simple slicing

Here’s the gist: take the bounding box of the STL mesh and calculate the layer steps (divide height by Z), then intersect the mesh at every layer step, and boom, there you have it. Sliced mesh saved into neat SVG lines.

In practice it’s not that simple. If you’re slicing a perfect cube, the bounding box will coincide with the cube and the first layer will not be a line segment, but the entire cube surface. Because of this, we need to introduce a small disturbance (epsilon) to the layer steps, so that we never align it perfectly with any horizontal face.

The final result we want right now is simple - an object SliceResult that contains ordered layers Vec<Layer>, each layer having a height and a set of Contours, which are disjointed sets of points that were intersected by our plane:

pub struct Contour {
    pub points: Vec<Point2<f32>>,
}

pub struct Layer {
    pub z: f32,
    pub contours: Vec<Contour>,
}

pub struct SliceResult {
    pub layers: Vec<Layer>,
}

Having 1 layer be more than 1 contour might sound like over-engineering, but a surprising amount of solids have holes or donut-like structures that generate disjointed contours when intersected.

Besides the intersecting function, we also need to connect these dots. Each contour should start and end at the same point, so we need to create a function for that too.

Building intersect_plane and assemble_contours

This is the basic algorithm that determines which points of a mesh intercept a given plane Z:

create empty segments array
for each triangle in the mesh
    if all 3 points are below or above the plane: continue.
    find the vertex that's isolated
    trace a line between that vertex and the other two:
        You'll get two points at the height Z -> p0 and p1
    Add (p0, p1) to the segments array

return segments

After this, we’ll have a Vec<Segment>, each Segment being simply 2 Point2 at height Z. Now, to connect these segments into one or more Contour:

mark all segments as unused
while there are unused segments:
    take 1 unused segment, mark it as used

    create a Contour
    current_point = end point of this segment
    start_point = start point of this segment

    while true:
        if current_point = start_point:
            break

        find an unused segment that touches current_point
        if found:
            add it to Contour
            move current_point to the other end of the segment
            mark segment as used
        else:
            break -> mesh probably isn't watertight

return all Contours

After that, my algo for the simple slicer is this:

pub fn slice_mesh(mesh: &Mesh, layer_height: f32) -> SliceResult {
    let (min, max) = mesh.bounding_box();
    let z_min = min.z + EPSILON;
    let z_max = max.z - EPSILON;

    let mut layers = Vec::new();
    let mut z = z_min + layer_height;
    while z < z_max {
        let segments = intersect_plane(mesh, z);
        let contours = assemble_contours(segments);
        layers.push(Layer { z, contours });
        z += layer_height;
    }

    SliceResult { layers }
}

The simplest MVP for me at this point was to print out the contours in an SVG with the CLI utility hehe. In the next post I’ll talk about the next steps, when I enter the magic land of 3D rendering, orthographic projections, azimuths and elevations, and maybe some performance optimization attempts, which is where I spent most of my time in this project.

That’s it, cheers!