(guide-compilers-material-inkjet)= # Material Inkjet Compiler ```{include} ../_guide-sidebar-compiler-membership.md ``` The **MaterialInkjetCompiler** turns OpenVCAD designs with **`VOLUME_FRACTIONS`** into a **stack of RGBA PNG images**: one image per build layer, where each pixel encodes the **resolved material** for one addressable voxel in a material-jetting workflow. This page is both a **tutorial** (from a valid design to PNG slices) and a **reference** for compiler behavior and Python API. ## Voxel-printing workflow The Material Inkjet compiler is meant for **inkjet / PolyJet-class 3D printers** that use a workflow often called **voxel printing**. These systems build objects by depositing droplets of photocurable polymers layer by layer. As the layers stack up, those droplets form a 3D object. Unlike 2D inkjet paper printing, a 3D material-jetting voxel cannot hold an arbitrary blend of inks. At a given voxel location, the printer must choose **one** material channel to deposit. That gives the workflow full local composition control: for every addressable voxel, the slice stack can say which material should be placed there. Large-scale gradients come from changing the density of material choices across a region rather than from storing a blended color at one voxel. For example, a region that should behave like a 50/50 red-blue mixture is represented by many neighboring voxels where roughly half are assigned red and half are assigned blue. Because inkjet printers work at very small scales, that interdigitated pattern can appear smooth at the part scale. A Stratasys J750-style workflow, for example, uses a voxel pitch around **42.3 x 84.6 x 27 microns** (**0.0423 x 0.0846 x 0.027 mm**). Inkjet 3D printers commonly consume a **stack of PNG files**. Each PNG is one Z layer, and each pixel is one addressable voxel in that layer. The pixel color is a palette value that downstream printer software maps to a physical material channel. These PNG stacks are usually composed of **base material colors interdigitated across space**, not full-color gradients in the image data itself, because each voxel color must identify one printable channel. OpenVCAD provides a programmable way to generate these stacks from implicit geometry and material fields. This matters because the voxel count is far beyond what a designer can draw manually in traditional image-editing software. A large J750-class build can contain **over 400 billion addressable voxels**, so the practical workflow is to model the part and its material distribution parametrically, then let OpenVCAD compile the PNG stack. OpenVCAD produces the PNG stack, but it does not perform the final printer setup. Downstream software such as **Stratasys GrabCAD Print** or the **Voxel Print Utility** is still responsible for mapping the PNG color palette into printer material channels. Likewise, the Material Inkjet compiler does not generate support material; support generation is normally handled later by the printer workflow. ## Prerequisites - OpenVCAD installed with **`pyvcad`** and **`pyvcad_compilers`** (see the installation guide and repository **`examples/project_example`**). - Familiarity with **`VOLUME_FRACTIONS`** on geometry (see {ref}`guide-getting-started`, especially **Lesson 6: Volume Fractions**, and the [Functional Grading Guide](../gradients.md)). ## What this compiler is for Use **Material Inkjet** when you need **discrete material assignment per voxel** derived from a **volume-fraction mixture** at each point, exported as **slice images** for hardware or software that consumes **layered PNG (or similar) stacks**. Typical examples include **multi-material jetting** systems and other pipelines that think in **Z-ordered voxel planes**. If your goal is **continuous RGB color** per voxel through an ICC-aware pipeline that maps to **CMYK + white + clear** process inks, use the [Color Inkjet](color-inkjet.md) compiler instead (**`COLOR_RGBA`**). ## What "PNG stacks" means here After compilation you get **one PNG file per Z layer** in the voxel grid built from the scene's bounding box: - **Width x height** of each PNG equals the **X x Y** voxel resolution for that job. - **RGBA** stores the **material color** from your **`MaterialDefs`** for the **chosen** material ID at that voxel, not a continuous mixture rendered as a color blend in the PNG (see [Assignment modes](#assignment-modes)). - **File naming:** each slice is written as your **`file_prefix`** plus the layer index plus **`.png`** in the output directory. **Empty layers** (no active voxels) are **removed** after the run to keep the stack smaller. - **Coordinate note:** slice images may use an **X-axis flip** relative to world axes. If your downstream tool expects a different image handedness, you may need to account for that when interpreting files. ## How volume fractions become voxel assignments The compiler voxelizes the model's bounding box using your **`voxel_size`**, samples the implicit design on that grid, and writes one RGBA PNG per Z layer. The important conversion is from a continuous, implicit material field to a **single material assignment** at each voxel. For each voxel sample location, the compiler evaluates two ideas from the OpenVCAD design: - **`g(x, y, z)`**: the signed-distance field for the geometry. Negative values are inside the object, zero is on the surface, and positive values are outside. - **`a(x, y, z)`**: the sampled **`VOLUME_FRACTIONS`** attribute at that location. For this attribute, the value is a material mixture: material IDs mapped to local fractions. First, **`g(x, y, z)`** decides whether the voxel is part of the build. If the sample is outside the object, the compiler writes the transparent void color **`(0, 0, 0, 0)`**. If the sample is inside or on the surface, the compiler samples **`a(x, y, z)`** and uses the resulting volume fractions to choose one material. Volume fractions follow the same normalization rule as probability distributions: the local material fractions should sum to one. The compiler can resolve those fractions with either independent weighted sampling or deterministic 3D error diffusion. If a voxel location samples a **0.5 / 0.5** mixture of red and blue, both modes target an equal population of red and blue voxels, but they distribute those choices differently in space. The PNG pixel receives the RGBA color for the selected material from **`MaterialDefs`**. This process repeats for every voxel in the object's bounding box, at the requested printer resolution, until the compiler has written the complete stack. **Asymmetric voxel sizes are fully supported**; inkjet printers often have different X, Y, and Z pitches, so **`voxel_size`** is a **`Vec3`** rather than one scalar.

