Creating a Compiler in Python#

An OpenVCAD compiler translates a reusable design tree into the lower-level representation understood by a machine. The design describes geometry and spatial attributes; the compiler decides where to sample them, validates the result, and writes the machine-facing files.

This guide builds a small compiler entirely in Python for a hypothetical vat photopolymerization printer. The printer accepts a stack of full-bed, 8-bit grayscale PNG images:

  • black (0) means no exposure;

  • white (255) means maximum exposure; and

  • intermediate values request an intermediate exposure time or light intensity.

Some grayscale-capable vat systems use exposure dose to change cure state and, in turn, mechanical response. That is the motivation for this example. The built-in VAT Photo Compiler solves a similar problem with OpenVCAD’s standard INTENSITY attribute and BMP output. Here, we will write a simpler PNG compiler ourselves and deliberately use a custom scalar attribute named exposure.

Prerequisites and project files#

This is an advanced guide, but it assumes only the Python concepts introduced in Getting Started with OpenVCAD and the Functional Grading Guide. All dimensions in this guide are in millimetres.

The complete project is in examples/compilers/custom_python_vat/

custom_python_vat/
├── .gitignore
├── README.md
├── design.py
├── grayscale_vat_compiler.py
├── compile_design.py
└── preview_design.py

The separation is intentional. grayscale_vat_compiler.py is reusable and accepts any compatible OpenVCAD root object. design.py is only one test design, while compile_design.py supplies the settings for one printer.

A practical compiler contract#

You do not need a custom compiler for every design, and a small Python compiler does not need a complicated framework. A useful compiler should:

  1. accept any OpenVCAD root object that carries the field it consumes;

  2. name and validate its required attribute;

  3. define the machine’s sampling domain and resolution;

  4. check that the object fits within the machine;

  5. sample geometry and attributes in bulk;

  6. define behavior for missing or invalid values;

  7. convert valid samples into the machine’s representation; and

  8. report actionable errors and progress.

The lowest-level attribute should be as close as practical to something the machine understands. Our printer understands grayscale exposure, so exposure is a better compiler input than a high-level property such as modulus. A different workflow could convert modulus into exposure before this compiler runs via Attribute Translation in the Attribute Modifier and Resolver Guide.

Prepare before querying a tree#

OpenVCAD design roots provide three central query methods:

  • bounding_box() returns the minimum and maximum authored coordinates;

  • evaluate(x, y, z) queries geometry; and

  • sample(x, y, z) queries geometry and attributes together.

Call prepare(voxel_size, bandwidth) on a root before calling evaluate() or sample().

voxel_size = pv.Vec3(0.254, 0.254, 1.0)
bandwidth = 6.0

root.prepare(voxel_size, bandwidth)

voxel_size tells the tree the intended sampling pitch in X, Y, and Z. bandwidth is a physical distance around the zero level set—the surface where the signed distance equals zero. Some nodes use these values to load data, construct an internal distance field, or initialize state needed for later queries.

Within the narrow band, the signed-distance magnitude describes distance to the surface. Away from it, many workflows need only the sign: negative or zero is inside and positive is outside. Depending on the node, a query far outside its prepared domain may instead return None. Analytic primitives may provide an exact distance over a wider region, but a compiler should not depend on far-field distance magnitude when an inside/outside test is enough.

Signed distance and the narrow band

Diagram showing a surface, its narrow band, and the signed-distance sign inside and outside

evaluate(): geometry only#

evaluate() returns a signed-distance value or None:

signed_distance = root.evaluate(0.0, 0.0, 0.0)

if signed_distance is None:
    print("Outside the prepared query domain")
elif signed_distance <= 0.0:
    print("Inside or on the object")
else:
    print("Outside the object")

This is enough for a compiler that cares only about occupancy or surface geometry.

sample(): geometry and attributes#

sample() accepts the same X, Y, and Z coordinates but returns two values:

signed_distance, attributes = root.sample(0.0, 0.0, 0.0)

if signed_distance is not None and signed_distance <= 0.0:
    if attributes is not None and attributes.has_sample("exposure"):
        exposure = attributes.get_sample("exposure")
        print("Exposure:", exposure)

The first value follows evaluate(). The second is an AttributeSamples container holding whatever named fields are defined at that location. Use has_sample(name) before get_sample(name).

