12-31-3d-modeling-with-code.md 9.7 KB


title: 3d Modeling with Code tags: coding, blender, python, openscad, 3d description: Wrestling with 3d modeling when you don't know your axis from a teapot category: coding

date: 2023-12-31

I've been playing an open source game a fair bit, lately. It's quite extensible, well-documented, and already boasts a library of plugins.

Of course, it was, perhaps, inevitable that I would want to make my own plugin. And so I've been working on one for a little while now. Unfortunately, there's one sticking point. I'm a moderately capable writer, and an experienced coder (though, truly, the data language for the game doesn't ask for much in that regard), but graphics are a bit beyond me.

Still, games need graphics, and any plugin should try to match the standards of the game it's made for. And this game is often pretty explicit about its standards. Indeed, the game is open source, and so are the assets.

When you want to make game graphics nowadays, there's a decent chance you want to make 3d objects and texture them. And if you want to make 3d objects and texture them, you want to use Blender. And if you want to use Blender, you need to learn Blender. In my case, that meant watching hours of tutorials randomly chosen from the "blender tutorial" search results on youtube and trying to make a donut. My brain wouldn't wrap around it.

Enter OpenSCAD, which is language-driven rather than GUI-driven. One of the graphics I wanted to make was a computer with a bit of a retro style, complete with a circular oscilloscope-style screen. With about an hour of tinkering, I got this:

A simple computer model{width=323 height=223}

The code was pretty straightforward, too:

back_bottom  = [0,0];
back_top     = [0,8];
front_top    = [2,8];
front_mid    = [4,3];
front_bottom = [4,0];

frame_points = [back_bottom,
    back_top, front_top,
    front_mid, front_bottom];

rotate([90,0,0]){
  linear_extrude(height=10)
    polygon(frame_points);

  translate([3,5.5,6.5])
    rotate([0,0,292])
    resize([5,2,5])
    sphere(d=10, $fn=50);

  for(i=[7,5.5,4])
    for(j=[.8,1.2,1.6,2,2.4,2.8,3.2])
      if(rands(0,10,1)[0]>6)
        translate([4.5-(i/2.5),i,j])
          cube(size=[.8,.6,.2], center = false);
      else
        translate([4.7-(i/2.5),i,j])
          cube(size=[.8,.6,.2], center = false);
}

There are some problems with it. A bunch of magic numbers in there, but such is often the case with tinkering, and could be fixed relatively easily. The buttons, on the other hand, are trickier. They're parallel with the floor, rather than perpendicular to the slanted panel. Still, not bad for an hour, and much more solid progress than I'd had trying Blender.

The biggest weakness, though, is the yellow. You can change the color of your surfaces and solids in OpenSCAD, but only during modelling--once you render, the colors disappear. And there's no ability to apply textures. This all makes sense. OpenSCAD is intended for physical modelling, and is commonly used to model things intended for 3d printers. But it's inconvenient for me.

Someone let me know that Blender itself can be used in a language-driven way. I was surprised by this, given my experience looking for howto documentation. Indeed, even a search for "blender for coders" turned up tutorials on how to use the GUI. "Blender python" does a bit better. If I can manage to model in Blender, then adding textures should be straightforward.

So let's see if I can replicate this through Blender's Python interface.

First things first, one huge difference: in OpenSCAD, your script is the model, so if you change it and re-run, you get a brand new model; in Blender, your script modifies the existing scene. This makes lots of sense if you're writing plugins, but I'm not. That means, if I want to run and re-run the script, I need to delete everything first every time. Fortunately, that seems pretty straightforward:

import bpy

# first, clean the slate
for o in bpy.data.objects:
    bpy.data.objects.remove(o)

Now it gets messier. Let's make the box. In OpenSCAD, I made a five-sided polygon, then extruded it into a seven-faced polyhedron (then rotated it to be upright, since I got there by tinkering). The coordinates for all the vertices are very straightforward, and it makes Blender sense to build a mesh out of those coordinates.

But that's not enough to make a solid. You also need either the edges (pairs of vertices), or the faces (sets of three or more vertices describing each face of the solid). I'm going with the latter.

Vertices are always coordinate triplets--3-tuples of floats (though integers will degrade gracefully to floats without issue). Faces, on the other hand, are a sequence of vertices, expressed as indices to the vertex list. Furthermore, the sequence needs to proceed around the perimeter of the face, in order. If you put them in the wrong order, the mesh will look like it's folding back on itself. I'm going to stick with counterclockwise as I'm looking at each face. I'm also going to be extremely explicit in my code, because I'll easily forget, otherwise.