Per-voxel material assignment

Flowchart showing how signed distance and volume fractions become material assignments in a PNG stack
(assignment-modes)= ### Assignment modes Where **`VOLUME_FRACTIONS`** define a **mixture** of materials, the PNG does **not** store fractional blending. Choose the spatial assignment method with **`MaterialInkjetExportMode`**: - **`STOCHASTIC`** is the default and preserves the original compiler behavior. Each active voxel independently makes a weighted random choice from its local material fractions. It is fast and highly parallel, but small neighborhoods can deviate noticeably from their requested mixture and repeated runs produce different patterns. - **`DITHERED_3D`** makes a deterministic material choice and diffuses its quantization error into later neighboring voxels in X, Y, and Z. The scan alternates direction on successive rows to reduce directional artifacts. This produces the ordered patterns expected from error diffusion and usually tracks requested fractions more closely in small neighborhoods. The dithered mode normalizes nonnegative fractions after liquid keep-out is applied. A material whose local fraction is zero is not eligible to receive diffused error, so exact single-material regions, fallback assignments, and liquid exclusions remain hard constraints. Negative or non-finite fractions are rejected with a location-bearing error instead of being silently treated as probabilities. The two modes below compile the same uniform 50/50 red-blue field. The metrics measure the red fraction of the whole slice and the mean absolute red-fraction error across non-overlapping 8 x 8 voxel windows. The random sample is close globally but uneven locally; the error-diffused result is exactly balanced in this test and forms a regular pattern.

Continuous 50/50 VOLUME_FRACTIONS preview

Purple preview of a prism with a continuous 50 percent red and 50 percent blue volume-fraction field

Discrete material assignments

Side-by-side stochastic and three-dimensional error-diffused slices for a 50 percent red and 50 percent blue mixture
(liquid-keep-out)= ### Liquid keep-out If **`liquid_keep_out_distance > 0`**, the **liquid** material is removed from the local mixture when the sample lies within that distance of the **surface** (using the signed distance). Your **`MaterialDefs`** must include a material named **`"liquid"`** or **`"M.Cleanser"`** in that mode, or **`compile()`** raises an error. Use this when your process should avoid assigning liquid near boundaries. ### Missing **`VOLUME_FRACTIONS`** inside the solid This behavior is separate from the **up-front check** that the design exposes **`VOLUME_FRACTIONS`** at the root. Review **Lesson 6: Volume Fractions** for how the attribute is attached, and **Lesson 7: The Node Hierarchy and Attribute Priority** for how unions and overrides affect which regions inherit **`VOLUME_FRACTIONS`**. That is why one branch of a union can be "missing" data even when another branch is not: - **Default:** if a voxel is inside the solid but **`VOLUME_FRACTIONS`** are not defined there, the compiler uses **`fallback_material_id`** (default **0**, typically **void** in the default material table) and still treats the voxel as active. - **`set_strict_mode(True)`:** raises **`RuntimeError`** on the first inside voxel that lacks **`VOLUME_FRACTIONS`**, including the world-space location in the message. If the **`VOLUME_FRACTIONS`** name never appears on the root attribute list at all, **`compile()`** fails before voxelization. See **`examples/attributes_by_type/undefined/material_inkjet_example.py`** for a union where only one child carries **`VOLUME_FRACTIONS`**. ## Tutorial: design to PNG slices The scripts for this walkthrough live under **`examples/compilers/material_inkjet/`** in the repository.