To check which names occur anywhere in a design tree:

print(root.attribute_list())

if "exposure" not in root.attribute_list():
    raise RuntimeError("The design does not contain an 'exposure' attribute.")

This is a useful preflight check, but it is not proof that the attribute exists at every interior point. We will handle that distinction explicitly.

Build a design with a custom attribute#

Attribute names do not need to come from pv.DefaultAttributes. This example uses the ordinary Python string "exposure" and a floating-point field from 0.1 to 1.0 along the 30 mm X length:

import pyvcad as pv

EXPOSURE = "exposure"

length_mm = 30.0
width_mm = 16.0
height_mm = 6.0

prism = pv.RectPrism(
    pv.Vec3(0, 0, 0),
    pv.Vec3(length_mm, width_mm, height_mm),
)

# x spans -15 to +15 mm:
# 0.03(-15) + 0.55 = 0.1
# 0.03(+15) + 0.55 = 1.0
exposure_gradient = pv.FloatAttribute("0.03 * x + 0.55")
prism.set_attribute(EXPOSURE, exposure_gradient)

root = prism

This definition is also available in design.py. The compiler does not know that it is receiving a rectangular prism; it only knows that root is an OpenVCAD object with geometry and, somewhere in its tree, an exposure attribute.

The render below visualizes that custom attribute with a black-to-white palette. Dark values are near exposure 0.1, and white reaches exposure 1.0.

Demo object with its exposure gradient

Rendered rectangular prism with a black-to-white exposure gradient along its length

Scale up with TreeSampler#

Calling evaluate() or sample() in a Python loop works for a few inspection points, but printer grids can contain billions of samples. Repeated single-point calls add unnecessary Python overhead.

pyvcad.TreeSampler provides ordered, parallel bulk queries:

points = [
    pv.Vec3(-10, 0, 0),
    pv.Vec3(0, 0, 0),
    pv.Vec3(10, 0, 0),
]

sampler = pv.TreeSampler(root, voxel_size)

distances = sampler.evaluate_points(points)
samples = sampler.sample_points(points)
  • evaluate_points(points) performs the equivalent of evaluate() for every point.

  • sample_points(points) performs the equivalent of sample() for every point.

  • Both methods preserve the input order, run the work in parallel, and accept an optional progress callback receiving integers from 0 through 100.

Constructing TreeSampler prepares the root automatically using the supplied voxel size and a bandwidth six times the largest voxel dimension. The earlier explicit prepare() example remains important whenever you query a root directly.

The grid-oriented TreeSampler methods sample the object’s bounding-box domain with sampler padding. Our machine instead needs a full-printer image, so the compiler builds one layer of printer-space XYZ points and passes those points to sample_points().

Define the printer and the two bounding boxes#

The example machine is described by:

printer_size_mm = pv.Vec3(60.0, 40.0, 20.0)
dpi = 100.0
layer_height_mm = 1.0

There are two bounding boxes:

  1. The printer bounding box runs from (0, 0, 0) to (60, 40, 20) mm.

  2. The object bounding box comes from root.bounding_box().

The object must not exceed the printer in X, Y, or Z. The compiler centers its bounding box in the printer’s XY image and places its lowest Z coordinate on the build plane. It preserves the design’s orientation and does not modify the caller’s tree.

The lower-left corner of every displayed PNG is the lower-left corner of the print bed. The physical pixel pitch is:

\[ p_{xy} = \frac{25.4\ \mathrm{mm/in}}{\mathrm{DPI}} \]

At 100 DPI, \(p_{xy}=0.254\) mm. The 60 × 40 mm bed becomes a 236 × 157 pixel image after rounding to whole pixels.

Printer and object bounding boxes

Printer bounding box with a smaller exposure-gradient object bounding box centered in XY

Implement the reusable compiler#

The class constructor stores the design and machine settings. It computes the pixel pitch and full-bed image dimensions, but it does not assume a particular geometry:

class GrayscaleVatCompiler:
    exposure_attribute = "exposure"

    def __init__(
        self,
        root,
        printer_size_mm,
        dpi,
        layer_height_mm,
        output_directory,
    ):
        self.root = root
        self.printer_size_mm = printer_size_mm
        self.dpi = float(dpi)
        self.layer_height_mm = float(layer_height_mm)
        self.output_directory = Path(output_directory)

        self.pixel_pitch_mm = 25.4 / self.dpi
        self.image_width = round(
            self.printer_size_mm.x / self.pixel_pitch_mm
        )
        self.image_height = round(
            self.printer_size_mm.y / self.pixel_pitch_mm
        )

Validate fit before sampling#

bounding_box() is safe before prepare(), so the compiler can reject an oversized job early:

object_min, object_max = self.root.bounding_box()
object_size = (
    object_max.x - object_min.x,
    object_max.y - object_min.y,
    object_max.z - object_min.z,
)

printer_size = (
    self.printer_size_mm.x,
    self.printer_size_mm.y,
    self.printer_size_mm.z,
)

for name, object_length, printer_length in zip(
    ("X", "Y", "Z"), object_size, printer_size
):
    if object_length > printer_length:
        raise RuntimeError(
            "The object is {:.3f} mm in {}, but the printer allows "
            "only {:.3f} mm.".format(
                object_length, name, printer_length
            )
        )

Build and sample one layer at a time#

Each point is placed at a pixel center. The mapping below centers the object’s authored XY bounding box over the printer image:

for y_index in range(self.image_height):
    bed_y = (y_index + 0.5) * self.pixel_pitch_mm
    world_y = object_center_y + bed_y - printer_depth_mm / 2.0

    for x_index in range(self.image_width):
        bed_x = (x_index + 0.5) * self.pixel_pitch_mm
        world_x = object_center_x + bed_x - printer_width_mm / 2.0
        points.append(pv.Vec3(world_x, world_y, world_z))

samples = sampler.sample_points(points, progress_callback)

The returned sample at index i belongs to points[i]. Outside geometry is black. Inside geometry reads exposure, validates it, and maps it to one grayscale byte:

if signed_distance is None or signed_distance > 0:
    grayscale = 0
elif attributes is None or not attributes.has_sample("exposure"):
    grayscale = 0
else:
    exposure = attributes.get_sample("exposure")
    if exposure < 0.0 or exposure > 1.0:
        raise RuntimeError("Exposure must be between 0 and 1.")
    grayscale = round(exposure * 255.0)

The implementation writes each completed layer immediately. PNG files store rows from top to bottom, so it vertically flips the NumPy array before saving; this makes the displayed lower-left pixel correspond to bed (0, 0).

Missing attributes and invalid values#

A root-level check answers “does exposure occur anywhere in this tree?” It does not answer “is exposure defined at this exact interior point?” A union, for example, may combine one branch that defines exposure with another that does not.

The VAT Photo Compiler guide and the examples under examples/attributes_by_type/undefined/ show the same issue with built-in compiler attributes.

There are two reasonable policies for a custom compiler:

  • strict: stop at the first undefined interior sample and ask the designer to repair the field; or

  • fallback: substitute a documented machine-safe value.

This example uses exposure 0.0 as the fallback, so an undefined location is not cured. It counts these samples and prints one warning after compilation. It fails immediately if the root never contains exposure, if the sampled value is not scalar, or if a value is not finite or falls outside [0, 1].

Complete compiler module#

