M0UNTAIN 0F C0DE

📸 Rendering OpenSCAD Models To PNG

3D OpenSCAD

I'm working on a project where I need to generate transparent images of OpenSCAD models. Easy, right? OpenSCAD can directly export PNGs. As always, the devil is in the detail. My approach is inspired by this post which is doing a lot more than I need.

Antialiasing

The native PNG export produces some pretty jagged edges. We can work around this by exporting a huge image and then shrinking it down.

openscad-nightly \
    --hardwarnings \
    --autocenter \
    --viewall \
    --imgsize=4096,4096 \
    --render \
    -o model.png \
    model.scad

convert \
    model.png \
    -resize 700x700 \
    model.png

Transparency

Making the background transparent was a lot easier than I expected. Image Magick can do a colour-to-transparency mask. This replaces the background colour of #fafafa used in the Nature colour scheme. I also much prefer this colour scheme.

openscad-nightly \
    --hardwarnings \
    --autocenter \
    --viewall \
    --imgsize=4096,4096 \
    --colorscheme Nature \
    --render \
    -o model.png \
    model.scad

convert \
    model.png \
    -transparent "#fafafa" \
    -resize 700x700 \
    model.png

Padding

The exported images contain a lot of whitespace around them, again we can lean on Image Magick to strip it back:

openscad-nightly \
    --hardwarnings \
    --autocenter \
    --viewall \
    --imgsize=4096,4096 \
    --colorscheme Nature \
    --render \
    -o model.png \
    model.scad

convert \
    model.png \
    -transparent "#fafafa" \
    -trim \
    -resize 650x650 \
    -bordercolor none \
    -border 25 \
    model.png

Camera Position

The default camera position is usually fine, but sometimes you need to adjust the camera position. The --camera argument does this but don't try to manually figure out the numbers to pass. Instead, position the model in the GUI and copy the numbers from the toolbar:

openscad-nightly \
    --hardwarnings \
    --autocenter \
    --viewall \
    --imgsize=4096,4096 \
    --colorscheme Nature \
    --camera 3.12,-1.71,-2.69,73.20,0,32,192.04 \
    --render \
    -o model.png \
    model.scad

Multiple Parts

It's not unusual for a design to consist of multiple parts, and I want each part to have its own image. The solution I came up with was to add magic comments at the end of the file. Each one is a snippet which defines how to render a part. Replacing //part: with ! will render just that part.

module base() { /* ... */ }
module nozzle() { /* ... */ }

//part: base();
//part: nozzle();

Automation

Of course, I'm not doing all this manually. I whipped up the following script which processes all the models in a directory. It includes STL rendering too.

<?php

declare(strict_types=1);

$scadFilePaths = glob(__DIR__ . '/public/3d/*.scad');
$modelFilter = $argv[1] ?? null;
$exportFormat = $argv[2] ?? 'update-existing';