OpenVCAD to voxel-printing stack

Workflow from OpenVCAD design to PNG stack to downstream material channel mapping for inkjet printing
### 1. Build geometry with **`VOLUME_FRACTIONS`** Use **`pv.VolumeFractionsAttribute`** with expressions and material IDs from **`pv.default_materials`** (or your own **`MaterialDefs`** JSON). Fractions at each point should **sum to 1** (see **Lesson 6: Volume Fractions**). The example uses a rectangular prism with a left-to-right material gradient between blue and red. The renderer preview shows the continuous **`VOLUME_FRACTIONS`** field before it is converted into discrete voxel assignments.

VOLUME_FRACTIONS (preview)

Material inkjet guide preview render
### 2. Choose **`voxel_size`** **`voxel_size`** is a **`Vec3`** of voxel edge lengths in **millimeters** (same unit convention as the rest of OpenVCAD). Resolution in each axis scales with **bounding box extent / voxel size**. Finer voxels increase memory use, file count, and compile time. For inkjet workflows, **`voxel_size` is often set to match your printer's native voxel or slice resolution** (or an integer fraction of it) so the stack aligns with what the machine or downstream printer software expects. The runnable example below uses **`pv.Vec3(0.0423, 0.0846, 0.027)`**, matching the J750-style pitch discussed above. ### 3. Run the compiler ```python import os import shutil import pyvcad as pv import pyvcad_compilers as pvc import pyvcad_rendering as viz materials = pv.default_materials cube = pv.RectPrism(pv.Vec3(0, 0, 0), pv.Vec3(20, 10, 10)) fraction_gradient = pv.VolumeFractionsAttribute( [ ("x/20 + 0.5", materials.id("blue")), ("-x/20 + 0.5", materials.id("red")) ] ) cube.set_attribute(pv.DefaultAttributes.VOLUME_FRACTIONS, fraction_gradient) root = cube voxel_size = pv.Vec3(0.0423, 0.0846, 0.027) output_dir = os.path.join(os.path.dirname(__file__), "output") prefix = "slice_" if os.path.isdir(output_dir): shutil.rmtree(output_dir) os.makedirs(output_dir, exist_ok=True) compiler = pvc.MaterialInkjetCompiler( root, voxel_size, output_dir, prefix, materials, 0.0, pvc.MaterialInkjetExportMode.STOCHASTIC, ) def on_progress(p): print("compile progress: {:.1f}%".format(100.0 * p)) compiler.set_progress_callback(on_progress) compiler.compile() print("resolution (x, y, z png count):", compiler.resolution()) print("material voxel counts:", compiler.material_voxel_counts()) viz.Render(root, materials) ``` After **`compile()`**, **`output/`** contains the PNG stack. Inspect a slice in any image viewer; Z order follows the file index. When the job finishes, **`resolution()`** reports **`(x_pixels, y_pixels, z_png_count)`**, and **`material_voxel_counts()`** reports how many active voxels were assigned to each material ID. Change the final constructor argument to **`pvc.MaterialInkjetExportMode.DITHERED_3D`** to resolve the same design with 3D error diffusion. The separate comparison example runs both modes and writes them to different output directories. The comparison slices below are from the same example scene, using two coarser **`voxel_size`** settings for readability. Smaller voxels increase in-plane resolution and make smooth **`VOLUME_FRACTIONS`** gradients look less blocky at pixel scale, at the cost of more layers and longer compile times.