The full implementation is kept here as a code block and as the reusable grayscale_vat_compiler.py file. You can also browse the OpenVCAD-Public examples for more complete workflows:

  1"""A small, reusable grayscale VAT compiler written entirely in Python."""
  2
  3import math
  4from pathlib import Path
  5
  6import numpy as np
  7from PIL import Image
  8import pyvcad as pv
  9
 10
 11class GrayscaleVatCompiler:
 12    """Compile an OpenVCAD design into full-bed 8-bit grayscale PNG layers."""
 13
 14    exposure_attribute = "exposure"
 15
 16    def __init__(
 17        self,
 18        root,
 19        printer_size_mm,
 20        dpi,
 21        layer_height_mm,
 22        output_directory,
 23    ):
 24        self.root = root
 25        self.printer_size_mm = printer_size_mm
 26        self.dpi = float(dpi)
 27        self.layer_height_mm = float(layer_height_mm)
 28        self.output_directory = Path(output_directory)
 29        self.undefined_sample_count = 0
 30
 31        self._validate_settings()
 32        self.pixel_pitch_mm = 25.4 / self.dpi
 33        self.image_width = max(
 34            1, round(self.printer_size_mm.x / self.pixel_pitch_mm)
 35        )
 36        self.image_height = max(
 37            1, round(self.printer_size_mm.y / self.pixel_pitch_mm)
 38        )
 39
 40    def _validate_settings(self):
 41        printer_dimensions = (
 42            self.printer_size_mm.x,
 43            self.printer_size_mm.y,
 44            self.printer_size_mm.z,
 45        )
 46        if any(value <= 0 for value in printer_dimensions):
 47            raise ValueError("Printer dimensions must be positive.")
 48        if self.dpi <= 0:
 49            raise ValueError("Printer DPI must be positive.")
 50        if self.layer_height_mm <= 0:
 51            raise ValueError("Layer height must be positive.")
 52
 53    def _prepare_placement(self):
 54        object_min, object_max = self.root.bounding_box()
 55        object_size = (
 56            object_max.x - object_min.x,
 57            object_max.y - object_min.y,
 58            object_max.z - object_min.z,
 59        )
 60        printer_size = (
 61            self.printer_size_mm.x,
 62            self.printer_size_mm.y,
 63            self.printer_size_mm.z,
 64        )
 65
 66        dimension_names = ("X", "Y", "Z")
 67        for name, object_length, printer_length in zip(
 68            dimension_names, object_size, printer_size
 69        ):
 70            if object_length > printer_length:
 71                raise RuntimeError(
 72                    "The object is {:.3f} mm in {}, but the printer allows "
 73                    "only {:.3f} mm.".format(object_length, name, printer_length)
 74                )
 75
 76        self.object_min = object_min
 77        self.object_max = object_max
 78        self.object_size = object_size
 79        self.object_center_x = (object_min.x + object_max.x) / 2.0
 80        self.object_center_y = (object_min.y + object_max.y) / 2.0
 81        self.layer_count = max(1, math.ceil(object_size[2] / self.layer_height_mm))
 82
 83    def _points_for_layer(self, layer_index):
 84        world_z = (
 85            self.object_min.z
 86            + (layer_index + 0.5) * self.layer_height_mm
 87        )
 88        points = []
 89
 90        for y_index in range(self.image_height):
 91            bed_y = (y_index + 0.5) * self.pixel_pitch_mm
 92            world_y = (
 93                self.object_center_y
 94                + bed_y
 95                - self.printer_size_mm.y / 2.0
 96            )
 97
 98            for x_index in range(self.image_width):
 99                bed_x = (x_index + 0.5) * self.pixel_pitch_mm
