Content-Type: text/plain

I have the Bambu Lab A1. To build my own models, I've generally used Onshape's free plan. It's a nice web CAD editor1 and free if you're happy to make all your designs public2 and sacrifice them to Onshape.

There is no way to export your design's parametric history. You're allowed to export the geometry, but you effectively have no ownership over your work3. As far as I'm concerned, the features and parametric history are not really mine.

I wanted to regain ownership of my design process and create the ideal reproducible-build workflow while I was at it.

OpenSCAD is a programming language for 3D design. Unfortunately, it's hard to reason about 3D space in code form unless you have a lot of experience with it already, or a brain that loves that kind of thing. It's not particularly accessible for the kind of person who just wants to build the occasional 3D model.

Times have changed and now we can delegate the grunt work4, with me validating the result and iterating on it. Models become diffable, committable, and encoded in the best file format on the planet, plain old text.

But there's more than just the geometry. Models are packaged up in 3MF files5, which can contain:

  • Multiple model geometries, including per-object transforms, references, and positioning
  • Colour, material, texture, and extruder assignments
  • Print settings, most of which are manufacturer-specific
  • Metadata like title, description, author, licence, creation date, and object UUIDs

This is a lot of information that isn't part of the model itself, but is just as important if you want to print it.

3MF = geometry vertices, facets, shapes + config colours, materials, metadata

Just for fun, let's make a 3D version of the basic shapes logo6 from the favicon.

#Geometry

First, the code.

shape = [
              [0,  1],
    [-1,  0], [0,  0], [1,  0],
              [0, -1],
];

size = 40; // mm per cube

for (offsets = shape)
    translate([offsets.x * size, 0, (offsets.y + 1.5) * size])
        cube(size, center = true);

This is not an imperative language, even if it kind of looks like one. Think of it as declarative expressions that emit geometry to the scene. For each pair of offsets in shape, a cube is created and translated to a position, and then all geometries are unioned into the final object.

[0, -1] [-1, 0] [0, 0] [1, 0] [0, 1] x z y

The integer pair x and y is the offset of each coloured cube relative to the centre cube. It represents the x and z coordinates in the call to translate, which is very confusing, so the parameters will henceforth be known as a, b, and c.

translate ( x * size a , 0 b , (y + 1.5) * size c )

The a parameter defines how the cube should be translated along the x-axis. This one is straightforward, corresponding to -40, 0, and 40, positioning the three cubes in a line.

The b parameter is irrelevant as the cubes don't move forwards or backwards along the y-axis.

The c parameter is slightly more interesting. There's a magic 1.5 in the translate call above. What's that doing? It might be easier to see from another perspective.

front column [0, 0, 20] [-40, 0, 60] [0, 0, 60] [40, 0, 60] [0, 0, 100] 20 60 100 0 20 40 60 80 100 z z=0 -40 0 40 x

The center parameter to cube() centres the cube at the origin, instead of placing one of its corners there. For this axis the cubes need to sit at 20, 60, and 100 mm. The magic 1.5 is actually two numbers: 1 and 0.5. Adding one bumps the offsets from [-1, 0, 1] to [0, 1, 2] and the additional half nudges it up to [0.5, 1.5, 2.5], which is then multiplied by the size to give the final values of [20, 60, 100].

So that's how you build a formation of cubes.

#Configuration

Now that the model's geometry is done, the next step is to make it real, which involves giving it a position and colour on the print bed.

Lately I've updated some projects that previously took the suckless approach, replacing it with a standard config file. Here it fits too, with each model directory getting its own.

In the logo's case, its config.toml looks like:

[models.textplain_logo]
uuid = "b29e3b9b-3721-4ce0-b2f1-400e66ebe872"
title = "text/plain"
filament_colours = ["#3a3a3a", "#4a9e9e", "#8f75c9", "#e8a54a", "#d45a5a"]

left.plate = 1
left.position = [-100, 0]
left.filament = 3
top.plate = 1
top.position = [-50, 0]
top.filament = 2
center.plate = 1
center.position = [0, 0]
center.filament = 1
bottom.plate = 1
bottom.position = [50, 0]
bottom.filament = 5
right.plate = 1
right.position = [100, 0]
right.filament = 4

The uuid is a stable identifier. The rest of the file defines each cube's plate, position, and filament assignment.

If you now feel confused, then you've been paying attention. You'll notice that the configuration file references the cubes by labels like left and top, when nothing in the scad code defines them.

I've misled you for explanatory purposes. The real scad code looks like this:

shape = [
                       ["top",    [0,  1]],
    ["left", [-1, 0]], ["center", [0,  0]], ["right", [1, 0]],
                       ["bottom", [0, -1]],
];

part = "all"; // [all, center, top, left, right, bottom]
size = 40; // mm per cube

for (offsets = shape)
    if (part == "all" || part == offsets[0])
        translate([offsets[1].x * size, 0, (offsets[1].y + 1.5) * size])
            cube(size, center = true);

It's not as compact and pretty, but to be able to build the model either as a whole or as individual cubes, you have to parameterise on part. Now that you understand the original code, it should be easy enough to see how that logic slots in.

#Build

The two operands of the 3MF equation are in place: geometry and config. It all goes into a folder called textplain_logo, and then it's time to call bin/ctl build-all and see what it does.

$ bin/ctl build-all
packaged dist/textplain_logo/textplain_logo.3mf exported share/textplain_logo/textplain_logo.html
$ tree
  • src
    • textplain_logo
      • config.toml
      • textplain_logo.scad
  • dist
    • textplain_logo
      • textplain_logo.3mf
  • share
    • textplain_logo
      • textplain_logo.html
  • bin
    • ctl
  • tpl
    • 3mf
      • [Content_Types].xml
      • filaments.json
      • Metadata
        • cut_information.xml
        • project_settings.config
        • slice_info.config
      • _rels
    • fonts
      • OFL.txt
      • space-grotesk-semibold.woff2
    • three
      • three.bundle.min.js
$ tree
├── src/
│   └── textplain_logo/
│       ├── config.toml
│       └── textplain_logo.scad
├── dist/
│   └── textplain_logo/
│       └── textplain_logo.3mf
├── share/
│   └── textplain_logo/
│       └── textplain_logo.html
├── bin/
│   └── ctl
└── tpl/
    ├── 3mf/
    │   ├── [Content_Types].xml
    │   ├── filaments.json
    │   ├── Metadata/
    │   │   ├── cut_information.xml
    │   │   ├── project_settings.config
    │   │   └── slice_info.config
    │   └── _rels/
    ├── fonts/
    │   ├── OFL.txt
    │   └── space-grotesk-semibold.woff2
    └── three/
        └── three.bundle.min.js

The src, dist, and share directories hold the model in its three states:

  • The scad source and configuration
  • A reproducible 3MF file, for distribution
  • A self-contained HTML 3D viewer, for sharing

The tpl directory holds the template files needed to build the artefacts. Understanding the relevance of the files within is left as an exercise for the reader.

The 3MF file can be persuaded to reveal all with the inspect subcommand:

$ bin/ctl inspect dist/textplain_logo/textplain_logo.3mf
application: BambuStudio-02.06.00.51 presets: process: 0.20mm Standard @BBL A1 filament: Bambu PLA Matte @BBL A1 printer: Bambu Lab A1 0.4 nozzle objects: id 1 Center filament 1 (#3a3a3a) 8 verts 12 tris id 2 Top filament 2 (#4a9e9e) 8 verts 12 tris id 3 Left filament 3 (#8f75c9) 8 verts 12 tris id 4 Right filament 4 (#e8a54a) 8 verts 12 tris id 5 Bottom filament 5 (#d45a5a) 8 verts 12 tris layout: plate 1 (by object) Center filament 1 (#3a3a3a) position (128.00, 128.00), z-rest -40.00 Top filament 2 (#4a9e9e) position (78.00, 128.00), z-rest -80.00 Left filament 3 (#8f75c9) position (68.00, 128.00), z-rest -40.00 Right filament 4 (#e8a54a) position (188.00, 128.00), z-rest -40.00 Bottom filament 5 (#d45a5a) position (178.00, 128.00), z-rest 0.00 settings: (no process overrides)

This file is ready to upload to MakerWorld so it can start getting boosted.

If you have friends, you may wish to show them your creation. The self-contained viewer can be used for that. It renders the model using a vendored three.js in the browser, entirely offline.

Here it is, embedded in the page. You should be able to interact directly with it.


  1. As far as I can tell. I don't have much experience with 3D modelling software. ↩︎

  2. Free-plan documents are publicly searchable and copyable by other Onshape users. ↩︎

  3. Onshape would claim otherwise, but I disagree. ↩︎

  4. Models model the model. ↩︎

  5. Which is really just a zip file. ↩︎

  6. This was actually just a temporary makeshift logo, but I guess I've committed to it now. ↩︎