Formlabs Developers
Download PreForm Insider to start developing with the PreForm API.

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 Download 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

A block on supports with a 0.3 mm fin standing on its top face

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-1234folder with examples of various issues.

Connect to PreForm#

Make sure PreForm is ready to accept connections.

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.

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 first; with uv, uv run installs it from the example's pyproject.toml.

Terminal
python printability_report.py order-1234 --screenshots renders
Terminal
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#

Feedback & Bug Reports