100                world_x = (
101                    self.object_center_x
102                    + bed_x
103                    - self.printer_size_mm.x / 2.0
104                )
105                points.append(pv.Vec3(world_x, world_y, world_z))
106
107        return points
108
109    def _grayscale_layer(self, points, samples):
110        layer = np.zeros(
111            (self.image_height, self.image_width),
112            dtype=np.uint8,
113        )
114
115        for index, (signed_distance, attributes) in enumerate(samples):
116            if signed_distance is None or signed_distance > 0:
117                continue
118
119            if (
120                attributes is None
121                or not attributes.has_sample(self.exposure_attribute)
122            ):
123                self.undefined_sample_count += 1
124                continue
125
126            exposure = attributes.get_sample(self.exposure_attribute)
127            if not isinstance(exposure, (int, float)):
128                raise RuntimeError(
129                    "'{}' must be a floating-point attribute.".format(
130                        self.exposure_attribute
131                    )
132                )
133            if not math.isfinite(exposure) or exposure < 0.0 or exposure > 1.0:
134                point = points[index]
135                raise RuntimeError(
136                    "Exposure must be between 0 and 1; sampled {} at "
137                    "({:.3f}, {:.3f}, {:.3f}) mm.".format(
138                        exposure,
139                        point.x,
140                        point.y,
141                        point.z,
142                    )
143                )
144
145            y_index = index // self.image_width
146            x_index = index % self.image_width
147            layer[y_index, x_index] = round(exposure * 255.0)
148
149        return layer
150
151    def compile(self, progress_callback=None):
152        """Compile the design and return the paths of the generated PNG files."""
153
154        if self.exposure_attribute not in self.root.attribute_list():
155            raise RuntimeError(
156                "The design does not contain an '{}' attribute.".format(
157                    self.exposure_attribute
158                )
159            )
160
161        self._prepare_placement()
162        self.output_directory.mkdir(parents=True, exist_ok=True)
163        for old_layer in self.output_directory.glob("layer_*.png"):
164            old_layer.unlink()
165
166        voxel_size = pv.Vec3(
167            self.pixel_pitch_mm,
168            self.pixel_pitch_mm,
169            self.layer_height_mm,
170        )
171
172        # TreeSampler prepares the root with this voxel size and a narrow-band
173        # width of six times the largest voxel dimension.
174        sampler = pv.TreeSampler(self.root, voxel_size)
175        output_paths = []
176        self.undefined_sample_count = 0
177        last_progress = -1
178
179        def report_progress(layer_index, sample_progress):
180            nonlocal last_progress
181            overall = round(
182                100.0
183                * (layer_index + sample_progress / 100.0)
184                / self.layer_count
185            )
186            if progress_callback is not None and overall > last_progress:
187                last_progress = overall
188                progress_callback(overall)
189
190        for layer_index in range(self.layer_count):
191            points = self._points_for_layer(layer_index)
192            samples = sampler.sample_points(
193                points,
194                lambda percent: report_progress(layer_index, percent),
195            )
196            layer = self._grayscale_layer(points, samples)
197
198            # PNG rows are stored top-to-bottom. Flipping here makes the
199            # displayed lower-left pixel correspond to the print-bed origin.
200            output_path = self.output_directory / "layer_{:04d}.png".format(
201                layer_index
202            )
203            Image.fromarray(np.flipud(layer)).save(output_path)
204            output_paths.append(output_path)
205
206        if self.undefined_sample_count:
207            print(
208                "Warning: {} inside samples had no '{}' value and were "
209                "written as exposure 0.".format(
210                    self.undefined_sample_count,
211                    self.exposure_attribute,
212                )
213            )
214
215        return output_paths

Run the compiler#

compile_design.py constructs the design, supplies the printer settings, and prints progress in ten-percent increments:

root = build_design()

compiler = GrayscaleVatCompiler(
    root,
    pv.Vec3(60.0, 40.0, 20.0),
    100.0,
    1.0,
    Path(__file__).parent / "output",
)

def show_progress(percent):
    if percent % 10 == 0:
        print("Compiling: {:3d}%".format(percent))

layers = compiler.compile(show_progress)
print("Wrote {} layers".format(len(layers)))

From the repository root:

./.venv/bin/python examples/compilers/custom_python_vat/compile_design.py

The example writes six full-bed PNGs. The middle layer below is copied directly from that output stack:

Compiled grayscale PNG layer

Full printer-bed grayscale PNG with a centered rectangular exposure gradient

The black area is the unused printer bed. Inside the object, grayscale rises from exposure 0.1 on the left to 1.0 on the right.

To preview the authored design interactively before compiling:

./.venv/bin/python examples/compilers/custom_python_vat/preview_design.py

Beyond image stacks#

Voxel and image-stack compilers are often the easiest place to start, but TreeSampler is not restricted to regular grids.

Suppose an external slicer has already produced G-code motion points. A Python compiler could collect those XYZ locations, call sample_points() once per chunk, and insert OpenVCAD attributes such as speed_override or laser_power into the outgoing G-code.

Other approaches can segment a design into meshes or build complete slicer projects. See the Slicer Project Compiler and the other compiler guides for examples of what the provided compilers can produce.

Bulk results still occupy memory. At very high resolution, do not construct every point and every sample for the full three-dimensional build at once. Chunk by layer, path segment, tile, or another natural machine unit; write each completed result, release its arrays, and then sample the next chunk. This guide’s compiler already follows that pattern by keeping only one printer layer in memory.

Source access and help#

The supplied compilers are valuable references even when their implementations are more extensive than this tutorial. You can request access to the OpenVCAD source repository to study them and adapt their validation and output strategies to new machines.

We are interested in helping users reach additional manufacturing systems. If you are developing a compiler, start a conversation through GitHub Discussions or email charles.wade@colorado.edu to discuss the machine and its file format.