foreach ($scadFilePaths as $scadFilePath) {
    $baseFileName = basename($scadFilePath, '.scad');
    $fileName = basename($scadFilePath);
    $exitCode = null;
    $output = null;

    if ($modelFilter !== null && str_contains($fileName, $modelFilter) === false) {
        continue;
    }

    $scadParts = null;

    if (preg_match_all('@//\s*part:\s(.+?)(?=\n|$)@i', file_get_contents($scadFilePath), $scadParts) !== false) {
        $scadParts = $scadParts[1];
    }

    try {
        if ($scadParts === []) {
            echo 'Rendering ' . $fileName . '... ';

            $pngPath = dirname($scadFilePath) . '/' . $baseFileName . '.png';
            $stlPath = dirname($scadFilePath) . '/' . $baseFileName . '.stl';

            if ($exportFormat === 'update-existing') {
                if (file_exists($pngPath)) {
                    renderScadToPng($scadFilePath, $pngPath, true);
                }
                if (file_exists($stlPath)) {
                    renderScadToStl($scadFilePath, $stlPath);
                }
            } else {
                match($exportFormat) {
                    'png' => renderScadToPng($scadFilePath, $pngPath, true),
                    'stl' => renderScadToStl($scadFilePath, $stlPath),
                };
            }

            echo 'Done' . PHP_EOL;
        } else {
            foreach ($scadParts as $scadPart) {
                $scadPartFileName = str_replace(['(', ')', ';'], '', $scadPart);
                echo 'Rendering ' . $fileName . ' [' . $scadPartFileName . ']... ';

                $pngPath = dirname($scadFilePath) . '/' . $baseFileName . '_' . $scadPartFileName . '.png';
                $stlPath = dirname($scadFilePath) . '/' . $baseFileName . '_' . $scadPartFileName . '.stl';

                if ($exportFormat === 'update-existing') {
                    if (file_exists($pngPath)) {
                        renderScadPartToPng($scadFilePath, $pngPath, $scadPart, true);
                    }
                    if (file_exists($stlPath)) {
                        renderScadPartToStl($scadFilePath, $stlPath, $scadPart);
                    }
                } else {
                    match($exportFormat) {
                        'png' => renderScadPartToPng($scadFilePath, $pngPath, $scadPart, true),
                        'stl', => renderScadPartToStl($scadFilePath, $stlPath, $scadPart),
                    };
                }

                echo 'Done' . PHP_EOL;
            }
        }
    } catch (Exception $e) {
        echo 'FAILED' . PHP_EOL . $e->getMessage() . PHP_EOL . PHP_EOL . PHP_EOL;

        continue;
    }
}

function renderScadPartToStl($scadFilePath, string $stlPath, string $part): void
{
    $tempFilePath = $scadFilePath . '.tmp';

    try {
        file_put_contents($tempFilePath, file_get_contents($scadFilePath) . "\n\n!" . $part . ";\n");

        renderScadToStl($tempFilePath, $stlPath);
    } finally {
        unlink($tempFilePath);
    }
}

function renderScadToStl($scadFilePath, string $stlPath): void
{
    exec(
        'openscad-nightly \
        --hardwarnings \
        --autocenter \
        --viewall \
        -o ' . escapeshellarg($stlPath) . ' \
        ' . escapeshellarg($scadFilePath) . ' 2>&1',
        $output,
        result_code: $exitCode,
    );

    if ($exitCode !== 0) {
        throw new Exception(implode(PHP_EOL, $output));
    }
}

function renderScadPartToPng($scadFilePath, string $pngPath, string $part, bool $preview = false): void
{
    $tempFilePath = $scadFilePath . '.tmp';

    try {
        file_put_contents($tempFilePath, file_get_contents($scadFilePath) . "\n\n!" . $part . ";\n");

        renderScadToPng($tempFilePath, $pngPath, $preview);
    } finally {
        unlink($tempFilePath);
    }
}

function renderScadToPng($scadFilePath, string $pngPath, bool $preview = false): void
{
    exec(
        'openscad-nightly \
        --hardwarnings \
        --autocenter \
        --viewall \
        --imgsize=4096,4096 \
        --colorscheme Nature \
        ' . ($preview ? '--preview ' : '') . ' \
        --render \
        -o ' . escapeshellarg($pngPath) . ' \
        ' . escapeshellarg($scadFilePath) . ' 2>&1',
        $output,
        result_code: $exitCode,
    );

    if ($exitCode !== 0) {
        throw new Exception(implode(PHP_EOL, $output));
    }

    $exitCode = null;
    $output = null;

    exec(
        'convert \
        ' . escapeshellarg($pngPath) . ' \
        -transparent "#fafafa" \
        -trim \
        -resize 650x650 \
        -bordercolor none \
        -border 25 \
        ' . escapeshellarg($pngPath) . ' 2>&1',
        $output,
        result_code: $exitCode,
    );

    if ($exitCode !== 0) {
        throw new Exception(implode(PHP_EOL, $output));
    }
}