Slice - 0.15 mm voxels

Material inkjet slice at 0.15 mm voxel size

Slice - 0.05 mm voxels

Material inkjet slice at 0.05 mm voxel size
From the repository root, with the project virtual environment activated: **`python examples/compilers/material_inkjet/01_basic_png_stack.py`** runs the compile and then opens the interactive preview (**`viz.Render`** does not return until you close the window). To verify only the compile step, temporarily comment out the final **`viz.Render`** line. ### 4. Preview in the renderer With **`viz.Render(root, materials)`**, use the renderer's attribute selection to view **`VOLUME_FRACTIONS`** as usual. The PNG stack, however, shows **picked** materials per voxel, not the continuous mixture. ## Reference: Python API ### Constructor `MaterialInkjetCompiler(root, voxel_size, output_directory, file_prefix, material_defs, liquid_keep_out_distance=0.0, export_mode=MaterialInkjetExportMode.STOCHASTIC)` | Argument | Meaning | | -------- | ------- | | `root` | Root **`Node`** of the design tree. | | `voxel_size` | **`Vec3`** voxel spacing (mm), including asymmetric printer pitches. | | `output_directory` | Folder to create/write PNGs into. | | `file_prefix` | Prefix for slice filenames (`prefix + z + ".png"`). | | `material_defs` | **`MaterialDefs`** (e.g. **`pv.default_materials`**) mapping IDs to RGBA. | | `liquid_keep_out_distance` | Distance (mm) for [liquid keep-out](#liquid-keep-out); **`0`** disables. | | `export_mode` | **`STOCHASTIC`** for independent weighted sampling or **`DITHERED_3D`** for deterministic 3D error diffusion. | ### Methods (including **`CompilerBase`**) | Method | Role | | ------ | ---- | | `compile()` | Run the full pipeline. | | `supported_attributes()` | Names this compiler requires (includes **`VOLUME_FRACTIONS`**; same string as **`pv.DefaultAttributes.VOLUME_FRACTIONS`**). | | `cancel()` | Request cooperative cancellation. | | `set_progress_callback(fn)` | **`fn(progress)`** with **`progress`** in **[0, 1]**. | | `resolution()` | **`(x_pixels, y_pixels, z_png_count)`** after compile. | | `material_voxel_counts()` | **`dict`** mapping **material ID** to **active voxel count**. | | `set_strict_mode(bool)` | Require **`VOLUME_FRACTIONS`** at every inside voxel. | | `set_fallback_material_id(id)` | Material ID when strict mode is off and samples are missing. | For full autodoc signatures, see the **pyvcad_compilers** section (**`MaterialInkjetCompiler`** and **`CompilerBase`**). ## Limitations - **One discrete material** per voxel in the output; mixtures become stochastic or error-diffused assignments, not blended colors in the PNG. - Effective **resolution** is tied to **`voxel_size`** and **bounding box**; fine spatial detail needs sufficiently small voxels. - **Asymmetric** **`voxel_size`** components are allowed and expected for many inkjet workflows. Treat X, Y, and Z resolution separately. - **3D error diffusion** buffers the active voxel field and per-material fractions before a sequential dither pass. It therefore uses more memory and exposes less parallelism than stochastic sampling. The neighborhood weights operate in voxel-index space and are not rescaled for asymmetric physical voxel pitches. - **Root attribute list** must include **`VOLUME_FRACTIONS`**; missing **per-voxel** samples are handled by **strict** / **fallback** and are a different situation than the attribute never appearing on the tree. - The PNG stack is an OpenVCAD compiler output, not a complete printer job. Downstream printer software still maps palette colors to material channels and handles support generation. ## Further examples - **`examples/compilers/material_inkjet/01_basic_png_stack.py`** - end-to-end compile + render (this guide). - **`examples/compilers/material_inkjet/02_compare_export_modes.py`** - compile the same 50/50 mixture with stochastic and 3D error-diffused assignment, then compare the PNG stacks and voxel counts. - **`examples/attributes_by_type/undefined/material_inkjet_example.py`** - default fallback, custom fallback, and strict mode with **partial** **`VOLUME_FRACTIONS`** on a union.