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

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:
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:
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:
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:
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.stemA 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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
python printability_report.py order-1234 --screenshots rendersuv run printability_report.py order-1234 --screenshots rendersThe 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 failWith --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