Read time: 2.6 minutes (256 words)

Step 6: OpenSCAD Manager

Most of the analysis of a design will be done using an STL file which describes a part we create. Before we worry about generating the parts, we can set up a Python class that will manage access to OpenSCAD from the command line.

Feature 2: Add OpenSCAD Management class

The primary purpose of the OpenSCAD manager class is to isolate running OpenSCAD to perform needed operations. For our first test of this class, we will launch OpenSCAD and fetch the version of the program we are using. Unfortunately, getting the Continuous Integration servers to install the version I have on my machine (installed with Homebrew) has proven difficult, so we will not test the actual version, just ensure we get valid output.

The code we need to write will assume that OpenSCAD is on the system path and can be launched with the name openscad.

Test OpenSCAD Version

Users will need to install at least OpenSCAD version 2019.05, so we will set up a test to check that.

Here is our first test for this class:

tests/test_OpenSCAD.py
 1import os
 2import json
 3from mmdesigner.OpenSCAD import OpenSCAD
 4
 5
 6def test_OSC_version():
 7    """Test app returns installed OpenSCAD version"""
 8    mgr = OpenSCAD()
 9    version = mgr.get_version()
10    year, rel = version.split(".")
11    assert int(year) >= 2019
12
13
14def test_OSC_STL_generator():
15    """Test generation of STL file from specified SCAD file"""
16    mgr = OpenSCAD()
17    scad_file = "tests/test_data/spar.scad"
18    stl_file = "tests/test_data/spar.stl"
19    if os.path.exists(stl_file):
20        os.remove(stl_file)
21    mgr.gen_stl(scad_file)
22    assert os.path.isfile(stl_file)
23
24
25def test_OSC_STL_bad_file():
26    """Test error return on bad file name"""
27    mgr = OpenSCAD()
28    assert mgr.gen_stl("bad") == 1
29
30
31def test_OSC_STL_bad_stl_name():
32    """Test error return on non-scad file name"""
33    stl_file = "tests/test_data/spar.stl"
34    mgr = OpenSCAD()
35    assert mgr.gen_stl(stl_file) == 1
36
37
38def test_mass_properties():
39    mgr = OpenSCAD()
40    scad_file = "tests/test_data/spar.scad"
41    err_code = mgr.get_properties(scad_file)
42    assert err_code == 0
43    assert mgr.get_bounds() == [1.0, 0.0, 5.0, 0.0, 1.0, 0.0]
44
45
46def test_OSC_json():
47    mgr = OpenSCAD()
48    scad_file = "tests/test_data/spar.scad"
49    json_file = "tests/test_data/spar.json"
50    err_code = mgr.get_properties(scad_file)
51    assert err_code == 0
52    mgr.dump_to_json(json_file)
53    with open(json_file) as jin:
54        jdata = json.load(jin)
55        assert float(jdata["maxy"]) == 5.0

And here is the start of our class:

mmdesigner/OpenSCAD.py
 1import os
 2import stl
 3from stl import mesh
 4import subprocess
 5
 6
 7class OpenSCAD(object):
 8    """Management class for OpenSCAD command line interface"""
 9    def __init__(self):
10        pass
11
12    def get_version(self):
13        """return installed OpenScAD version"""

With this running locally, we now need to modify the Continuous Integration service control files and get OpenSCAD installed on their virtual machines for testing. This took some research, but i finally managed to get things working.

Test OpenSCAD STL Generation

Our next task involves getting OpenSCAD to generate an STL file from the associated SCAD file.

mmdesigner/OpenSCAD.py
 1        result = subprocess.run(['openscad', '--version'], capture_output=True)
 2        version = result.stderr.decode().split()[2]
 3        return version
 4
 5    def gen_stl(self, scad_path):
 6        """ Generate STL file from specified SCAD file"""
 7        print("Generating STL file from ", scad_path)
 8        err_code = 1
 9        if not os.path.isfile(scad_path):
