Instant quote
This script quotes a price for a part in each material on a price list. For each material, the quote shows the print time, the resin the parts use on their own and with supports, and the total price split into resin, machine time, and a setup fee.
View on GitHub Download instant-quote.zip
The price comes from the oriented and supported build and its print time, so it still holds when the job prints. Passing in --json, the script returns the quotes in a machine readable JSON format.
The script uses the PreForm API to orient, support, and lay out the parts on the build platform, calculate the resin the build uses with its supports and raft, and slice the build for its print time. pricing.toml stands in for a price list, with a machine hour rate, a setup fee, and a price per mL for each material.


This example contains three files: instant_quote.py prepares and prices the build through the PreForm API, pricing.toml stands in for the shop's price list, and bracket.stl is a sample part.
Read the price list#
pricing.toml starts with the shop's machine type, machine hour rate, and setup fee:
[shop]
currency = "USD"
machine_type = "FORM-4-0" # Form 4; "FRML-4-0" for Form 4L
machine_hour_rate = 6.00 # charged per hour of print time
setup_fee = 5.00 # charged once per jobEach material follows as its own table, with its scene settings and a price per mL:
[materials.tough-2000]
label = "Tough 2000 V2"
material_code = "FLTO2002"
layer_thickness_mm = 0.1
# The catalog's default settings for this material on Form 4. SUPPORTSV2 gives each
# part a larger raft, so fewer copies fit on the build platform.
print_setting = "SUPPORTSV1"
price_per_ml = 0.22The material code, layer thickness, and print setting of each entry must be a combination that /v1/print-setting/list/ returns for the machine type. Tough 2000 V2 uses SUPPORTSV1, which that list labels as the default settings; with SUPPORTSV2, each bracket gets a larger raft and 10 copies no longer fit on a Form 4 build platform.
main reads the price list from PRICING_FILE, next to the script, unless --pricing names another:
PRICING_FILE = Path(__file__).parent / "pricing.toml"
PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388")It then parses the arguments, splits the price list into the shop and its materials, and checks the names passed with --materials:
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 to quote"
)
parser.add_argument(
"--copies", type=int, default=1, help="copies of each part (default: 1)"
)
parser.add_argument(
"--materials", help="comma-separated names from the price list (default: all)"
)
parser.add_argument(
"--pricing", type=Path, default=PRICING_FILE,
help="price list (default: pricing.toml)",
)
parser.add_argument("--json", action="store_true", help="print the quotes as JSON")
args = parser.parse_args()
pricing = tomllib.loads(args.pricing.read_text())
shop, materials = pricing["shop"], pricing["materials"]
chosen = args.materials.split(",") if args.materials else list(materials)
unknown = [name for name in chosen if name not in materials]
if unknown:
sys.exit(
f"Unknown materials {unknown}; the price list has {', '.join(materials)}"
)Connect to PreForm#
Make sure PreForm is ready to accept connections.
main connects to PreForm, exits with a hint if nothing answers, and quotes each chosen material:
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"
)
quotes = [
quote(preform, shop, materials[name], args.parts, args.copies)
for name in chosen
]PREFORM_URL defaults to http://localhost:44388. Set FL_LOCAL_BASE_URL to reach PreForm on another port or host.
Create a scene for each material#
quote creates one scene for each material on the list, with that material's scene settings:
def quote(
preform: PreFormApi, shop: dict, material: dict, parts: list[Path], copies: int
) -> dict:
"""Prepare the parts as one build in this material and price it."""
scene = preform.create_scene(
machine_type=shop["machine_type"],
material_code=material["material_code"],
layer_thickness_mm=material["layer_thickness_mm"],
print_setting=material.get("print_setting", "DEFAULT"),
)A scene holds one material and layer thickness, so each material is quoted on its own build. print_setting falls back to DEFAULT when the price list names none.
Import and copy the parts#
quote imports every part, then duplicates the models to the requested number of copies:
for part in parts:
# The server resolves the path from its own working directory.
preform.import_models(
scene_id=scene.scene_id, file=str(part.resolve()), name=part.stem
)
if copies > 1:
preform.duplicate_models(scene_id=scene.scene_id, count=copies - 1)duplicate_models makes count copies of every model in the scene, so passing --copies 10 to the script gives 10 of each part.
Prepare the build as it prints#
The scripts run orientation, supports, and layout to produce a build.
# Supports depend on the orientation, and the layout on both.
preform.auto_orient_models(scene_id=scene.scene_id)
preform.auto_support_models(scene_id=scene.scene_id)
try:
preform.auto_layout_models(scene_id=scene.scene_id)
except PreFormApiError as exc:
# By default a layout that can't place every model fails and leaves the
# scene unchanged, so the material is reported unpriced.
return {
"material": material["label"],
"error": f"does not fit on one build platform ({exc.code})",
}When the copies don't fit, the layout fails with OPERATION_FAILED and the table reports the material unpriced.
Get an estimate of the print time#
calculate_print_time slices the build and estimates the print time based on the generated slices. get_scene returns the scene's material usage and layer count:
print_time_s = preform.calculate_print_time(scene_id=scene.scene_id).total_print_time_s
state = preform.get_scene(scene_id=scene.scene_id)Delete the scene#
The finally block deletes the scene, including after a failed layout:
finally:
preform.delete_scene(scene_id=scene.scene_id)Price the build#
The price is the resin times the price per mL, plus the print hours times the machine hour rate, plus the setup fee:
# volume_ml includes the supports and the raft; unsupported_volume_ml is the
# parts alone.
resin_ml = state.material_usage.volume_ml
cost = {
"resin": round(resin_ml * material["price_per_ml"], 2),
"machine": round(print_time_s / 3600 * shop["machine_hour_rate"], 2),
"setup": shop["setup_fee"],
}
# The total is the sum of the rounded lines, so the quote adds up as printed.
cost["total"] = round(sum(cost.values()), 2)volume_ml is the same figure as MaterialUsage_mL in the scene's job metadata. For 10 brackets in Grey V5, the supports and raft add 37.7 mL to the 89.2 mL of the parts, so pricing the parts alone would undercharge the resin by about 30%.
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 instant_quote.py bracket.stl --copies 10uv run instant_quote.py bracket.stl --copies 10The script reads the parts named on the command line and pricing.toml from its own folder, and writes nothing. To quote only some materials, pass their names from the price list, such as --materials grey,tough-2000; to use another price list, pass --pricing. The script prints one line for each material:
1 part(s) x 10 on FORM-4-0: bracket.stl
material layers print time parts mL resin mL resin machine setup total USD
Grey V5 420 1h 15m 89.2 126.9 19.04 7.56 5.00 31.60
Clear V5 420 1h 09m 89.2 129.5 19.42 6.91 5.00 31.33
Tough 2000 V2 420 1h 43m 89.0 141.3 31.09 10.33 5.00 46.42
Rigid 10K V1.1 420 1h 33m 89.3 126.8 44.40 9.32 5.00 58.72
Precision Model V1 840 2h 23m 89.1 142.1 19.89 14.30 5.00 39.19The layout uses a random seed, so the print time and the machine line can differ by a few seconds and cents between runs.