---
title: "Printability Report"
description: "Grade a folder of STL files PASS, WARN, or FAIL for printing, with the checks behind each grade and a picture of each part that did not pass."
url: https://developer.formlabs.com/examples/printability-report/
---

# Printability Report

This script checks a folder of STL files for printability and grades each part PASS, WARN, or FAIL.
For each part, the report lists the checks that found a problem and renders
each part that did not pass.

[View on GitHub](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/printability-report) [Download printability-report.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/printability-report.zip)

The script uses the PreForm API to check each mesh for holes, thin walls, and cups that trap resin.

```
FAIL beam_400mm
     FAIL: does not fit on the build platform
PASS bracket
FAIL cup
     FAIL: 1 cup(s) that trap resin; reorient or add drain holes
FAIL holed_bracket
     FAIL: not manifold (holes or self-intersections); rerun with --repair
     WARN: wall thickness not checked (TOO_SMALL)
WARN thin_fin
     WARN: 1 region(s) thinner than 0.5 mm, 88.7 mm3

5 parts: 1 pass, 1 warn, 3 fail
```

![An L bracket on supports with a triangular hole in the top of its horizontal arm](https://developer.formlabs.com/examples/preflight-holed-bracket.png)

![A block on supports with a 0.3 mm fin standing on its top face](https://developer.formlabs.com/examples/preflight-thin-fin.png)

This example contains one script: `printability_report.py` which runs the checks through the PreForm API. It also contains five STL files in the `order-1234`folder with examples of various issues.

## Connect to PreForm

Make sure [PreForm is ready to accept connections](https://developer.formlabs.com/getting-started/install.md#connect-to-a-running-preform).

The constants at the top of the script set the PreForm address, the printer, the grades, and the
support threshold:

```python

PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388")
MACHINE_TYPE = "FORM-4-0"
PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
# A part with more than this percentage of its surface under-supported gets a warning.
MAX_UNSUPPORTED_PERCENT = 1.0
MESH_SUFFIXES = {".stl", ".obj", ".3mf"}
```

`main` reads the command-line options, connects to PreForm, and exits with a hint if nothing
answers:

```python

def main() -> None:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("parts", nargs="+", type=Path,
                        help="STL, OBJ, or 3MF files, or folders of them")
    parser.add_argument("--repair", action="store_true",
                        help="repair non-manifold parts and check again")
    parser.add_argument("--thin-mm", type=float, default=0.5,
                        help="warn about walls thinner than this")
    parser.add_argument("--material", default="FLGPGR05",
                        help="material code to support and lay out with")
    parser.add_argument("--screenshots", type=Path,
                        help="folder for a picture of each part that did not pass")
    parser.add_argument("--json", action="store_true", help="print the report as JSON")
    args = parser.parse_args()

    with FormlabsClient() as client:
        try:
            preform = client.preform.connect(base_url=PREFORM_URL)
        except PreFormApiError as exc:
            sys.exit(f"{exc}\nNo PreForm at {PREFORM_URL}; "
                     "open PreForm and start its API server")
        parts = collect_parts(args.parts)
```

`PREFORM_URL` defaults to `http://localhost:44388`. Set `FL_LOCAL_BASE_URL` to reach PreForm on
another port or host.

## Create a scene for the order

`printability_report` creates one scene for every part in the order:

```python

def printability_report(preform: PreFormApi, parts: list[Path], material: str,
                        thin_mm: float, repair: bool,
                        screenshots: Path | None) -> list[dict]:
    """Run every check on the parts in one scene and grade each PASS, WARN, or FAIL."""
    scene = preform.create_scene(
        machine_type=MACHINE_TYPE,
        material_code=material,
        layer_thickness_mm=0.1,
        print_setting="DEFAULT",
    )
```

Supports and layout depend on the printer and the material, so the scene is a Form 4 at 0.1 mm
with the material from `--material`, `FLGPGR05` by default.

## Import the files as delivered

`import_parts` imports each file with `repair_behavior` set to `IGNORE`:

```python

def import_parts(
    preform: PreFormApi, scene_id: UUID, parts: list[Path], findings: Findings
) -> dict[str, str]:
    """Import each file as delivered and return a map of model ID to part name."""
    names: dict[str, str] = {}
    for part in parts:
        findings[part.stem] = []
        try:
            # IGNORE imports the mesh as the customer sent it, so the manifold check
            # sees any holes. The default, ERROR, fails the import of such a mesh.
            result = preform.import_models(
                scene_id=scene_id,
                file=str(part.resolve()),
                name=part.stem,
                repair_behavior=RepairBehavior.IGNORE,
            )
        except PreFormApiError as exc:
            findings[part.stem].append((FAIL, f"could not import: {exc.message}"))
            continue
        for model in result.new_models:
            names[str(model.model_id)] = part.stem
```

A file that fails to import is a FAIL with the server's message, and the other parts are still
checked.

## Check for holes

`check_meshes` asks the manifold check about every model in the scene and repairs the broken ones
when you pass `--repair` to the script:

```python

def check_meshes(preform: PreFormApi, scene_id: UUID, names: dict[str, str],
                 findings: Findings, thin_mm: float, repair: bool) -> None:
    """Check each mesh for holes and walls thinner than thin_mm."""
    manifold = preform.validate_manifoldness(scene_id=scene_id).per_model_results
    broken = [
        model_id
        for model_id, result in manifold.additional_properties.items()
        if not result.is_manifold
    ]
    if broken and repair:
        repaired = preform.repair_models(
            scene_id=scene_id, model_ids=broken
        ).per_model_results
        for model_id in broken:
            if model_id in repaired and repaired[model_id].is_manifold:
                findings[names[model_id]].append(
                    (WARN, "not manifold; repaired, check it with the customer")
                )
            else:
                findings[names[model_id]].append(
                    (FAIL, "not manifold; repair did not fix it")
                )
    else:
        for model_id in broken:
            findings[names[model_id]].append(
                (FAIL, "not manifold (holes or self-intersections); "
                       "rerun with --repair")
```

Every validator returns a map of model ID to result, which the script joins back to the filename. A
repaired part is a WARN, because the customer has to agree to the repaired shape. For more
information about the checks, see [Validate](https://developer.formlabs.com/core-concepts/validate.md).

## Check wall thickness

The thin-wall check flags regions thinner than `--thin-mm`, 0.5 mm by default:

```python
thin = preform.validate_thin_features(
    scene_id=scene_id, threshold_mm=thin_mm
).per_model_results
for model_id, result in thin.additional_properties.items():
    # Any status other than ANALYZED means the model could not be assessed, such as
    # a mesh with holes.
    if result.status != ThinWallDetectionResultStatus.ANALYZED:
        findings[names[model_id]].append(
            (WARN, f"wall thickness not checked ({result.status})")
        )
    elif result.has_thin_walls:
        findings[names[model_id]].append(
            (WARN, f"{result.thin_region_count} region(s) thinner than {thin_mm} mm, "
                   f"{result.thin_volume_mm3:.1f} mm3")
        )
```

## Check that the parts fit

`check_layout` lays the parts out on one build platform and reports the ones the layout ejects:

```python

def check_layout(
    preform: PreFormApi, scene_id: UUID, names: dict[str, str], findings: Findings
) -> None:
    """Lay the parts out on one build platform and report the ones that don't fit."""
    # EJECT_A_MODEL_AND_RETRY removes parts from the scene, picked at random, until the
    # rest fit. The cup and support checks fail for the whole scene while any model is
    # out of bounds, so layout runs before them.
    layout = preform.auto_layout_models(
        scene_id=scene_id, on_layout_failure=OnLayoutFailure.EJECT_A_MODEL_AND_RETRY
    )
    for model_id in layout.ejected_model_ids:
        findings[names[str(model_id)]].append(
            (FAIL, "does not fit on the build platform")
        )
    for pair in preform.validate_interferences(scene_id=scene_id).model_ids:
        for model_id in pair:
            findings[names[str(model_id)]].append(
                (FAIL, "collides with another part after layout")
            )
    for model in preform.list_models(scene_id=scene_id).models:
        if not model.in_bounds:
```

The 400 mm beam in the sample folder is longer than a Form 4 build platform in any direction, so the
layout ejects it.

## Check for cups

`check_printability` starts with the cup check, which looks for cavities that face the build
platform and seal against the resin as the part prints:

```python

def check_printability(
    preform: PreFormApi, scene_id: UUID, names: dict[str, str], findings: Findings
) -> None:
    """Check for cups that trap resin, then support each part and check its supports."""
    cups = preform.validate_cups(scene_id=scene_id).per_model_results
    for model_id, result in cups.additional_properties.items():
        if result.cup_count:
            findings[names[model_id]].append(
                (FAIL, f"{result.cup_count} cup(s) that trap resin; "
                       "reorient or add drain holes")
```

The check always runs on the whole scene and depends on each part's orientation.

## Check the supports

The script supports every part with the default settings, then checks what the supports miss:

```python
preform.auto_support_models(scene_id=scene_id)
minima = preform.validate_minima(scene_id=scene_id).per_model_results
for model_id, result in minima.additional_properties.items():
    if result.minima_count:
        findings[names[model_id]].append(
            (FAIL, f"{result.minima_count} unsupported minima after auto-support")
        )

supported = preform.validate_supportedness(scene_id=scene_id).per_model_results
for model_id, result in supported.additional_properties.items():
    if result.percent_unsupported > MAX_UNSUPPORTED_PERCENT:
        findings[names[model_id]].append(
            (WARN, f"{result.percent_unsupported:.0f}% of the surface under-supported")
        )
```

A model with an unsupported minimum will be marked with `FAIL`. And a part with more than `MAX_UNSUPPORTED_PERCENT` of its surface
under-supported will be marked with `WARN`.

## Render the parts that did not pass

When `--screenshots` is passed to the scrip, `render_offenders` renders each part that is marked with `WARN` or `FAIL`:

```python

def render_offenders(preform: PreFormApi, scene_id: UUID, names: dict[str, str],
                     findings: Findings, folder: Path) -> None:
    """Render a picture of every part that did not pass, framed on that part alone."""
    folder.mkdir(parents=True, exist_ok=True)
    # Parts that layout ejected are no longer in the scene, so they get no picture.
    for model in preform.list_models(scene_id=scene_id).models:
        name = names[str(model.id)]
        if findings[name]:
            preform.render_scene(
                scene_id=scene_id,
                file=str((folder / f"{name}.png").resolve()),
                model_ids=[model.id],
                view_type=RenderViewType.ZOOM_ON_MODELS,
                image_size_px=600,
```

## Running all the checks

`printability_report` runs the checks in order, and its `finally` block deletes the scene:

```python
findings: Findings = defaultdict(list)
try:
    names = import_parts(preform, scene.scene_id, parts, findings)
    if names:
        check_meshes(preform, scene.scene_id, names, findings, thin_mm, repair)
        check_layout(preform, scene.scene_id, names, findings)
        check_printability(preform, scene.scene_id, names, findings)
        if screenshots:
            render_offenders(preform, scene.scene_id, names, findings, screenshots)
finally:
```

Without it, a failed check would leave the scene open on the server.

## Grade each part

`printability_report` attaches the worst status label it has found for each part:

```python

report = []
for name, items in findings.items():
    levels = {level for level, _ in items}
    status = FAIL if FAIL in levels else WARN if WARN in levels else PASS
    report.append({
        "part": name,
        "status": status,
        "findings": [{"level": level, "message": message} for level, message in items],
    })
```

## Set the exit status

`main` prints the report and exits with status 1 if any part fails:

```python

counts = {status: sum(entry["status"] == status for entry in report)
          for status in (PASS, WARN, FAIL)}
if args.json:
    print(json.dumps(report, indent=1))
else:
    for entry in report:
        print(f"{entry['status']} {entry['part']}")
        for finding in entry["findings"]:
            print(f"     {finding['level']}: {finding['message']}")
    print(f"\n{len(report)} parts: {counts[PASS]} pass, "
          f"{counts[WARN]} warn, {counts[FAIL]} fail")
# A non-zero exit lets an order system or a CI pipeline hold the job.
```

WARN parts don't change the exit status. Pass `--json` to print the report as JSON for a subsequent
system to store.

## Run the example

In a terminal, run the script from the folder you unzipped. With pip, [install the Formlabs SDK](https://developer.formlabs.com/getting-started/install.md) first; with [uv](https://docs.astral.sh/uv/getting-started/installation/), `uv run` installs it from the example's `pyproject.toml`.

**pip:**

```bash
python printability_report.py order-1234 --screenshots renders
```

**uv:**

```bash
uv run printability_report.py order-1234 --screenshots renders
```

The script reads every STL, OBJ, and 3MF file in `order-1234` and writes a PNG file for each part
that did not pass to `renders`. To check another order, pass its folder or its files. The script
prints a line for each part:

```
FAIL beam_400mm
     FAIL: does not fit on the build platform
PASS bracket
FAIL cup
     FAIL: 1 cup(s) that trap resin; reorient or add drain holes
FAIL holed_bracket
     FAIL: not manifold (holes or self-intersections); rerun with --repair
     WARN: wall thickness not checked (TOO_SMALL)
WARN thin_fin
     WARN: 1 region(s) thinner than 0.5 mm, 88.7 mm3

5 parts: 1 pass, 1 warn, 3 fail
```

With `--repair`, the hole in `holed_bracket` is repaired and the part becomes a WARN:

```
WARN holed_bracket
     WARN: not manifold; repaired, check it with the customer
```

## What's next

- [Validate](https://developer.formlabs.com/core-concepts/validate.md)
- [Part labels](https://developer.formlabs.com/examples/part-labels.md)