10            return err_code
11        if not scad_path.endswith(".scad"):
12            return err_code
13        base, ext = scad_path.split(".")
14        stl_path = base+".stl"
15        cmd = [
16            'openscad',
17            scad_path,
18            "-o",
19            stl_path

Mass Property Generator

Now that we can generate an STL file, we can analyze that file to get the part properties. These will be saved in a JSON output file:

mmdesigner/OpenSCAD.py
  1import os
  2import stl
  3from stl import mesh
  4import subprocess
  5
  6
  7class OpenSCAD(object):
  8    """Management class for OpenSCAD command line interface"""
  9    def __init__(self):
 10        pass
 11
 12    def get_version(self):
 13        """return installed OpenScAD version"""
 14        result = subprocess.run(['openscad', '--version'], capture_output=True)
 15        version = result.stderr.decode().split()[2]
 16        return version
 17
 18    def gen_stl(self, scad_path):
 19        """ Generate STL file from specified SCAD file"""
 20        print("Generating STL file from ", scad_path)
 21        err_code = 1
 22        if not os.path.isfile(scad_path):
 23            return err_code
 24        if not scad_path.endswith(".scad"):
 25            return err_code
 26        base, ext = scad_path.split(".")
 27        stl_path = base+".stl"
 28        cmd = [
 29            'openscad',
 30            scad_path,
 31            "-o",
 32            stl_path
 33        ]
 34
 35        result = subprocess.run(cmd, capture_output=True)
 36        err_code = result.returncode
 37        return err_code
 38
 39    def _get_bounds(self):
 40        """set internal bounds variables"""
 41        minx = maxx = miny = maxy = minz = maxz = None
 42        obj = self.mesh
 43        for p in obj.points:
 44            if minx is None:
 45                minx = p[stl.Dimension.X]
 46                maxx = p[stl.Dimension.X]
 47                miny = p[stl.Dimension.Y]
 48                maxy = p[stl.Dimension.Y]
 49                minz = p[stl.Dimension.Z]
 50                maxz = p[stl.Dimension.Z]
 51            else:
 52                maxx = max(p[stl.Dimension.X], maxx)
 53                minx = min(p[stl.Dimension.X], minx)
 54                maxy = max(p[stl.Dimension.Y], maxy)
 55                miny = min(p[stl.Dimension.Y], miny)
 56                maxz = max(p[stl.Dimension.Z], maxz)
 57                minz = min(p[stl.Dimension.Z], minz)
 58        self.minx = minx
 59        self.maxx = maxx
 60        self.miny = miny
 61        self.maxy = maxy
 62        self.minz = minz
 63        self.maxz = maxz
 64
 65    def dump_to_json(self, json_path):
 66        """write mas properties data to specified JSON file"""
 67        json = "{\n"
 68        json += '  "maxx": "%s",\n' % self.maxx
 69        json += '  "minx": "%s",\n' % self.minx
 70        json += '  "maxy": "%s",\n' % self.maxy
 71        json += '  "miny": "%s",\n' % self.miny
 72        json += '  "maxz": "%s",\n' % self.maxz
 73        json += '  "minz": "%s",\n' % self.minz
 74        json += '  "volume": "%s",\n' % self.volume
 75        json += '  "cgx": "%s",\n' % self.cgx
 76        json += '  "cgy": "%s",\n' % self.cgy
 77        json += '  "cgz": "%s",\n' % self.cgz
 78        json += '  "ixx": "%s",\n' % self.ixx
 79        json += '  "ixy": "%s",\n' % self.ixy
 80        json += '  "ixz": "%s",\n' % self.ixz
 81        json += '  "iyx": "%s",\n' % self.iyx
 82        json += '  "iyy": "%s",\n' % self.iyy
 83        json += '  "iyz": "%s",\n' % self.iyz
 84        json += '  "izx": "%s",\n' % self.izx
 85        json += '  "izy": "%s",\n' % self.izy
 86        json += '  "izz": "%s"\n' % self.izz
 87        json += '}\n'
 88        with open(json_path, 'w') as fout:
 89            fout.write(json)
 90
 91    def get_properties(self, scad_file):
 92        """Calculate mass properties from specified SCAD file"""
 93        scad_path = os.path.abspath(scad_file)
 94        err_code = self.gen_stl(scad_path)
 95        base, ext = scad_path.split(".")
 96        stl_file = base + ".stl"
 97        self.json_file = base + ".json"
 98        self.mesh = mesh.Mesh.from_file(stl_file)
 99        self._get_bounds()
100        volume, cog, inertia = self.mesh.get_mass_properties()
101        self.cgx, self.cgy, self.cgz = cog
102        self.volume = volume
103        self.ixx, self.ixy, self.ixz = inertia[0]
104        self.iyx, self.iyy, self.iyz = inertia[1]
105        self.izx, self.izy, self.izz = inertia[2]
106        self.dump_to_json(self.json_file)
107        return err_code
108
109    def get_bounds(self):
110        """Return bounds data as a list"""
111        return [
112                self.maxx, self.minx,
113                self.maxy, self.miny,
114                self.maxz, self.minz
115        ]
116
117
118if __name__ == '__main__':
119    mgr = OpenSCAD()
120    print(mgr.get_version())
121    mgr.get_properties("../tests/test_data/spar.scad")
122    mgr.dump_to_json("../tests/test_data/spar.json")

This code depends on numpy-stl which needs to be added to the requirements.txt file.