back_bottom_left   = (0,0,0)
back_top_left      = (0,0,8)
front_top_left     = (2,0,8)
front_mid_left     = (4,0,3)
front_bottom_left  = (4,0,0)
back_bottom_right  = (0,10,0)
back_top_right     = (0,10,8)
front_top_right    = (2,10,8)
front_mid_right    = (4,10,3)
front_bottom_right = (4,10,0)

box_vertices = [back_bottom_left, back_top_left, back_bottom_right, back_top_right,
    front_top_left, front_top_right, front_mid_left, front_mid_right,
    front_bottom_left, front_bottom_right]
box_faces = [
    (0,1,3,2), # the back of the box: back_bottom_left, back_top_left, back_top_right, back_bottom_right
    (0,2,9,8), # the bottom: back_bottom_left, back_bottom_right, front_bottom_right, front_bottom_left
    (8,9,7,6), # lower front plate: front_bottom_left, front_bottom_right, front_mid_right, front_mid_left
    (9,2,3,5,7), # right side panel (5 edges): front_bottom_right, back_bottom_right, back_top_right, front_top_right, front_mid_right
    (0,8,6,4,1), # left side panel: back_bottom_left, front_bottom_left, front_mid_left, front_top_left, back_top_left
    (6,7,5,4), # upper front plate: front_mid_left, front_mid_right, front_top_right, front_top_left
    (4,5,3,1), # top: front_top_left, front_top_right, back_top_right, back_top_left
]

Finally, our order of operations with Blender will be:

  1. build a mesh,
  2. using the mesh, make an object,
  3. add the object to the scene

This is the code I came up with for that:

box_mesh = bpy.data.meshes.new('MyComputerBoxMesh')
box_mesh.from_pydata(box_vertices, [], box_faces)

box_obj = bpy.data.objects.new('MyComputerBox', box_mesh)
bpy.context.scene.collection.objects.link(box_obj)

Okay, that was a bunch of code (and I'm well past several hours of tinkering to get here). The monitor and buttons, though, should be simpler, since Blender has primitives for spheres and cubes.

The monitor involves a bunch of magic numbers, sometimes found through trial and error. Notable differences from OpenSCAD include: the scale being relative to the current size (as opposed to OpenSCAD's resize), the angle of rotation being different, and the difference in controlling the sphere's resolution (in OpenSCAD, the $fn argument is simply defined as 'resolution', while Blender has the similarly opaque 'subdivisions').

import mathutils # this is a Blender library
import math # this is a core Python library

monitor_location = mathutils.Vector((3, 2.5, 5.5))
monitor_scale = mathutils.Vector((.4, 1, 1))
monitor_rotation = mathutils.Euler((0, math.radians(157), 0))

bpy.ops.mesh.primitive_ico_sphere_add(
    subdivisions=4, # this controls how many triangles make up the sphere
    radius=2,
    location=monitor_location,
    scale=monitor_scale, # this is relative to the size
    rotation=monitor_rotation)

Finally, let's put our rows of buttons.

import random

for i in [7, 5.5, 4]:
    for j in [6.8, 7.2, 7.6, 8, 8.4, 8.8, 9.2]:
        if random.random() > .6:
            button_location = mathutils.Vector((5.0 - (i/2.5), j, i))
        else:
            button_location = mathutils.Vector((5.2 - (i/2.5), j, i))

        bpy.ops.mesh.primitive_cube_add(
            size=1,
            location=button_location,
            scale=mathutils.Vector((.8, .2, .6)))

That translated pretty simply, all things considered. The j coordinates are all shifted to account for the change in origin (my OpenSCAD model had the origin at bottom_right_back, and added the buttons while the box was still on its side, while this Blender model has the origin at bottom_left_back). I had to shuffle some of the coordinates around (for much the same reason), and I modified the depth of the buttons because of difference in how Blender and OpenSCAD place things. Aside from that, the logic all ported across nicely.

All together, I end up with something like this:

Another simple computer model{width=323 height=223}

Now, we haven't added textures, we haven't rendered (or added the needed things to render, like light sources and cameras), but I'm content today to catch up with where I was.

Tune in next time as I continue to wrestle with this stuff.