# Formlabs Developer Portal > Python SDK, PreForm API, and Web API documentation for building on Formlabs printers - getting started, runnable examples, and reference links. # Get started The Formlabs Python SDK runs the steps you take by hand in PreForm from a Python script: Job Setup, orienting, supports, layout, and sending the job to a printer or a Printer Group. A script prepares every incoming order with the same settings, repeats a job without clicking through it again, or prices a part for a quote. The script connects to the PreForm window you have open, and each step shows up in it. PreForm Server runs the same script with no window. The SDK also gets the tokens for the Formlabs Web API behind Dashboard. [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md) # Install the Developer Kit The Formlabs Python SDK, `formlabs-sdk`, drives PreForm from your own scripts. A script connects to PreForm, then imports, orients, supports, and lays out models through the PreForm API while the PreForm window shows each step. ## Requirements The SDK runs on the following: | Component | Version | | --- | --- | | Python | 3.11 or later | | PreForm or PreForm Server | 3.64 or later | | Operating system | macOS or Windows | ## Install the SDK Install the SDK using `pip`: **macOS:** ```bash pip install "formlabs-sdk @ git+https://github.com/Formlabs/formlabs-api-python" ``` **Windows:** ```powershell pip install "formlabs-sdk @ git+https://github.com/Formlabs/formlabs-api-python" ``` ## Install PreForm > **PreForm Insider Program:** Scripting PreForm from the SDK is available only in the PreForm Insider build. To get > it, join the [PreForm Insider Program](https://formlabs.com/software/preform-insider-program/). > The Insider build installs next to your current PreForm and leaves it in place. Open the Insider build, click **File > Start PreForm Server**, and click **Start** with your desired port in the textbox (default 44388). PreForm then accepts API requests on your specified port, and every script on these pages attaches to it with the `connect()` method. ## Connect to a running PreForm With PreForm's API server started, attach to it and print who is logged in: ```python from formlabs_sdk import FormlabsClient with FormlabsClient() as client: # connect() attaches to the running PreForm and leaves its login alone. Closing # the client leaves PreForm running. preform = client.preform.connect() user = preform.current_user() print(f"PreForm {preform.version} at {preform.base_url}") # current_user() returns None when PreForm is logged out. print(f"Logged in as {user.username}" if user else "Not logged in") ``` When the `connect()` method finds nothing on the port, it fails with `SERVER_UNREACHABLE`. Check that PreForm is open and that **Stop Server** is visible in the **File** menu, indicating that PreForm Server is running. To use another port, pass the `port` argument. ## Prepare a model from Python The following script prepares one STL file for a Form 4 in Grey V5 in the open PreForm window: ```python """Connect to PreForm from Python and prepare a model in its window. Before you run it, open PreForm, click File > Start PreForm Server, and start the server on port 44388. """ import sys from pathlib import Path from formlabs_sdk import FormlabsClient # PreForm resolves a relative path against its own working directory. PART = str(Path(sys.argv[1]).resolve()) with FormlabsClient() as client: # connect() attaches to the PreForm window on port 44388. Leaving this with block # leaves PreForm open, with the prepared scene in it. with client.preform.connect() as preform: print(f"PreForm {preform.version} at {preform.base_url}") # FORM-4-0 is the Form 4, and FLGPGR05 is Grey V5. scene = preform.create_scene( machine_type="FORM-4-0", material_code="FLGPGR05", layer_thickness_mm=0.1, ) preform.import_models(scene_id=scene.scene_id, file=PART) preform.auto_orient_models(scene_id=scene.scene_id) preform.auto_support_models(scene_id=scene.scene_id) preform.auto_layout_models(scene_id=scene.scene_id) # Each call shows up in the PreForm window as it runs. print("Prepared. Look at the scene in the PreForm window.") ``` Run it with the path to an STL file, for example `python start_preform.py part.stl`. The `create_scene` method makes the choices of the **Job Setup** screen: printer, material, and layer thickness. The next three calls correspond to **Auto-Orient All**, **Auto-Generate All**, and **Layout All** in PreForm's orientation, supports, and layout tools. ![The PreForm window with one model on a Form 4 build platform in Grey V5, oriented, supported, and placed by the script.](https://developer.formlabs.com/assets/images/screenshots/quickstart-prepared-model.png) *PreForm after the script runs.* For the codes of other printers and materials, the `list_print_settings` method lists every combination the running PreForm supports. [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) walks through each of these calls and sends the job to a printer. To run the same scripts with no window, for example on a MES server, see [Headless PreForm Server](https://developer.formlabs.com/guides/headless-preform-server.md). ## Connect to Dashboard Dashboard is the web interface for Fleet Control and print history. It shows the printers connected to your account, their Printer Groups and group queues, and every print job they ran. The Formlabs Web API reads and manages the same data, and it authenticates with developer credentials. 1. Log in to [Dashboard](https://dashboard.formlabs.com) with your Formlabs account, or create an account there. 2. Open the [**Developer**](https://dashboard.formlabs.com/#developer) tab and create an application. Dashboard shows its **Client ID** and **Client Secret**. 3. Store both values as environment variables: **macOS:** ```bash export FORMLABS_CLIENT_ID=... export FORMLABS_CLIENT_SECRET=... ``` **Windows:** ```powershell $env:FORMLABS_CLIENT_ID = "..." $env:FORMLABS_CLIENT_SECRET = "..." ``` **Caution:** The client secret grants the same access as your application. Keep it out of source control and out of public facing code. To check the credentials, request a developer token: ```python import os from formlabs_sdk import FormlabsClient, FormlabsCredentials credentials = FormlabsCredentials( client_id=os.environ["FORMLABS_CLIENT_ID"], client_secret=os.environ["FORMLABS_CLIENT_SECRET"], ) with FormlabsClient(credentials=credentials) as client: # A rejected client ID or secret raises InvalidCredentialsError. client.auth.developer_token() print("Developer credentials accepted") ``` A rejected client ID or secret raises `InvalidCredentialsError`. The SDK has no client for the Formlabs Web API endpoints. For the first request, see [Dashboard client](https://developer.formlabs.com/core-concepts/dashboard-client.md). ## What's next - [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) - [Headless PreForm Server](https://developer.formlabs.com/guides/headless-preform-server.md) - [Dashboard client](https://developer.formlabs.com/core-concepts/dashboard-client.md) # Create your first script A job starts as an STL file and ends as a print job in a printer's queue. The `quickstart.py` script takes it through PreForm: job setup, import, orient, support, layout, and upload. You need: - **The SDK and the PreForm Insider build:** For more information, see [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md). - **An STL file:** The script takes its path as the first argument. - **To print, a printer:** Either a printer on your local network or a [printer](https://developer.formlabs.com/core-concepts/device.md) or [printer group](https://developer.formlabs.com/core-concepts/group.md) that's registered to your Formlabs account. To print with printers not on your local network, the application needs PreForm to be [logged in](https://developer.formlabs.com/core-concepts/preform-client.md#log-preform-in) to your account with **Log In** in the **Account** menu. Without a printer, the script saves a `.form` file. Download the complete script: [`quickstart.py`](https://developer.formlabs.com/getting-started/src/quickstart.py). ## Set up the script The script reads the model path from the command line and the printer from the environment: ```python import os import sys from pathlib import Path from formlabs_sdk import FormlabsClient, PreFormApiError # PreForm resolves a relative path against its own working directory. PART = str(Path(sys.argv[1]).resolve()) # Without a printer, the script saves a .form file. PRINTER = os.environ.get("FORMLABS_PRINTER") ``` ## Connect to PreForm Open PreForm, click **File > Start PreForm Server**, and click start on the dialog that follows. PreForm then accepts API requests on the specified port, by default this is port 44388. The `connect()` method creates a [PreForm client](https://developer.formlabs.com/core-concepts/preform-client.md) attached to that PreForm window: ```python with FormlabsClient() as client: # connect() attaches to the PreForm window on port 44388, and each call shows # up in it. Leaving this with block leaves PreForm open. with client.preform.connect() as preform: ``` The remaining steps run inside the `with` block, and each one shows up in the PreForm window as it runs. When the script ends, PreForm stays open with the prepared build. For more information, see [PreForm client](https://developer.formlabs.com/core-concepts/preform-client.md). ## Create a scene A scene holds the choices you make in **Job Setup**: the printer type, material, and layer thickness for every model in it: ```python # FORM-4-0 is the Form 4, and FLGPGR05 is Grey V5. scene = preform.create_scene( machine_type="FORM-4-0", material_code="FLGPGR05", layer_thickness_mm=0.1, ) ``` For more information, see [Scene](https://developer.formlabs.com/core-concepts/scene.md) and [Print settings](https://developer.formlabs.com/core-concepts/print-settings.md). For other printers and resins, see the [List print settings](https://developer.formlabs.com/api-reference/print-settings/list-print-settings.md) endpoint. To learn more about choosing a material and layer thickness for a part, read our support articles: [Print Settings guide](https://formlabs.com/support/Print-Settings-guide/), [Selecting a material in PreForm](https://formlabs.com/support/Selecting-a-material-in-PreForm/), and [Adjusting layer thickness in PreForm](https://formlabs.com/support/Adjusting-layer-thickness/). To choose a material and layer thickness for a part, see the [Print Settings guide](https://formlabs.com/support/Print-Settings-guide/), [Selecting a material in PreForm](https://formlabs.com/support/Selecting-a-material-in-PreForm/), and [Adjusting layer thickness in PreForm](https://formlabs.com/support/Adjusting-layer-thickness/). ## Import the model The `import_models` method loads the STL file into the scene, as opening the file in PreForm does: ```python # One file can produce several models, so the result holds a list of their ids. imported = preform.import_models(scene_id=scene.scene_id, file=PART) first = imported.new_models[0] print(f"Imported {len(imported.new_models)} model(s): {first.model_id}") ``` The result lists each new model's `model_id`, which later calls take to act on that model alone. For more information, see [Model](https://developer.formlabs.com/core-concepts/model.md) and the support article about [Opening a model in PreForm](https://formlabs.com/support/Opening-a-model-in-PreForm/). ## Prepare the model Three calls orient, support, and lay out every model in the scene, as **Auto-Orient All** in the **Orient** tool, **Auto-Generate All** in the **Support** tool, and **Layout All** in the **Layout** tool do in PreForm: ```python # Without model_ids, each call applies to every model in the scene. preform.auto_orient_models(scene_id=scene.scene_id) preform.auto_support_models(scene_id=scene.scene_id) preform.auto_layout_models(scene_id=scene.scene_id) ``` These operations run asynchronously in PreForm, and the SDK polls each one until it finishes. The PreForm window shows the result: ![PreForm with one model oriented, supported, and laid out on a Form 4 build platform, set up for Grey V5 at 0.1 mm.](https://developer.formlabs.com/assets/images/screenshots/quickstart-prepared-model.png) *The model after the script orients, supports, and lays it out.* For more information about long running operations, see [Operation](https://developer.formlabs.com/core-concepts/operation.md). To learn more about what each step does to the print, read the following support articles: [Model orientation best practices for SLA printing](https://formlabs.com/support/Model-Orientation/), [How supports work in SLA printing](https://formlabs.com/support/How-supports-work-in-SLA-printing/), and [Automatic layout settings for SLA printing](https://formlabs.com/support/Automatic-layout-settings-for-SLA-printing/). ## Check the model The `validate_manifoldness` method reports whether each mesh is a closed solid, and the `calculate_print_time` method returns the print time in seconds: ```python # A mesh that isn't a closed solid reports is_manifold as False. report = preform.validate_manifoldness(scene_id=scene.scene_id) for model_id, result in report.per_model_results.additional_properties.items(): if not result.is_manifold: print(f"Model {model_id} is not manifold: {result.status}") # The faster estimate_print_time raises ESTIMATION_NOT_SUPPORTED on a Form 4. times = preform.calculate_print_time(scene_id=scene.scene_id) print(f"Print time: {times.total_print_time_s / 3600:.1f} h") ``` For the other checks PreForm runs on a scene, see [Validate](https://developer.formlabs.com/core-concepts/validate.md). ## Send the job to a printer With a printer set, the `print_scene` method uploads the job to it. Without one, the `save_form_file` method writes a `.form` file to the current directory: ```python if PRINTER: try: # printer takes a serial name, an IP address, or a Fleet Control # queue ID. print_now=False puts the print job in the queue without # starting it. job = preform.print_scene( scene_id=scene.scene_id, printer=PRINTER, job_name="quickstart", print_now=False, on_progress=lambda p: print(f"Uploading {p:.0%}"), ) print(f"Queued job {job.job_id} on {PRINTER}") except PreFormApiError as exc: print(f"Could not send to {PRINTER}: {exc.code}: {exc.message}") else: # A .form file holds the scene, and it opens in PreForm. preform.save_form_file( file=str(Path("quickstart.form").resolve()), scene_ids=[scene.scene_id], ) print("Saved quickstart.form") ``` `print_now=False` does what **Upload to Queue** does in PreForm, and `print_now=True` does what **Print Now** does. For more information, see [Job](https://developer.formlabs.com/core-concepts/job.md), [Queue](https://developer.formlabs.com/core-concepts/queue.md), and [Sending a print job from PreForm to a printer](https://formlabs.com/support/Sending-a-print-job-from-PreForm-to-a-printer/). To send the job to a Printer Group, set `FORMLABS_PRINTER` to the group's queue ID. For more information, see [Group](https://developer.formlabs.com/core-concepts/group.md). To set up a group, see [Using Group Queues in Dashboard](https://formlabs.com/support/Using-Group-Queues-in-Dashboard/). ## Run the script To save a `.form` file, pass the STL file: **macOS:** ```bash python3 quickstart.py part.stl ``` **Windows:** ```powershell py -3 quickstart.py part.stl ``` The script prints the model name, the print time, and the file it saved: ``` Imported part Print time: 0.5 h Saved quickstart.form ``` To queue the job on a printer, set the printer's [serial name](https://developer.formlabs.com/core-concepts/device.md) first: **macOS:** ```bash export FORMLABS_PRINTER=Form4-ExampleDevice python3 quickstart.py part.stl ``` **Windows:** ```powershell $env:FORMLABS_PRINTER = "Form4-ExampleDevice" py -3 quickstart.py part.stl ``` Start the print job on the printer after you load the build platform. To learn more, read our support article on [Starting a print job on the printer touchscreen](https://formlabs.com/support/Starting-a-print-job-on-the-printer-touchscreen/). To run the same script with no window, start PreForm Server instead of PreForm. The `connect()` method attaches to either. For more information, see [Headless PreForm Server](https://developer.formlabs.com/guides/headless-preform-server.md). ## Handle errors Every failed call raises `PreFormApiError`. When no PreForm listens on port 44388, the `connect()` method raises it with the code `SERVER_UNREACHABLE`. In that case, click **File > Start PreForm Server** in PreForm, click **Start**, and run the script again. For the error codes that the PreForm API returns, see [Errors](https://developer.formlabs.com/api-reference/schemas/errorcode.md). # PreForm client The *PreForm client*, `client.preform`, is the SDK's handle to the PreForm API client that talks to your instance of PreForm. Most methods on the client do a step you already do in the PreForm window, such as the **Job Setup** choices, importing a file, or **Upload to Queue**. A script that calls them in order runs the same steps for every incoming order, without an operator repeating the clicks. ## Connect or launch The `connect()` method attaches to a PreForm that's already running, and the `launch()` method starts one: | | `connect()` | `launch()` | | --- | --- | --- | | **Process** | Attaches to PreForm or PreForm Server on the port | Starts PreForm or PreForm Server from the executable you pass | | **Login** | Leaves the login alone unless you pass `login=True` | Logs PreForm in with the client's account, when the client has one | | **On close** | Leaves PreForm running | Closes PreForm | | **Failure codes** | `SERVER_UNREACHABLE` | `STARTUP_FAILED`, `PORT_IN_USE` | Both methods default to port 44388 and take `port=` for another one. ## Connect to a running PreForm The `connect()` method attaches to a PreForm that's already open, or to a PreForm Server another process started: ```python # connect() attaches to PreForm on port 44388 and leaves its login alone. preform = client.preform.connect() print(preform.version, preform.owns_process) ``` `owns_process` is `False` for a connected process, so closing the client leaves it running. To reach PreForm Server on another machine, pass `base_url=`, for example `http://printhost:44388`. The PreForm you connect to may already be logged in: `current_user()` returns the account, or `None`. For more information, see [Log PreForm in](#log-preform-in). ## Launch PreForm A `with` block scopes the PreForm process to the code that uses it: ```python # launch() waits up to 60 seconds for PreForm to print READY FOR INPUT. with client.preform.launch(PREFORM) as preform: print(preform.version, preform.base_url, preform.pid) ``` The handle reports the `version` and `base_url` of PreForm, and `pid` holds the process ID of a PreForm that `launch()` started. When another PreForm or PreForm Server already listens on the port, `launch()` fails with `PORT_IN_USE`. ## Log PreForm in PreForm works without a login: it prepares and saves jobs, and prints to printers on your local network. Logged in to your Formlabs account, it also reaches the printers registered to the account and its Printer Groups, as **Log In** in PreForm's **Account** menu does: ![The PreForm Account menu while logged out, with the line Log in for remote printers and groups and the Log In button.](https://developer.formlabs.com/assets/images/screenshots/authentication-account-menu.png) *The Account menu in PreForm.* Pass the account to the client, and `connect(login=True)` logs PreForm in: ```python import os from formlabs_sdk import FormlabsClient # The client holds the account, and connect(login=True) logs PreForm in to it. with FormlabsClient( username=os.environ["FORMLABS_USERNAME"], password=os.environ["FORMLABS_PASSWORD"], ) as client: # A rejected login raises PreFormApiError with the code LOGIN_FAILED, and # PreForm keeps running, logged out. with client.preform.connect(login=True) as preform: user = preform.current_user() print(f"Logged in as {user.username}") ``` The login lasts until PreForm exits or `logout()` ends it, and a new login replaces the one before. Without `login=True`, `connect()` leaves the login as it is. To log in later, call `preform.login()`. A rejected login raises `PreFormApiError` with the code `LOGIN_FAILED`, and PreForm keeps running, logged out. **Caution:** Before you replace the login of a PreForm someone else uses, check `current_user()`. ## Call the PreForm API Each endpoint has a method named after its operation. The following table maps common PreForm actions to the methods that do the same work: | In PreForm | Method | | --- | --- | | Choose printer, material, and layer thickness on the **Job Setup** screen | `create_scene`, `update_scene` | | Open a `.form` file | `open_form_file` | | Import an STL or CAD file | `import_models` | | Orient, support, and lay out models automatically | `auto_orient_models`, `auto_support_models`, `auto_layout_models` | | Save the build as a `.form` file | `save_form_file` | | Send the job to a printer with **Print Now** or **Upload to Queue** | `print_scene` | A long-running endpoint starts an operation, and the method returns when the operation finishes. For more information, see [Operation](https://developer.formlabs.com/core-concepts/operation.md). ## Handle errors A failed call raises `PreFormApiError`, which carries the error `code`, its `message`, and the HTTP `status_code`: ```python try: preform.create_scene( machine_type="FORM-4-0", material_code="NOT-A-MATERIAL", layer_thickness_mm=0.1, ) except PreFormApiError as exc: # code is the field to match on. print(exc.code, exc.status_code, exc.message) ``` A scene setting that Formlabs doesn't ship fails with `INPUT_ERROR` and status `400`. For more information about error codes, see [Errors](https://developer.formlabs.com/api-reference/schemas/errorcode.md). ## Blocking and async clients `FormlabsClient` gives a blocking handle, and `AsyncFormlabsClient` gives an async one with the same methods. In async code, await `connect()` and each method: ```python async def main() -> None: # A logged-out PreForm can still prepare a scene and save it, so the account # is optional here. async with AsyncFormlabsClient( username=os.environ.get("FORMLABS_USERNAME"), password=os.environ.get("FORMLABS_PASSWORD"), ) as client: # Awaiting connect() returns once PreForm answers on port 44388. Leaving this # with block leaves PreForm running. async with await client.preform.connect() as preform: ``` The blocking client runs the async one on a private event-loop thread. The samples on the Core concepts pages use the blocking client. ## What's next - [Scene](https://developer.formlabs.com/core-concepts/scene.md) - [API reference](https://developer.formlabs.com/api-reference.md) # Scene A *scene* is the server-side state of one build: a printer type, a material, a layer thickness, a print setting, and the models placed in the printer's build volume. It's the same thing you see in the PreForm window. Every PreForm API call that changes a model names the scene it belongs to by its `scene_id`. One PreForm process holds several scenes, so a script can prepare several builds in one session. For example, a service bureau that receives orders in two materials creates one scene per material and imports each order into the matching scene. ![The PreForm window with two models on the build platform, one of them selected.](https://developer.formlabs.com/assets/images/support/preform_scene.png) *A scene holds everything that prints in one build on one printer.* ## What a scene holds A scene ties the models to the settings they print with: ```mermaid flowchart LR subgraph scene["Scene (scene_id)"] settings["Scene settings: machine type, material, layer thickness, print setting"] m1["Model"] m2["Model"] m3["Model"] end settings -. applies to .-> m1 & m2 & m3 scene -- "slice" --> job["Job"] scene -- "save" --> form[".form file"] ``` - **Scene settings:** The machine type, such as `FORM-4-0`, the material code, such as `FLGPGR05`, the layer thickness, and the print setting. Together they select one print setting that Formlabs ships. - **Models:** Each imported mesh, with its position, orientation, and supports. For more information, see [Model](https://developer.formlabs.com/core-concepts/model.md). - **Derived values:** The build volume, which comes from the machine type, and the material usage and layer count, which PreForm recalculates as the models change. For an example that prices a build from its material usage, see [Instant quote](https://developer.formlabs.com/examples/instant-quote.md). In PreForm, the **Summary** section of the side panel shows the derived values: ![The Summary section of the PreForm side panel for a Form 4 build in Grey V5, with the print time, the layer count, and the resin volume.](https://developer.formlabs.com/assets/images/screenshots/scene-summary-panel.png) *The layer count and resin volume in **Summary** are the scene's `layer_count` and `material_usage`.* In PreForm, the scene settings are the choices on the **Job Setup** screen: ![The PreForm Job Setup screen with four steps: choose printer, choose material, choose layer thickness, and choose print settings.](https://developer.formlabs.com/assets/images/support/job-setup-preform-jan-2023.png) *The Job Setup screen in PreForm sets the same values as `create_scene`.* For more information, see [Introduction to the PreForm Print Settings Editor](https://formlabs.com/support/Introduction-to-the-Print-Settings-Editor/). ## Create a scene The `create_scene` method takes the scene settings and returns the new scene: **SDK:** ```python # PreForm rejects a combination that Formlabs doesn't ship. scene = preform.create_scene( machine_type="FORM-4-0", material_code="FLGPGR05", layer_thickness_mm=0.1, ) # create_scene answers with the new scene's id and settings; get_scene has the rest. build_volume = preform.get_scene(scene_id=scene.scene_id).build_volume print(scene.scene_id, build_volume.dimensions_mm) ``` **curl:** **macOS:** ```bash curl -X POST http://localhost:44388/v1/scene/create/ \ -H "Content-Type: application/json" \ -d '{"machine_type": "FORM-4-0", "material_code": "FLGPGR05", "layer_thickness_mm": 0.1}' ``` **Windows:** ```powershell @{ machine_type = "FORM-4-0"; material_code = "FLGPGR05"; layer_thickness_mm = 0.1 } | ConvertTo-Json | curl.exe -X POST http://localhost:44388/v1/scene/create/ ` -H "Content-Type: application/json" ` -d "@-" ``` The combination must be one that Formlabs ships, or PreForm rejects it. To find a valid combination, see the [Print Settings](https://developer.formlabs.com/api-reference/print-settings.md) endpoints. ## How long a scene lives A scene lives in the memory of the PreForm process until you delete it: ```python # Closing PreForm discards every scene. PreForm Server also keeps at most 30. for listed in preform.list_scenes().scenes: print(listed.id, len(listed.models)) preform.delete_scene(scene_id=scene.scene_id) ``` A freshly started PreForm holds one default scene. Deleting the default scene resets it to an empty scene with the same settings, and deleting any other scene removes it. PreForm Server keeps at most 30 scenes and deletes the least recently used one when a new scene exceeds the limit. PreForm has its own limit on how many scenes exist at once: when it's reached, `create_scene` fails with `BUILD_VOLUME_LIMIT_EXCEEDED`, and you must delete a scene before you create another. Closing PreForm discards every scene, so save a scene you need later as a `.form` file. For more information, see the [Scenes](https://developer.formlabs.com/api-reference/scenes.md) endpoints. ## What's next - [Print settings](https://developer.formlabs.com/core-concepts/print-settings.md) - [Automatic layout settings for SLA printing](https://formlabs.com/support/Automatic-layout-settings-for-SLA-printing/) # Print settings A *print setting* holds the parameters a printer prints one material with, at one layer thickness. Four values select one of the print settings Formlabs ships: | Field | Meaning | Example | | --- | --- | --- | | `machine_type` | The printer type | `FORM-4-0`, the Form 4 and Form 4B | | `material_code` | The material | `FLGPGR05`, Grey V5 | | `layer_thickness_mm` | The layer thickness, in millimeters | `0.1` | | `print_setting` | The setting variant for that material and thickness | `DEFAULT` | A [scene](https://developer.formlabs.com/core-concepts/scene.md) carries one print setting for all its models, chosen on PreForm's **Job Setup** screen, or with `create_scene`. A combination that isn't available in your PreForm instance is rejected. To select a print setting that is not shipped with PreForm you can [create or import a custom print setting in PreForm](#create-a-custom-setting-in-preform). ## List the print settings The `list_print_settings` method returns every combination the running PreForm ships, grouped by printer type and material: ```python # Grouped by printer type, then material, then each material's variants. catalog = preform.list_print_settings().to_dict() for printer in catalog["printer_types"]: for material in printer["materials"]: for setting in material["material_settings"]: # scene_settings is what create_scene takes. print(printer["label"], material["label"], setting["label"], setting["scene_settings"]) ``` Each setting has a display `label`, such as `0.025 mm (Default settings)`, and its `scene_settings` are the four fields `create_scene` takes. To list only what a connected printer accepts, pass `compatible_with=` with the printer's serial name. ## List and check layer thicknesses The `validate_print_setting` method answers which layer thicknesses a printer, material, and print setting take, and whether one thickness works, without creating a scene: ```python # Without layer_thickness_mm, options lists every thickness the other three take. thicknesses = preform.validate_print_setting( machine_type="FORM-4-0", material_code="FLGPGR05", print_setting="DEFAULT" ) if thicknesses.options: print("Grey V5 on Form 4:", thicknesses.options.layer_thicknesses_mm) # With all four, valid says whether that thickness prints. check = preform.validate_print_setting( machine_type="FORM-4-0", material_code="FLGPGR05", print_setting="DEFAULT", layer_thickness_mm=0.05, ) print("0.05 mm:", check.valid) ``` Leave out `layer_thickness_mm`, and `options.layer_thicknesses_mm` lists every thickness Formlabs ships for the other three fields. Give all four, and `valid` says whether that thickness is one of them. For a thickness with no print settings the result of the call is `false`. ## Create a custom setting in PreForm A custom print setting starts as a copy of a Formlabs setting, which you change in PreForm's Print Settings Editor: 1. In PreForm, click **Edit > Print Settings Editor**. 2. Click **New Print Setting**, choose the Formlabs setting to start from, and click **Copy & Edit Selected**. 3. Change the parameters. To rename the setting, click the pencil icon next to its name. 4. To use the setting on another computer, click the **Export Print Setting** icon next to it. PreForm saves it as an `.fps` file. In turn **Import FPS File** brings an `.fps` file into your PreForm's print settings list. For more details, see [Using the PreForm Print Settings Editor](https://formlabs.com/support/Using-the-Print-Settings-Editor/). ## Use a custom setting The `create_scene` and `update_scene` methods take an `.fps` file as `fps_file`, in place of the four print setting selection fields: ```python # FPS is a custom setting exported from PreForm's Print Settings Editor. scene = preform.create_scene(fps_file=FPS) print(preform.get_scene(scene_id=scene.scene_id).scene_settings) ``` ## Export the print setting embedded into a .form file A `.form` file someone prepared in PreForm prints with the setting they chose, custom or not. When you build jobs from a script to match such a file, open it and save its setting with the `save_print_setting` method: ```python # Open a job someone prepared in PreForm, and save the setting it prints with. job = preform.open_form_file(file=FORM).scenes[0] print(preform.get_scene_metadata(scene_id=job.scene_id)["PrintSettingsName"]) saved = str(Path(f"{Path(FORM).stem}.fps").resolve()) preform.save_print_setting(scene_id=job.scene_id, file=saved) # Scenes the script builds take the same setting from the file. batch = preform.create_scene(fps_file=saved) ``` The saved `.fps` file holds the scene's setting with any changes made in that scene. To move a folder of saved jobs to another setting, see the [Print setting rollout](https://developer.formlabs.com/examples/print-setting-rollout.md) example script. For each field and error code, see the [Print Settings](https://developer.formlabs.com/api-reference/print-settings.md) endpoints. ## What's next - [Model](https://developer.formlabs.com/core-concepts/model.md) - [Print setting rollout](https://developer.formlabs.com/examples/print-setting-rollout.md) - [Using the PreForm Print Settings Editor](https://formlabs.com/support/Using-the-Print-Settings-Editor/) # Model A *model* is a mesh loaded into a scene. An STL file, an OBJ file, or a CAD file becomes a model when you import it. The model carries everything the print needs from that part: its geometry, its position and orientation on the build platform, and its supports. Each model is one entry in the **Model List** in PreForm. When a customer order arrives as several STL files, each file becomes its own model, and the model's name tells you which order it belongs to. ![The Model List in the PreForm side panel with three models named by order number, one of them selected on the build platform.](https://developer.formlabs.com/assets/images/screenshots/model-model-list-order-names.png) *Each row in the **Model List** is one model, and the row shows the model's `name`.* ## What a model holds Each model in a scene has its own ID and properties: | Property | Meaning | | --- | --- | | `id` | The model's ID within its scene | | `name` | The name the **Model List** shows, derived from the filename unless you set one | | `position`, `orientation`, `scale` | Where the model sits on the build platform, in millimeters and degrees | | `units` | The units the file was interpreted in, such as `MILLIMETERS` | | `bounding_box` | The model's extent in the scene, including its supports | | `has_supports` | Whether the model has supports | | `in_bounds` | Whether the model fits inside the build volume | | `original_file` | The path of the file the model came from | ## Import a model The `import_models` method loads a file into a scene and returns the models it produced: **SDK:** ```python # PreForm reads the file itself, so PART is a full path on the machine that runs # PreForm. imported = preform.import_models(scene_id=scene.scene_id, file=PART) # The import answers with the new models' ids; list_models has the rest. units is # the unit the file was interpreted in, such as MILLIMETERS. model_id = imported.new_models[0].model_id model = preform.list_models(scene_id=scene.scene_id, model_ids=[model_id]).models[0] print(model.id, model.name, model.units) ``` **curl:** **macOS:** ```bash curl -X POST http://localhost:44388/v1/scene/model/import/ \ -H "Content-Type: application/json" \ -d "{\"scene_id\": \"$SCENE_ID\", \"file\": \"$PWD/part.stl\"}" ``` **Windows:** ```powershell @{ scene_id = $env:SCENE_ID; file = "$PWD\part.stl" } | ConvertTo-Json | curl.exe -X POST http://localhost:44388/v1/scene/model/import/ ` -H "Content-Type: application/json" ` -d "@-" ``` PreForm reads the file itself, so pass a full path on the machine that runs PreForm. A CAD or 3MF file with several parts imports as one model unless you pass `split_multi_model_file=True`. For more information, see the [Scenes](https://developer.formlabs.com/api-reference/scenes.md) endpoints. To label each model with an order number, pass `name=`. An STL file carries no units, so PreForm reads it as millimeters. For a file a customer designed in inches, pass `units=ImportUnits.INCHES`, from `formlabs_preform_api.models`. The `import_models` method loads STL, OBJ, and 3MF mesh files, and CAD files in STEP, SolidWorks, PTC Creo, JT, CATIA V5, Inventor, and Rhino formats. For more information, see [Opening a model in PreForm](https://formlabs.com/support/Opening-a-model-in-PreForm/). ## Supports belong to the model On SLA printers, a model prints on supports. Supports consist of three parts: the *raft*, which adheres to the build platform; the *scaffolding*, which rises from the raft; and the *touchpoints*, where the scaffolding meets the model: ![A bust in PreForm on its supports. Label 1 marks the touchpoints, label 2 the scaffolding, and label 3 the raft on the build platform.](https://developer.formlabs.com/assets/images/support/bust-with-numbers-preform-3-0.png) *Touchpoints (1), scaffolding (2), and the raft (3).* The supports are part of the model, so they move with it, and its bounding box includes them: ```python preform.auto_support_models(scene_id=scene.scene_id, model_ids=[model.id]) # The supports belong to the model, so its bounding_box includes them. for listed in preform.list_models(scene_id=scene.scene_id).models: print(listed.name, listed.has_supports, listed.in_bounds, listed.bounding_box) ``` Orientation affects how much support a model needs, so orient a model before you support it. For more information, see [Model orientation best practices for SLA printing](https://formlabs.com/support/Model-Orientation/) and [How supports work in SLA printing](https://formlabs.com/support/How-supports-work-in-SLA-printing/). ## What's next - [Validate](https://developer.formlabs.com/core-concepts/validate.md) # Validate The *printability checks* report, per model, whether a scene will print as it is: a hole in the mesh, a wall too thin, a cup that traps resin during printing, a minima the supports miss, or a model outside the build volume. Each check reads the scene and returns the results for each model on the scene. ![An L bracket on supports with a triangular hole in the top of its horizontal arm, which the manifold check reports.](https://developer.formlabs.com/examples/preflight-holed-bracket.png) ![A block on supports with a 0.3 mm fin standing on its top face, which the thin-wall check reports.](https://developer.formlabs.com/examples/preflight-thin-fin.png) ## How a check runs You call a check on a scene, PreForm analyzes its models, and the answer holds one result per model, keyed by model ID, under `per_model_results`. For example a cup check on a scene with two models will return: ```json { "per_model_results": { "c2a37eb2-8e4b-4b8e-9f37-1f5f2c1a9d10": { "cup_count": 0 }, "9b1dd8a0-4f6e-45c2-9a3e-77a01f2b6c55": { "cup_count": 2 } } } ``` After running a check you can also check the status of the validation on a model by checking the model properties such as `model.cup_count` Similar to PreForm, the results are invalidated after you move, reorient, or support a model, to get the validation results you have to run the check again. ## Mesh checks The manifold and thin-wall checks only look at the mesh, so you can run them right after you import a model: ```python # Each check answers per model: per_model_results maps a model ID to its result. manifold = preform.validate_manifoldness(scene_id=scene.scene_id) for model_id, result in manifold.per_model_results.additional_properties.items(): if not result.is_manifold: print(f"{model_id} has holes: {result.status}") thin = preform.validate_thin_features(scene_id=scene.scene_id, threshold_mm=0.5) for model_id, result in thin.per_model_results.additional_properties.items(): if result.has_thin_walls: print(f"{model_id}: {result.thin_region_count} region(s) under 0.5 mm") ``` The example imports the model with `repair_behavior=RepairBehavior.IGNORE`, so a mesh with holes is imported as it is and the manifold check can find the holes. To fix a model that isn't manifold, call `repair_models`. Hollowing and drilling only work on manifold models. Set `threshold_mm` to the thinnest wall your material can print at your layer thickness. If the thin-wall check can't analyze a model, for example because the mesh has holes, the model's `status` is not `ANALYZED`. ## Orientation and support checks Cups depend on how a model is oriented, and unsupported minima and undersupported areas depend on its supports. Run these checks after you orient and support your models: ```python preform.auto_orient_models(scene_id=scene.scene_id) preform.auto_support_models(scene_id=scene.scene_id) preform.auto_layout_models(scene_id=scene.scene_id) # The cup, minima and supportedness checks always analyze the whole scene. cups = preform.validate_cups(scene_id=scene.scene_id) minima = preform.validate_minima(scene_id=scene.scene_id) supported = preform.validate_supportedness(scene_id=scene.scene_id) for model_id, result in cups.per_model_results.additional_properties.items(): print(model_id, "cups:", result.cup_count) for model_id, result in minima.per_model_results.additional_properties.items(): print(model_id, "unsupported minima:", result.minima_count) for model_id, result in supported.per_model_results.additional_properties.items(): print(model_id, f"{result.percent_unsupported:.1f}% undersupported") ``` These checks always run on every printable model in the scene, and they can take tens of seconds. The SDK runs them as an [operation](https://developer.formlabs.com/core-concepts/operation.md) and waits for the result. The cup and minima checks fail with `OPERATION_FAILED` if a model is outside the build volume, so lay out your models before you run them. To fix a model with cups, reorient it or add drain holes. To fix unsupported minima or undersupported areas, generate the supports again or increase their density. ## Fit and collision checks The fit and collision checks look at the models where they are on the build platform: ```python # Read the scene as it stands: nothing is moved to make a model fit. fits = preform.validate_in_bounds(scene_id=scene.scene_id) collisions = preform.validate_interferences(scene_id=scene.scene_id) print(fits.to_dict()) print("colliding pairs:", collisions.model_ids) ``` The fit check includes the supports. A model that sticks out of the build volume is reported as `in_bounds: false`, even if it would fit in another orientation. Adding supports can also push a model out of bounds. If you move, scale, or reorient models yourself, they can overlap. The collision check returns each pair of overlapping models. ## The checks | Check | Method | Result per model | Run it after | | --- | --- | --- | --- | | Manifold | `validate_manifoldness` | `is_manifold` | Import | | Thin walls | `validate_thin_features` | `has_thin_walls`, `thin_region_count`, `thin_volume_mm3` | Import | | Cups | `validate_cups` | `cup_count` | Orientation | | Unsupported minima | `validate_minima` | `minima_count` | Supports | | Undersupported area | `validate_supportedness` | `percent_unsupported`, 0 to 100 | Supports | | Fit | `validate_in_bounds` | `in_bounds` | Layout | | Collisions | `validate_interferences` | Pairs of overlapping models, in `model_ids` | Layout | To check only some models, pass `model_ids` to the manifold, thin-wall, or fit check. For the fields and error codes of each check, see the [Printability Checks](https://developer.formlabs.com/api-reference/printability-checks.md) endpoints. For a script that checks every part in an order, see [Printability report](https://developer.formlabs.com/examples/printability-report.md). ## What's next - [Job](https://developer.formlabs.com/core-concepts/job.md) - [Printability report](https://developer.formlabs.com/examples/printability-report.md) - [Model orientation best practices for SLA printing](https://formlabs.com/support/Model-Orientation/) # Job A *job*, or *print job*, is what a printer runs. PreForm creates a job from a scene by slicing the scene into layers, the same step that runs when you click **Print Now** or **Upload to Queue** in PreForm. The job carries the layers, the scene's print settings, and a name that operators see on the printer and in Dashboard. ## From scene to job A scene stays in PreForm, and you can keep changing it. A job is a copy of the scene at the moment it's sliced: ```mermaid flowchart LR scene["Scene
models and settings"] -- "slice" --> job["Job
layers, print settings, job name"] job --> pq["Printer queue"] job --> gq["Group queue
(Dashboard)"] ``` Changes to the scene after slicing don't reach a job that already exists. To print the changed scene, create a new job from it. Prepare the models before you create a job: orient them, support them, and place them on the build platform. For more information, see [Model](https://developer.formlabs.com/core-concepts/model.md). ## Send a job to a printer The `print_scene` method slices the scene into a job and uploads the job in one step: ```python # print_now=False puts the print job in the printer's queue without # starting it. job = preform.print_scene( scene_id=scene.scene_id, printer=PRINTER, job_name="bracket", print_now=False, ) print(f"Print job {job.job_id}") ``` The `printer` field takes a printer's serial name or IP address, or the queue ID of a Printer Group. The `print_now` field picks between the two print buttons in PreForm: - **`print_now=False`:** **Upload to Queue**. The job waits in the printer's queue until an operator starts it on the touchscreen. - **`print_now=True`:** **Print Now**. The job starts immediately, and the call fails when the printer isn't ready to print. **Caution:** Omitting `print_now` defers to the printer's `ready_to_print_now` flag, so the job starts immediately on a printer that is ready. Pass `print_now=False` when an operator checks the build platform and resin tank before each print. The `job_name` field is the name an operator sees on the printer and in Dashboard. An order number or a batch ID in the name lets the operator match a finished build to its orders. For more information, see [Queue](https://developer.formlabs.com/core-concepts/queue.md). ## Keep the scene for later To print the scene again later, or to change it first, save it as a `.form` file with `save_form_file`. A `.form` file holds the models and settings, and it opens in PreForm. For more information, see [Scene](https://developer.formlabs.com/core-concepts/scene.md). ## What's next - [Operation](https://developer.formlabs.com/core-concepts/operation.md) - [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) - [Sending a print job from PreForm to a printer](https://formlabs.com/support/Sending-a-print-job-from-PreForm-to-a-printer/) - [Starting a print job on the printer touchscreen](https://formlabs.com/support/Starting-a-print-job-on-the-printer-touchscreen/)
# Operation An *operation* is a long-running PreForm API request that runs in the background in PreForm. Importing, orienting, supporting, laying out, slicing, and printing are the steps you wait on in the PreForm window, and they can take seconds to minutes. PreForm accepts the request, returns an operation ID, and reports progress until the work finishes. ![The Generating Supports progress bar partway through, after Auto-Generate All is clicked in the supports tool.](https://developer.formlabs.com/assets/images/screenshots/operation-generating-supports.png) *In PreForm, a progress bar tracks support generation. Through the API, an operation reports its `progress`.* ## How an operation runs A request with `async=true` starts an operation, and the client polls it until it succeeds or fails: ```mermaid sequenceDiagram participant C as Your code participant S as PreForm C->>S: POST /v1/scene/model/auto-support/?async=true S-->>C: 202 Accepted, operation_id loop Until status is SUCCEEDED or FAILED C->>S: GET /v1/operation/get/?operation_id=... S-->>C: status IN_PROGRESS, progress 0 to 1 end S-->>C: status SUCCEEDED, result ``` An operation's `status` is `IN_PROGRESS` while it runs, then `SUCCEEDED` or `FAILED`. Its `progress` value rises from 0 to 1. After it succeeds, `result` holds what the endpoint returns when you call it without `async=true`. ## Wait for an operation The SDK starts every long-running call as an operation and polls it for you, so a blocking call returns when the work is done: **SDK:** ```python # The blocking call polls the operation until it succeeds or fails. try: preform.auto_support_models( scene_id=scene.scene_id, timeout=300, # Called each time the progress, from 0 to 1, increases. on_progress=lambda p: print(f"Supports {p:.0%}"), ) except TimeoutError: # The PreForm API can't cancel an operation, so it keeps running. print("Still running in PreForm after 300 seconds") ``` **curl:** **macOS:** ```bash curl -X POST "http://localhost:44388/v1/scene/model/auto-support/?async=true" \ -H "Content-Type: application/json" \ -d "{\"scene_id\": \"$SCENE_ID\"}" curl "http://localhost:44388/v1/operation/get/?operation_id=$OPERATION_ID" ``` **Windows:** ```powershell @{ scene_id = $env:SCENE_ID } | ConvertTo-Json | curl.exe -X POST "http://localhost:44388/v1/scene/model/auto-support/?async=true" ` -H "Content-Type: application/json" ` -d "@-" curl.exe "http://localhost:44388/v1/operation/get/?operation_id=$env:OPERATION_ID" ``` The `on_progress` callback receives the progress each time it increases. A call that runs past `timeout` raises `TimeoutError`, and the operation keeps running in PreForm, because the PreForm API has no endpoint that cancels an operation. **Caution:** Don't retry a timed-out call on the same scene while the first operation still runs. The PreForm API reference states that parallel requests that change one scene overwrite each other, and the last write wins silently. ## What an operation doesn't cover An operation lives only as long as the PreForm process, and a restarted PreForm holds no operations. A print job that PreForm has uploaded is no longer an operation: the printer and its queue track it from then on. For more information, see [Queue](https://developer.formlabs.com/core-concepts/queue.md). ## What's next - [Device](https://developer.formlabs.com/core-concepts/device.md) # Dashboard client A *Dashboard client* calls the Formlabs Web API, the API behind Dashboard, at `https://api.formlabs.com/developer/v1/`. It reads the same printers, print history, Printer Groups, and group queues that Dashboard shows, so a script can build a usage report across every printer on the account. The SDK has no methods for the Formlabs Web API endpoints, so the client has two parts. A `FormlabsClient` with developer credentials supplies the token, and an HTTP client sends the requests. To send a print job to a group queue, as **Upload to Queue** does, use the PreForm client. For more information, see [PreForm client](https://developer.formlabs.com/core-concepts/preform-client.md). ## What each part does The two parts split the work: - **`FormlabsClient`:** Holds the client ID and client secret, requests the developer token, caches it, and requests a new one 300 seconds before it expires. Closing the client revokes the token. - **HTTP client:** Sends each request with the token in the `Authorization` header. The examples on this page use `httpx`, which the SDK installs. To create the client ID and client secret in Dashboard, see [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md#connect-to-dashboard). ## Attach the token to each request An `httpx.Auth` class asks the session for the token on every request, so a refreshed token reaches the next request: ```python class DeveloperTokenAuth(httpx.Auth): """Adds the developer token to each request, and retries once after a 401.""" def __init__(self, session): self.session = session def auth_flow(self, request): # The session returns its cached token until 300 seconds before it expires. request.headers["Authorization"] = f"Bearer {self.session.developer_token()}" response = yield request if response.status_code == 401: # The server revoked the token. Drop it so the session mints a new one. self.session.invalidate(TokenKind.DEVELOPER) token = self.session.developer_token() request.headers["Authorization"] = f"Bearer {token}" yield request ``` A `401` response means the server rejected a token the session considers valid, for example after the token was revoked. The `invalidate` method drops the cached token, and the retry carries a new one. ## Create the client The HTTP client takes the Formlabs Web API base URL and the auth class: ```python credentials = FormlabsCredentials( client_id=os.environ["FORMLABS_CLIENT_ID"], client_secret=os.environ["FORMLABS_CLIENT_SECRET"], ) with FormlabsClient(credentials=credentials) as client: # Closing the client revokes the developer token, so every request runs inside # this with block. with httpx.Client( base_url="https://api.formlabs.com/developer/v1/", auth=DeveloperTokenAuth(client.auth), ) as dashboard: ``` Creating `FormlabsClient` makes no network request. A rejected client ID or secret raises `InvalidCredentialsError` on the first request, when the session requests the token. ## Send a request Paths are relative to the base URL: ```python response = dashboard.get("printers/") response.raise_for_status() for printer in response.json(): print(printer["serial"]) ``` The `printers/` endpoint returns every printer on the account. Some list endpoints wrap their items in an envelope with `count` and `results`. For more information, see the [Formlabs Web API reference](https://formlabs-dashboard-api-resources.s3.amazonaws.com/formlabs-web-api-latest.html). ## What the Formlabs Web API covers The Formlabs Web API reads and manages what Dashboard shows: | Area | Paths | | --- | --- | | Printers | `printers/`, `printers/{printer_serial}/` | | Print history | `prints/`, `printers/{printer_serial}/prints/` | | Printer Groups and group queues | `groups/`, `groups/{group_id}/members/`, `groups/{group_id}/queue/` | | Consumables | `cartridges/`, `tanks/` | | Events | `events/` | For every endpoint, see the [Formlabs Web API reference](https://formlabs-dashboard-api-resources.s3.amazonaws.com/formlabs-web-api-latest.html). ## What's next - [Scene](https://developer.formlabs.com/core-concepts/scene.md) # Device A *device* is a printer, or a Printer Group, that a print job can go to. Each printer is identified by its *serial name*, such as `Form4-ExampleDevice`, in every Formlabs API. PreForm lists both the printers on its local network and Dashboard printers/Printer Groups (if logged in to a Formlabs account). ## How printers connect PreForm and PreForm Server reach printers on the local network. Printers reach Formlabs servers over the internet for Dashboard, remote printing, and firmware updates: ![Diagram of the connections between PreForm, Formlabs printers, and formlabs.com. PreForm sends print jobs and firmware updates to printers on port 35 over the local network. PreForm and printers reach api.formlabs.com, updates.formlabs.com, and gateway.formlabs.com on port 443 over the internet.](https://developer.formlabs.com/assets/images/support/connectivity-and-security-diagram.jpg) *Connections between PreForm, printers, and Formlabs servers.* A printer connects over USB, over a local network with no internet access, or over an internet-connected local network. Remote printing and Dashboard need the printer on the internet. For more information, see [Connectivity and security](https://formlabs.com/support/Connectivity-and-Security/). ## List devices with the PreForm API The `list_devices` method returns the same printers and Printer Groups that the printer list on the PreForm **Job Setup** screen shows: **SDK:** ```python # Disconnected devices aren't listed. for device in preform.list_devices().devices: print(device.id, device.product_name, device.status, device.ready_to_print_now) ``` **curl:** **macOS:** ```bash curl http://localhost:44388/v1/device/list/ ``` **Windows:** ```powershell curl.exe http://localhost:44388/v1/device/list/ ``` Each record carries the device's `id`, its `product_name`, its `status`, and its `ready_to_print_now` flag. A printer record also carries the `tank_material_code` of the loaded resin tank, so code that routes orders across a fleet can pick a printer that already has the right material. Disconnected devices aren't listed. To search the network first, call the `discover_devices` method. For more information, see [Manually adding a printer to PreForm](https://formlabs.com/support/Manually-adding-a-printer-to-PreForm/). ## What's next - [Group](https://developer.formlabs.com/core-concepts/group.md) - [Connectivity and security](https://formlabs.com/support/Connectivity-and-Security/) # Group A *Printer Group* is a set of printers in a Dashboard account, organized by location or purpose, such as one print room or one team's printers. Fleet Control gives each group a group queue, so a print job sent to the group goes to whichever printer in it is ready first. The group also controls who can see and use its printers. ## How a group fits in A group sits between Dashboard and the printers registered to the account: ```mermaid flowchart TB account["Dashboard account"] --> g1["Printer Group"] account --> g2["Printer Group"] g1 --> q1["Group queue"] g1 --> p1["Printer"] & p2["Printer"] g2 --> q2["Group queue"] g2 --> p3["Printer"] q1 -. "next print job" .-> p1 & p2 q2 -. "next print job" .-> p3 ``` - **Printers:** A printer is registered to one Dashboard account, and an admin places it in a group. - **Members:** A group has *admins* and *members*. An admin invites people by email, renames the group, and moves printers between groups. - **Group queue:** The print jobs sent to the group, waiting for a printer to be ready. For more information, see [Queue](https://developer.formlabs.com/core-concepts/queue.md). ## Create a group An admin creates and manages groups in Dashboard. To create a group: 1. Under **Printers**, click **Organize Printers**. 2. Click **+ New Printer Group**. ![The Printers page in Dashboard in organize mode, with the New Printer Group button highlighted and an unnamed group that holds one Form 3.](https://developer.formlabs.com/assets/images/support/dashboard-create-group-jan-2023.png) *Creating a Printer Group in Dashboard.* The person who creates a group becomes its admin. To add a printer to Dashboard, enter the registration code from the printer's touchscreen. For more information, see [Managing a Printer Group with Dashboard](https://formlabs.com/support/Managing-a-Printer-Group-with-Dashboard/) and [Adding and removing printers on Dashboard](https://formlabs.com/support/Adding-and-removing-printers-on-Dashboard/). ## Find a group from your code After PreForm logs in to the account, the printer list on the **Job Setup** screen shows each Printer Group with the model **Printer Group**: ![The Choose Printer step of Job Setup in PreForm. The printer list shows two Printer Groups, FLBP Community farm and West Wing, with the model Printer Group, above individual Form 3 printers.](https://developer.formlabs.com/assets/images/support/printer-groups-preform-jan-2023.png) *Printer Groups in the PreForm printer list.* The device list in the PreForm API holds the same groups, each with its queue ID and the printer types in it: ```python import os from formlabs_sdk import FormlabsClient with FormlabsClient( username=os.environ["FORMLABS_USERNAME"], password=os.environ["FORMLABS_PASSWORD"], ) as client: # login=True logs PreForm in with the client's credentials. PreForm lists Printer # Groups only while it's logged in to the account. with client.preform.connect(login=True) as preform: for device in preform.list_devices().devices: # Only a Printer Group record carries a dashboard_queue_id. if hasattr(device, "dashboard_queue_id"): queue_id = device.dashboard_queue_id print(device.id, queue_id, device.supported_machine_type_ids) ``` The `dashboard_queue_id` field is the value that the `print_scene` method takes as its `printer` ID to send a print job to the group. The `supported_machine_type_ids` field lists the printer types in the group, so create the scene with one of them. The Formlabs Web API lists, creates, and manages groups and their members. For more information, see the [Formlabs Web API reference](https://formlabs-dashboard-api-resources.s3.amazonaws.com/formlabs-web-api-latest.html). ## What's next - [Queue](https://developer.formlabs.com/core-concepts/queue.md) - [Dashboard overview and setup](https://formlabs.com/support/Dashboard-Overview-and-Setup/) # Queue A *queue* holds print jobs until a printer starts them. Each printer has its own queue, which holds the print jobs uploaded to it. Each Printer Group also has a *group queue*, the Fleet Control queue in Dashboard, which holds the print jobs sent to the group until a printer in the group is ready. ## How a print job moves through the queues A print job goes to one printer's queue, or to a group queue that hands it to the first ready printer: ```mermaid flowchart LR code["print_scene"] -- "printer: serial name" --> pq["Printer queue"] code -- "printer: queue ID" --> gq["Group queue
(Dashboard)"] gq -- "a printer is ready" --> pq2["Printer in the group"] pq --> start["Operator starts
the print job"] pq2 --> start ``` ## Queue a print job on a printer The `print_scene` method with `print_now=False` does what **Upload to Queue** does in PreForm: it uploads the print job to the printer's own queue. An operator starts it on the printer's touchscreen when the build platform is ready. For more information, see [Starting a print job on the printer touchscreen](https://formlabs.com/support/Starting-a-print-job-on-the-printer-touchscreen/). ![The Queue tab on a Form 4L touchscreen with three uploaded print jobs. Each shows its material, resin volume, and print time, and the first one is expanded with a start button and a delete button.](https://developer.formlabs.com/assets/images/support/f4l-queue.png) *The printer's own queue on a Form 4L touchscreen.* ## Send a print job to a group queue To send a print job to a Printer Group, pass the group's queue ID as the `printer`. The queue ID is the `dashboard_queue_id` field in the group's device record, and PreForm must be logged in to the account that owns the group: ```python # A queue ID as the printer sends the print job to the group queue. job = preform.print_scene( scene_id=scene.scene_id, printer=QUEUE_ID, job_name="bracket", ) ``` A group queue can't start a print job immediately, so `print_now=True` fails with `OPERATION_FAILED`. When PreForm can't find the queue within `find_printer_timeout_seconds`, 30 seconds by default, the operation fails with the same error. For more information, see [Group](https://developer.formlabs.com/core-concepts/group.md). ## How a group queue picks a printer Dashboard sends the next print job to a printer in the group when that printer is ready to print with the right consumables. Until one is, the print job waits in the queue. In Dashboard, an operator reorders, duplicates, and deletes queued print jobs: ![A group queue in Dashboard with four queued print jobs. Each is unassigned and waits for a printer with a matching configuration.](https://developer.formlabs.com/assets/images/support/fleet-control-reorder-queue-jan-2023.png) *Reordering a group queue in Dashboard.* A printer takes print jobs from the group queue only with remote printing turned on: ![The Remote printing and monitoring setting on a Form 4 touchscreen, turned on.](https://developer.formlabs.com/assets/images/support/f4-remote-printing-and-monitoring.png) *The remote printing setting on a Form 4.* For more information, see [Using Group Queues in Dashboard](https://formlabs.com/support/Using-Group-Queues-in-Dashboard/). ## What's next - [Tracking print progress with Dashboard](https://formlabs.com/support/Tracking-Print-Progress-with-Dashboard/)
# Headless PreForm Server PreForm Server is PreForm without its user interface. It serves the same PreForm API, so a script written against PreForm runs unchanged against PreForm Server. Use it where no one watches the screen, such as a computer that prepares each incoming order as it arrives, or an application that runs as a service. Most scripts don't need PreForm Server: get started [by connecting to the PreForm window](https://developer.formlabs.com/getting-started/install.md#connect-to-a-running-preform) you already have open. ## Download PreForm Server PreForm Server is distributed separately from PreForm. Download the build for macOS or Windows from [Formlabs API downloads and release notes](https://formlabs.com/support/Formlabs-API-downloads-and-release-notes?language=en_US). The SDK starts the server from the path of its executable. On macOS, that's the executable inside the app bundle, because the `.app` folder itself isn't an executable. ## Start PreForm Server from Python To check the setup, start the server, print its version, and shut it down: ```python import os from formlabs_sdk import FormlabsClient # On macOS, set PREFORM to the executable inside PreFormServer.app. PREFORM = os.environ.get("PREFORM", "PreFormServer.exe") with FormlabsClient() as client: # launch() waits up to 60 seconds for the server to start. Leaving the with block # shuts the server down. with client.preform.launch(PREFORM) as preform: print(f"PreForm Server {preform.version} at {preform.base_url}") ``` The script prints the server version and `http://localhost:44388`. The `launch()` method fails with `PORT_IN_USE` when another server already listens on port 44388. To use another port, pass the `port` argument. The SDK owns a server that `launch()` starts: leaving the `with` block shuts it down. `launch()` also logs the server in with the client's account, when the client has one. Every other call is the same as against PreForm, so a script from [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) runs headless once `connect()` is replaced with `launch()` and the path of the PreForm Server executable. ## Start PreForm Server from a terminal PreForm Server also runs on its own. The `--port` argument is required: **macOS:** ```bash /Applications/PreFormServer.app/Contents/MacOS/PreFormServer --port 44388 ``` **Windows:** ```powershell C:\PreFormServer\PreFormServer.exe --port 44388 ``` The server prints `READY FOR INPUT` after it starts accepting requests. A script then attaches with the `connect()` method, exactly as it does to PreForm, and closing the client leaves the server running. The `connect()` method leaves the server's login as it is, so a server started this way stays logged out until it's logged in. For more information, see [Log PreForm in](https://developer.formlabs.com/core-concepts/preform-client.md#log-preform-in). For the other options, see [CLI & command-line options](https://developer.formlabs.com/resources/preform-server-cli.md). ## What's next - [PreForm client](https://developer.formlabs.com/core-concepts/preform-client.md) # Examples Examples are provided for common use cases of the Formlabs Python SDK. Such as creating a report from a finished build to post-processing and billing or packing SLS jobs from a browser application. ## Example code Scripts that do one task from the command line and write a file you can use. **[Printability Report](https://developer.formlabs.com/examples/printability-report.md)** 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 didn't pass. **[Instant quote](https://developer.formlabs.com/examples/instant-quote.md)** Quote a price for a part in each material on a price list, with the print time and the resin including supports. **[Dental presets](https://developer.formlabs.com/examples/dental-presets.md)** Turn a case's scans into a print-ready `.form` file for orthodontic models, restorative models, occlusal guards, or surgical guides. **[Print setting rollout](https://developer.formlabs.com/examples/print-setting-rollout.md)** Move a queue of `.form` files to a new print setting, and preview the layer counts and print times before anything is saved. **[Part labels](https://developer.formlabs.com/examples/part-labels.md)** Turn a finished build into a report for sorting and billing: every part matched to its customer order, the material it used, and a bag label with its picture. ## Example applications Complete applications with a user interface, with PreForm Server behind them. **[Web SLS Packer](https://developer.formlabs.com/examples/web-sls-packer.md)** A web app that packs STL parts into Fuse 1+ and Fuse X1 builds from a browser, and sends each build to a printer. ## What's next - [Part labels](https://developer.formlabs.com/examples/part-labels.md) - [Web SLS Packer](https://developer.formlabs.com/examples/web-sls-packer.md) - [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) # 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) # 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](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/instant-quote) [Download instant-quote.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/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. ![The sample part: an L-bracket with a gusset and two mounting holes in each leg](https://developer.formlabs.com/examples/instant-quote-part.png) ![Ten copies of the bracket oriented, supported, and laid out on a Form 4 build platform, the build that the Grey V5 quote prices](https://developer.formlabs.com/examples/instant-quote-build.png) 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: ```toml [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 job ``` Each material follows as its own table, with its scene settings and a price per mL: ```toml [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.22 ``` The 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: ```python 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`: ```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 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](https://developer.formlabs.com/getting-started/install.md#connect-to-a-running-preform). `main` connects to PreForm, exits with a hint if nothing answers, and quotes each chosen material: ```python 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: ```python 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: ```python 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. ```python # 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: ```python 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: ```python 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: ```python # 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](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 instant_quote.py bracket.stl --copies 10 ``` **uv:** ```bash uv run instant_quote.py bracket.stl --copies 10 ``` The 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.19 ``` The layout uses a random seed, so the print time and the machine line can differ by a few seconds and cents between runs. ## What's next - [Part labels](https://developer.formlabs.com/examples/part-labels.md) # Dental presets This script turns the scans of a dental case into a print-ready `.form` file using one of the [dental presets](https://formlabs.com/support/Preparing-dental-scans-for-printing-with-PreForm-Dental-Workspace-Form-2-Form-3B-Form-3BL/#select-template): orthodontic models, restorative models, occlusal guards, or surgical guides. The script takes in the kind of case and STL files and picks the resin and layer height, orients each arch, adds supports to appliances, and lays everything out on the build platform. [View on GitHub](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/dental-presets) [Download dental-presets.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/dental-presets.zip) A dental lab can use presets (one of the provided presets, or create your own in PreForm), for example: a night guard that always goes on LT Comfort, tilted and supported, or a crown and bridge model that always goes on Precision Model at 0.05 mm. PreForm stores those choices as *presets* in its dental workspace, where a technician can pick one and PreForm sets up the build. This script applies the same presets from a script, so scans from your lab software or an intraoral scanner become a build ready to send, without a user having to open PreForm. `presets.toml` holds the four presets PreForm offers for Form 4B, with the same settings: | Preset | Prints | Resin | Layer height | Tilt | Supports | | --- | --- | --- | --- | --- | --- | | `orthodontic-models` | Arch models, such as for thermoforming aligners and retainers | Fast Model V1 | 0.16 mm | 0° | None | | `restorative-models` | Crown and bridge models | Precision Model V1 | 0.05 mm | 0° | None | | `occlusal-guards` | Night guards and splints | LT Comfort V1.1 | 0.1 mm | 15° | Full raft, labeled | | `surgical-guides` | Guides for implant placement | Surgical Guide V1 | 0.1 mm | 15° | Full raft, labeled | The script uses the PreForm API to orient, support, and lay out the models, and it reads back the size of each model, whether it fits on the build platform, the layer count, and the resin volume. ![Two arch models without supports, laid out side by side on the build platform](https://developer.formlabs.com/examples/dental-presets-orthodontic-models.png) ![A surgical guide tilted 15 degrees on supports and a full raft labeled with the model name](https://developer.formlabs.com/examples/dental-presets-surgical-guides.png) This example contains two files and a folder: `apply_preset.py` builds the job through the PreForm API, `presets.toml` holds the presets, and the `scans` folder holds four sample STL files: an upper and a lower arch for case 1042, an occlusal guard for case 1043, and a surgical guide for case 1044. The sample files are simple generated shapes that stand in for real scans. ## Describe each kind of case A preset is one table in `presets.toml`. The surgical guide preset prints on Surgical Guide V1 at 0.1 mm, tilts the guide 15 degrees, and supports it on a labeled raft: ```toml [presets.surgical-guides] material = "Surgical Guide V1" material_code = "FLSGAM01" layer_thickness_mm = 0.1 tilt_deg = 15 [presets.surgical-guides.supports] raft_type = "FULL_RAFT" raft_label_enabled = true density = 1.0 touchpoint_size_mm = 0.5 internal_supports_enabled = true ``` The keys under `supports` are the PreForm API's own parameter names. A preset without a `supports` table prints without supports, as the two model presets do. To add your lab's own kind of work, such as dentures or dies, add another `[presets.]` table. ## Fill in the defaults `load_presets` fills each preset from the `[defaults]` table: ```python def load_presets(path: Path) -> dict[str, dict]: """Read the presets, each filled in from the [defaults] table.""" data = tomllib.loads(path.read_text()) defaults = data.get("defaults", {}) return {name: defaults | preset for name, preset in data["presets"].items()} ``` The defaults set the machine type `FORM-4-0`, which covers Form 4B, the `DEFAULT` print setting, a tilt of 0 degrees, millimeter units, no mesh repair, the `DENTAL` layout, and 1 mm between models. A preset overrides any of them, as the orthodontic models preset does with `F4_FAST_ARCHES`, the Fast Arch print setting. ## Connect to PreForm Make sure [PreForm is ready to accept connections](https://developer.formlabs.com/getting-started/install.md#connect-to-a-running-preform). `PREFORM_URL` reads the PreForm address from `FL_LOCAL_BASE_URL`, with a default of port 44388: ```python PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388") ``` `main` connects to PreForm at that address, and exits with a hint if nothing answers: ```python 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" ) ``` Set `FL_LOCAL_BASE_URL` to reach PreForm on another port or host. ## Create the scene `apply_preset` creates a scene with the preset's printer, material, print setting, and layer thickness: ```python def apply_preset( preform: PreFormApi, preset: dict, models: list[Path], out: Path ) -> Scene: """Build a scene from the preset and models, save it as a .form, and return it.""" scene = preform.create_scene( machine_type=preset["machine_type"], material_code=preset["material_code"], print_setting=preset["print_setting"], layer_thickness_mm=preset["layer_thickness_mm"], ) ``` The four values must be a combination that `/v1/print-setting/list/` offers. For any other combination, the server returns `INPUT_ERROR` with the message `Scene type not supported`, and the script stops before it imports a model. ## Import the models Each STL file becomes one model, named after the file: ```python for model in models: # PreForm resolves paths from its own working directory. preform.import_models( scene_id=scene.scene_id, file=str(model.resolve()), name=model.stem, units=ImportUnits(preset["units"]), repair_behavior=RepairBehavior(preset["repair_behavior"]), ) ``` The model name carries the case number from the filename, such as `case-1042_upper_arch`. The presets import in millimeters, the unit that intraoral scanners and dental CAD software export. `IGNORE` leaves each mesh as it is. ## Orient the models The `DENTAL` orientation mode puts each arch occlusal side up, then tilts the occlusal plane by the preset's angle: ```python # DENTAL puts each arch occlusal side up, then tilts the occlusal plane by tilt degrees. preform.auto_orient_models( scene_id=scene.scene_id, mode=AutoOrientMode.DENTAL, tilt=preset["tilt_deg"] ) ``` Models print flat at 0 degrees, and guards and guides print tilted at 15 degrees, as in PreForm. ## Generate supports Guards and guides get supports and a raft: ```python if supports := preset.get("supports"): preform.auto_support_models( scene_id=scene.scene_id, raft_type=RaftType(supports["raft_type"]), raft_label_enabled=supports["raft_label_enabled"], density=supports["density"], touchpoint_size_mm=supports["touchpoint_size_mm"], internal_supports_enabled=supports["internal_supports_enabled"], ) ``` `FULL_RAFT` with `raft_label_enabled` embosses the model name on the raft, so after washing and curing, a technician can match each appliance to its case by the number on the raft. ## Lay out and save the build `auto_layout_models` places every model on the build platform, and `save_form_file` writes the job: ```python preform.auto_layout_models( scene_id=scene.scene_id, mode=AutoLayoutMode(preset["layout_mode"]), model_spacing_mm=preset["model_spacing_mm"], ) preform.save_form_file( file=str(out.resolve()), scene_ids=[scene.scene_id], project_name=out.stem ) return preform.get_scene(scene_id=scene.scene_id) ``` The `DENTAL` layout mode uses the layout parameters of PreForm's dental workspace. The scene that `get_scene` returns carries each model's `in_bounds` flag, and the script prints `NO` for any model that doesn't fit on the build platform. ## Delete the scene The `finally` block deletes the scene after the file is saved or a call fails: ```python finally: preform.delete_scene(scene_id=scene.scene_id) ``` ## Run the example 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`. 1. In a terminal, list the presets from the folder you unzipped: **pip:** ```bash python apply_preset.py --list ``` **uv:** ```bash uv run apply_preset.py --list ``` 2. Apply a preset to the models of one case: **pip:** ```bash python apply_preset.py orthodontic-models scans/case-1042_*.stl ``` **uv:** ```bash uv run apply_preset.py orthodontic-models scans/case-1042_*.stl ``` The script reads `presets.toml` and the model files you name, and writes `.form` next to the first model. Pass `--out` to choose another path and `--presets` to read another presets file. The two commands print the presets and the build: ``` orthodontic-models Fast Model V1, 0.16 mm, tilt 0, no supports restorative-models Precision Model V1, 0.05 mm, tilt 0, no supports occlusal-guards LT Comfort V1.1, 0.1 mm, tilt 15, supports surgical-guides Surgical Guide V1, 0.1 mm, tilt 15, supports orthodontic-models: Fast Model V1 (FLFMGR01) at 0.16 mm, tilt 0 deg, supports off model size x*y*z mm supports fits case-1042_lower_arch 40.2x53.2x14.7 no yes case-1042_upper_arch 45.7x58.9x16.4 no yes 102 layers, 25.0 mL of resin -> scans/orthodontic-models.form ``` ## What's next - [Print settings](https://developer.formlabs.com/core-concepts/print-settings.md) - [Part labels](https://developer.formlabs.com/examples/part-labels.md) # Print setting rollout When a new version of a print setting is approved, every job already prepared on the old version has to move to the new one. In PreForm, you would open each job, change the print setting in **Job Setup**, and save it again. This script does that for a whole folder of `.form` files. It changes only the jobs on the setting you name, saves the changed jobs to a new folder, and reports the layer count and print time of each job before and after the change. [View on GitHub](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/print-setting-rollout) [Download print-setting-rollout.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/print-setting-rollout.zip) A dry run on the sample jobs prints: ``` rolling out: Lab arches v4 (FORM-4-0, FLFMGR01, F4_FAST_ARCHES at 0.2 mm) dry run, nothing saved file current setting layers print time status case-1041.form Lab arches v3 150 -> 120 0h 15m -> 0h 12m changed case-1042.form Lab arches v3 156 -> 125 0h 16m -> 0h 13m changed case-1043.form Fast Arch 156 skipped, other setting 2 build volume(s) changed in 3 file(s) ``` The example contains the script, `roll_out_print_setting.py`, and its sample input: - `queue/` holds three supported jobs. Two are on the custom setting `Lab arches v3`, and one is on the shipped `Fast Arch` setting. - `lab_arches_v3.fps` and `lab_arches_v4.fps` are two versions of a custom setting. Both are copies of the Fast Arch setting for Fast Model V1 on Form 4 with a changed squish wait, and v4 also prints at 0.2 mm layers. ## Choose the new setting You can give the new setting as an `.fps` file, or as the four values of a shipped setting with `--shipped`: ```python paths = args.paths if args.shipped: machine, material, setting, thickness = args.shipped target = dict( machine_type=machine, material_code=material, print_setting=setting, layer_thickness_mm=float(thickness), ) elif paths[0].suffix.lower() == ".fps": target = {"fps_file": str(paths[0].resolve())} paths = paths[1:] else: sys.exit("Give an .fps file or --shipped MACHINE MATERIAL SETTING THICKNESS_MM") ``` Both end up as keyword arguments for the scene calls, so the rest of the script treats them the same way. ## 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 script reads the PreForm address from `FL_LOCAL_BASE_URL`, and uses `http://localhost:44388` if it isn't set: ```python PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388") ``` If PreForm doesn't answer, the script stops with a hint: ```python 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" ) ``` ## Check the new setting Before it opens any job, `check_target` makes sure the new setting works. It checks a shipped setting against PreForm's list, then creates an empty scene on the new setting and reads its name: ```python def check_target(preform: PreFormApi, target: dict) -> str: """Create an empty scene on the target setting, so a bad setting stops the run.""" if "fps_file" not in target and not preform.validate_print_setting(**target).valid: sys.exit(f"No shipped print setting matches {target}") scene = preform.create_scene(**target) try: return describe(preform, scene.scene_id) finally: preform.delete_scene(scene_id=scene.scene_id) ``` If the `.fps` file is missing or can't be read, the script stops here with `INPUT_ERROR`. The name on the first line of the report comes from the `.fps` file. ## Skip jobs on other settings `roll_out` opens each `.form` file. Each build volume in the file opens as its own scene, and the script reads the name of its print setting from the job metadata. With `--only-from`, it skips the build volumes on any other setting: ```python def roll_out( preform: PreFormApi, form: Path, target: dict, only_from: str | None, out: Path | None, with_time: bool, ) -> list[Row]: """Change each build volume in a .form file to the target, then save the file.""" opened = preform.open_form_file(file=str(form.resolve())) rows: list[Row] = [] try: for scene in opened.scenes: label = form.name if len(opened.scenes) > 1: label = f"{form.name} / {scene.scene_name}" before = setting_name(preform, scene.scene_id) layers_before = preform.get_scene(scene_id=scene.scene_id).layer_count if only_from and before != only_from: rows.append(Row(label, before, str(layers_before), status=SKIPPED)) continue ``` Each build volume is one row of the report. A skipped one shows the status `skipped, other setting`: ```python PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388") SKIPPED = "skipped, other setting" @dataclass class Row: label: str setting: str layers: str print_time: str = "" status: str = "" ``` The script matches the setting by the name PreForm shows. For a custom setting, that's the name stored in its `.fps` file, so give each version of a custom setting its own name. ## Change the setting `update_scene` takes the same arguments as `create_scene`. After the update, the script reads the scene again for its layer count and models: ```python time_before = print_time(preform, scene.scene_id) if with_time else "" # Models, supports, and layout stay. On another printer's setting, the # models move into its build volume, and any that don't fit are out of # bounds. preform.update_scene(scene_id=scene.scene_id, **target) after = preform.get_scene(scene_id=scene.scene_id) outside = [model.name for model in after.models if not model.in_bounds] rows.append( Row( label, before, f"{layers_before} -> {after.layer_count}", f"{time_before} -> {print_time(preform, scene.scene_id)}" if with_time else "", f"{len(outside)} model(s) outside the build volume" if outside else "changed", ) ) ``` The layer count changes when the new setting has another layer thickness, as `lab_arches_v4.fps` does. Models, supports, and layout stay as they are. If a model ends up outside the build volume, the report says so, and the job is still saved. ## Compare print times With `--print-time`, the script calculates the print time before and after the change: ```python def print_time(preform: PreFormApi, scene_id: UUID) -> str: seconds = preform.calculate_print_time(scene_id=scene_id).total_print_time_s return f"{seconds // 3600}h {seconds % 3600 // 60:02d}m" ``` The calculation slices each build, which adds time to the run. Leave out `--print-time` to skip it. ## Save the job The script saves the changed job to the `--out` folder, under its original file name, and the report shows where: ```python if out and any(row.status != SKIPPED for row in rows): out.mkdir(parents=True, exist_ok=True) saved = out / form.name preform.save_form_file( file=str(saved.resolve()), scene_ids=[scene.scene_id for scene in opened.scenes], project_name=opened.project_name or form.stem, ) for row in rows: if row.status != SKIPPED: row.status = f"saved to {saved}" if row.status == "changed" else f"{row.status}, saved to {saved}" ``` The original files are never changed. With `--dry-run`, nothing is saved. ## Delete the scenes The `finally` block deletes every scene the file opened, including when a call fails: ```python finally: for scene in opened.scenes: preform.delete_scene(scene_id=scene.scene_id) ``` ## Run the example 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`. 1. In a terminal, preview the change from the folder you unzipped: **pip:** ```bash python roll_out_print_setting.py lab_arches_v4.fps queue \ --only-from "Lab arches v3" --dry-run --print-time ``` **uv:** ```bash uv run roll_out_print_setting.py lab_arches_v4.fps queue \ --only-from "Lab arches v3" --dry-run --print-time ``` 2. Roll out the `.fps` file: **pip:** ```bash python roll_out_print_setting.py lab_arches_v4.fps queue \ --only-from "Lab arches v3" --out queue-v4 ``` **uv:** ```bash uv run roll_out_print_setting.py lab_arches_v4.fps queue \ --only-from "Lab arches v3" --out queue-v4 ``` 3. Roll out a shipped setting: **pip:** ```bash python roll_out_print_setting.py --shipped FORM-4-0 FLFMGR01 DEFAULT 0.2 queue \ --only-from "Lab arches v3" --out queue-v4 ``` **uv:** ```bash uv run roll_out_print_setting.py --shipped FORM-4-0 FLFMGR01 DEFAULT 0.2 queue \ --only-from "Lab arches v3" --out queue-v4 ``` The script reads the `.fps` file and every `.form` file in `queue/`, and writes the changed jobs to `queue-v4/`. To roll out another setting, pass its `.fps` file or its four `--shipped` values, and pass the name of the setting it replaces to `--only-from`. The preview prints the output at the top of this page. The `.fps` rollout saves both jobs: ``` rolling out: Lab arches v4 (FORM-4-0, FLFMGR01, F4_FAST_ARCHES at 0.2 mm) file current setting layers status case-1041.form Lab arches v3 150 -> 120 saved to queue-v4/case-1041.form case-1042.form Lab arches v3 156 -> 125 saved to queue-v4/case-1042.form case-1043.form Fast Arch 156 skipped, other setting 2 build volume(s) changed in 3 file(s) ``` The shipped setting rollout saves both jobs on the shipped setting: ``` rolling out: Default (FORM-4-0, FLFMGR01, DEFAULT at 0.2 mm) file current setting layers status case-1041.form Lab arches v3 150 -> 120 saved to queue-v4/case-1041.form case-1042.form Lab arches v3 156 -> 125 saved to queue-v4/case-1042.form case-1043.form Fast Arch 156 skipped, other setting 2 build volume(s) changed in 3 file(s) ``` ## What's next - [Print settings](https://developer.formlabs.com/core-concepts/print-settings.md) - [Part labels](https://developer.formlabs.com/examples/part-labels.md) # Part labels This script turns a `.form` file into a build report that travels with the build through post-processing and sorting. The report opens with a billing summary of every part by customer order and the material it used, followed by a bag label for each part. [View on GitHub](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/part-labels) [Download part-labels.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/part-labels.zip) The script uses the PreForm API to calculate the material each model uses with its supports and share of the raft, the build's total material usage, and it creates a rendered picture of each part from the `.form` file. The order number and customer each part belongs to would come from an order system, exported here as `orders.csv`. ![The billing summary: every part by order, with the material it used and a subtotal per order](https://developer.formlabs.com/examples/build-report.png) ![A letter sheet of six bag labels, each with the order number and a rendered picture of the part](https://developer.formlabs.com/examples/part-labels.png) This example contains three files: `generate_part_labels.py` reads the build through the PreForm API, `label_pdf_builder.py` draws the PDF with [PyMuPDF](https://pymupdf.readthedocs.io), and `orders.csv` stands in to represent an export from an order system. ## Match parts to orders `read_orders` maps each part name to an order number and a customer, from the CSV export: ```python FORM_FILE = Path(__file__).parent / "model_octopus.form" ORDERS_FILE = Path(__file__).parent / "orders.csv" OUTPUT_PATH = FORM_FILE.with_suffix(".report.pdf") DATE = datetime.date.today().isoformat() PREFORM_URL = os.environ.get("FL_LOCAL_BASE_URL", "http://localhost:44388") UNASSIGNED = ("UNASSIGNED", "No order found for this part") def read_orders(path: Path) -> dict[str, tuple[str, str]]: """Map each part name to its (order, customer), from the order system's export.""" with path.open(newline="") as f: rows = csv.DictReader(f) return {row["part"]: (row["order"], row["customer"]) for row in rows} ``` A part missing from the export is reported under `UNASSIGNED`, so a sorter sees it on the label before it goes into a bag. ## Connect to PreForm Make sure [PreForm is ready to accept connections](https://developer.formlabs.com/getting-started/install.md#connect-to-a-running-preform). `main` connects to PreForm, and exits with a hint if nothing answers: ```python def main() -> None: orders = read_orders(ORDERS_FILE) 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" ) ``` `PREFORM_URL` defaults to `http://localhost:44388`. Set `FL_LOCAL_BASE_URL` to reach PreForm on another port or host. ## Open the .form file `open_form_file` returns one scene for each build volume in the file, and each scene gets its own section of the report: ```python def fetch_builds( preform: PreFormApi, form_file: Path, orders: dict[str, tuple[str, str]], images: Path, ) -> list[Build]: """Open the .form file and return its builds, with a Label per distinct part.""" opened = preform.open_form_file(file=str(form_file.resolve())) ``` The path is absolute because PreForm resolves it from its own working directory. ## Group the models into parts Each scene's job metadata holds the material, the build's total material usage, and an entry for every model. A build usually holds several copies of one part, so the script groups models on their display name and mesh hash: ```python builds: list[Build] = [] try: for scene in opened.scenes: metadata = preform.get_scene_metadata(scene_id=scene.scene_id) # Copies of a part share a display name and a mesh hash, so grouping on that # pair gives one entry per distinct part, holding the metadata of every copy. parts: dict[tuple[str, str], list[dict]] = defaultdict(list) for model in metadata["Models"]: parts[(model["DisplayName"], model["RawMeshHash"])].append(model) labels: list[Label] = [] for (name, _mesh), copies in parts.items(): image = images / f"{len(builds):02d}-{len(labels):03d}.png" ``` The shape of the job metadata varies by printer family, so the script reads only the keys it reports. The mesh hash keeps two different meshes that share a display name on separate labels. ## Render a picture of each part `render_scene` renders one copy of each part, zoomed and cropped to that model: ```python for (name, _mesh), copies in parts.items(): image = images / f"{len(builds):02d}-{len(labels):03d}.png" preform.render_scene( scene_id=scene.scene_id, file=str(image), # The copies are identical, so any one will do model_ids=[copies[0]["Guid"].strip("{}")], view_type=RenderViewType.ZOOM_ON_MODELS, crop_to_models=True, image_size_px=600, ) ``` The images go to a temporary directory that `main` removes after the PDF is written. ## Record the material each part used Each model's metadata entry carries two volumes. `ModelVolume_mL` is the part alone, and `Volume_mL` adds the model's own supports and its share of the raft: ```python order, customer = orders.get(name, UNASSIGNED) labels.append( Label( order=order, customer=customer, part=name, quantity=len(copies), # Volume_mL is the model plus its own supports and share of the raft, so # the parts' totals add up to the material the whole build uses. model_ml=sum(copy["ModelVolume_mL"] for copy in copies), material_ml=sum(copy["Volume_mL"] for copy in copies), image=image, ) ) ``` The `Volume_mL` values of every model add up to the build's `MaterialUsage_mL`, so each order is charged for the material its parts consumed, supports included. The script sums the copies of a part because copies in different places on the build platform get different supports. ## Sort the labels by order The labels for one order print next to each other, so a sorter can bag one order at a time: ```python # Sorters work one order at a time, so the labels for an order print together. labels.sort(key=lambda label: (label.order, label.part)) ``` ## Delete the scenes The `finally` block deletes every scene the file opened: ```python finally: for scene in opened.scenes: preform.delete_scene(scene_id=scene.scene_id) ``` Without it, a failure partway through leaves the scenes open on the server. ## Lay out the report `label_pdf_builder.py` makes no PreForm API calls. `draw_summary` writes the billing page: every part with its order, customer, quantity, and material, a subtotal for each order, and the build total. `draw_label` puts the order number first, because that's what a sorter matches the bag to: ```python def draw_label( page: pymupdf.Page, box: pymupdf.Rect, label: Label, build: Build ) -> None: """Picture on the left; order, customer, part, quantity, and footer on the right.""" pad = 0.08 * INCH inner = pymupdf.Rect(box.x0 + pad, box.y0 + pad, box.x1 - pad, box.y1 - pad) split = inner.x0 + inner.width * PICTURE_SHARE page.insert_image( pymupdf.Rect(inner.x0, inner.y0, split - pad, inner.y1), filename=str(label.image), keep_proportion=True, ) footer = [f"{label.material_ml:.1f} mL {build.material}", build.date] # Centre the text block against the picture, then fill it in from the top down. text_pt = ORDER_PT + FOOTER_PT + PART_PT * PART_LINES + QTY_PT block = (text_pt + len(footer) * FOOTER_PT) * LINE + GAP_PT y = inner.y0 + max(0.0, (inner.height - block) / 2) def draw_row(y: float, text: str, size: float, font: str, lines: int = 1) -> float: """Draw one row of text at y, and return the y the next row starts at.""" box = pymupdf.Rect(split, y, inner.x1, y + size * (LINE * lines + HEADROOM)) return y + fit_text(page, box, text, size, font) # The order number leads: it is what a sorter matches the bag to. y = draw_row(y, label.order, ORDER_PT, BOLD) y = draw_row(y, label.customer, FOOTER_PT, REGULAR) y = draw_row(y, label.part, PART_PT, REGULAR, lines=PART_LINES) y = draw_row(y, f"Qty: {label.quantity}", QTY_PT, BOLD) + GAP_PT for entry in footer: y = draw_row(y, entry, FOOTER_PT, REGULAR) ``` The labels fill a US Letter sheet, six 4 x 3.333 inch labels to a page. To print on other stock or add fields, change the constants at the top of the file and the `Label` record; the API calls stay the same. ```python # Page Formatting INCH = 72.0 PAGE_W, PAGE_H = 8.5, 11.0 LABEL_W, LABEL_H = 4.0, 3.333 COLS, ROWS = 2, 3 MARGIN_X, MARGIN_Y = 0.16, 0.5 PICTURE_SHARE = 0.47 # how much of the label's width the picture takes PER_PAGE = COLS * ROWS ORDER_PT, PART_PT, QTY_PT, FOOTER_PT = 20.0, 15.0, 15.0, 11.0 LINE = 1.35 # height of one printed line, as a multiple of the type size HEADROOM = 0.4 # extra room insert_textbox needs above the first line of a box PART_LINES = 1 # lines allowed for the part name; raise to 2 to let long names wrap GAP_PT = 10.0 # blank space between the quantity and the footer REGULAR, BOLD = "helv", "hebo" ``` ## 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) and [PyMuPDF](https://pymupdf.readthedocs.io), which draws the PDF, first; with [uv](https://docs.astral.sh/uv/getting-started/installation/), `uv run` installs both from the example's `pyproject.toml`. **pip:** ```bash pip install pymupdf python generate_part_labels.py ``` **uv:** ```bash uv run generate_part_labels.py ``` The script reads `model_octopus.form` and `orders.csv` from its own folder and writes `model_octopus.report.pdf` next to them. To report on another build, change `FORM_FILE` and `ORDERS_FILE`. The script prints what it found: ``` model_octopus.form: 6 labels, 54.2 mL of Alumina 4N V1 SO-10482 1 x connector_fornormaloctopus 2.9 mL SO-10482 3 x multilegs_octopus_all 42.5 mL SO-10491 1 x eye_inner_left_multipart 1.3 mL SO-10491 1 x eye_inner_right_multipart 1.3 mL SO-10491 1 x eye_outer_left_multipart 3.1 mL SO-10491 1 x eye_outer_right_multipart 3.2 mL -> model_octopus.report.pdf ``` ## What's next - [Web SLS Packer](https://developer.formlabs.com/examples/web-sls-packer.md) # Web SLS Packer Web SLS Packer is a web application that manages SLS jobs from a browser. People add parts to a job, and the app packs them into Fuse 1+ or Fuse X1 build volumes with PreForm Server, one build after another. Each build shows up as it's packed, and downloads as a `.form` file or goes to a printer. [View on GitHub](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/web-sls-packer) [Download web-sls-packer.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/web-sls-packer.zip) ![Web SLS Packer showing a job of 80 3DBenchy boats for a Fuse 1+ in Nylon 12: the part with its size, volume, quantity and orientation lock, and two packed builds, the first holding 63 boats at 19.1% packing density and the second the 17 that did not fit.](https://developer.formlabs.com/assets/images/screenshots/web-sls-packer-benchy-batch.png) *Eighty boats pack into two Fuse 1+ builds: 63 in the first, and the 17 that did not fit in the second.* The browser talks only to the app, and the app is the only client of PreForm Server. ## Set up the app The app connects to PreForm Server once, when it starts, and every request uses that connection: ```python @asynccontextmanager async def lifespan(app: FastAPI): with FormlabsClient() as client: preform.connect(client) for job in list_jobs(): if job.status == "packing": job.status = "failed" job.error = "interrupted by an app restart; press Pack again" job.save() yield app = FastAPI(title="Web SLS Packer", lifespan=lifespan) app.mount("/static", StaticFiles(directory=HERE / "static"), name="static") ``` `MACHINE_TYPES` in `preform.py` limits jobs to the Fuse 1+ and the Fuse X1, and each job offers the materials PreForm lists for them. Each job lives in `jobs//`, next to `app.py`. ## Add parts The `/jobs//parts` route saves each uploaded file, measures it, and renders a thumbnail: ```python @app.post("/jobs/{job_id}/parts", response_class=HTMLResponse) def add_parts(job_id: str, files: list[UploadFile]): """Save each mesh, measure it, and render a thumbnail. The thumbnail also shows whether PreForm can import the mesh. """ job = load_job(job_id) bv = preform.build_volume(job.machine_type, job.material_code) added: list[Part] = [] for upload in files: name = upload.filename if not name or not name.lower().endswith((".stl", ".obj", ".3mf")): continue stamp = int(time.time() * 1000) % 10**9 part = Part(id=f"p{stamp:09d}{len(job.parts) + len(added):02d}", filename=Path(name).name, volume_mm3=0, extents_mm=[0, 0, 0]) path = job.part_path(part) with path.open("wb") as f: shutil.copyfileobj(upload.file, f) mesh = trimesh.load_mesh(path) part.volume_mm3 = float(abs(mesh.volume)) part.extents_mm = [float(e) for e in mesh.bounding_box.extents] part.import_error = preform.thumbnail(job.machine_type, job.material_code, path, job.thumbnail_path(part)) part.thumbnail = part.import_error is None part.reject_reason = packer.reject_reason(part, bv) added.append(part) job.parts += added job.save() return _rows_and_summary(job, added) ``` Rendering the thumbnail imports the part, so a file PreForm can't read is flagged as soon as it's uploaded. Clicking the thumbnail opens a 3D view for choosing the pose the part prints in. ## Pack the builds The `run` function fills one build at a time, and carries what doesn't fit to the next: ```python def run(job: Job, on_build: Callable[[Build], None], cancel: threading.Event | None = None) -> None: """Pack the job's parts into builds, saving the job after each. Raises Cancelled between builds when asked. """ packer = Packer(job, cancel) queue: list[Model] = [] for part in job.parts: part.reject_reason = reject_reason(part, packer.bv) if not part.reject_reason: queue += [Model(len(queue) + i, part) for i in range(part.quantity)] job.builds, job.unplaceable = [], [] while queue: if packer.cancel.is_set(): raise Cancelled("packing cancelled") chosen = packer.candidates(queue) settings = preform.scene_settings(job.machine_type, job.material_code) scene_id = str(packer.api.create_scene(**settings).scene_id) spacing = job.spacing_mm if job.spacing_mm is not None else UNSET try: mapping = packer.stage(scene_id, chosen) packed = packer.api.auto_pack_models( scene_id=scene_id, seed=7, model_spacing_mm=spacing, on_pack_failure=OnPackFailure.PARK_OUTSIDE_BUILD_VOLUME) landed = [mapping[str(i)] for i in packed.packed_model_ids] carried = [mapping[str(i)] for i in packed.unpacked_model_ids] if not landed: # Nothing fit, though every part passed reject_reason: the oldest # part cannot be placed in its lock. culprit = min(chosen, key=lambda m: m.seq).part job.unplaceable.append(culprit.filename) for m in chosen: if m.part is not culprit: bisect.insort(queue, m) continue for m in carried: bisect.insort(queue, m) if carried: packer.api.delete_models( scene_id=scene_id, model_ids=[str(i) for i in packed.unpacked_model_ids]) result = packer.finish(scene_id, len(job.builds) + 1) finally: packer.api.delete_scene(scene_id=scene_id) counts: dict[str, int] = {} for m in landed: counts[m.part.filename] = counts.get(m.part.filename, 0) + 1 build = Build(len(job.builds) + 1, counts, [m.part.filename for m in carried], round(result["height"], 1), round(result["density"], 4), result["density"] >= job.min_density, result["print_time_s"], result["cool_time_s"], round(result["powder_kg"], 3), round(result["sintered_kg"], 3)) job.builds.append(build) job.save() on_build(build) ``` Each build is offered about one build's worth of parts, mixed across part types. With `PARK_OUTSIDE_BUILD_VOLUME`, a pack that can't place every model reports the rest in `unpacked_model_ids` instead of failing, and those go back to the head of the queue. ## Run the example In a terminal, start the app from the folder you unzipped. With pip, [install the Formlabs SDK](https://developer.formlabs.com/getting-started/install.md) and the app's other packages first; with [uv](https://docs.astral.sh/uv/getting-started/installation/), `uv run` installs them all from the example's `pyproject.toml`. **pip:** ```bash pip install fastapi uvicorn jinja2 python-multipart numpy trimesh python app.py ``` **uv:** ```bash uv run app.py ``` Open `http://localhost:8323` in your browser. # PreForm API *v1.0.0-alpha* *OpenAPI 3.1.0* The PreForm API prepares jobs for Formlabs printers and sends them to print. PreForm, or PreForm Server, its windowless edition, serves it over HTTP on your own machine. From a script or a pipeline, import models; orient, support and lay them out; check printability; and slice or print the result. It supports Formlabs SLA and SLS printers; [`/v1/print-setting/list/`](https://developer.formlabs.com/api-reference/print-settings/list-print-settings.md) lists every printer and material a server supports. Every v1 endpoint is under `/v1/`. **Base URL** `http://localhost:44388` PreForm or PreForm Server on the local machine, on the port it was started with: PreForm Server's required `--port` argument, or the port chosen when starting the server from PreForm's File menu. **Just getting started?** - [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md) The Python SDK and PreForm - [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) Prepare a part and slice it with the Python SDK - [PreForm Server in PreForm](https://developer.formlabs.com/getting-started/install.md#install-preform) Start it from the desktop app's File menu - [Headless PreForm Server](https://developer.formlabs.com/guides/headless-preform-server.md) Start it from a terminal or a service ## How PreForm Server differs from a cloud API Most web APIs, such as the Formlabs Web API, are stateless REST services: each request stands alone, and resources live in the provider's database. PreForm Server is a desktop application with an HTTP interface. It holds your work in memory between requests and runs beside your files and printers. 1. **State lives in the server process.** Scenes, models, operations and the Dashboard login live in the server's memory and vanish when it exits. Save work with [`/v1/scene/save/`](https://developer.formlabs.com/api-reference/scenes/save-form-file.md) and reopen it with [`/v1/scene/open/`](https://developer.formlabs.com/api-reference/scenes/open-form-file.md). Create or open a scene first; endpoints that act on it take its `scene_id`. 2. **Endpoints are actions, not resources.** Each route names an action, such as `/v1/scene/model/auto-orient/`, and takes a JSON body, so most are `POST`, reads included. A response reports what the action did, such as the ids it created or work it left unfinished, not the resulting state. Read state with [`/v1/scene/get/`](https://developer.formlabs.com/api-reference/scenes/get-scene.md) or [`/v1/scene/model/list/`](https://developer.formlabs.com/api-reference/modifying-models/list-models.md). 3. **Changes to a scene run in order.** Requests that change a scene run one at a time, in arrival order, and each sees the results of those before it. Reads don't wait: they return the scene as the last finished request left it. 4. **It runs on your machine.** The server listens on localhost and has no API keys: whatever reaches the port can use it, so keep the port private to the machine. File paths in requests refer to the server's machine, or to its container when it runs in Docker. Endpoints that call Formlabs web services also need a Dashboard login ([`/v1/login/`](https://developer.formlabs.com/api-reference/authentication/login.md)), and say so in their descriptions. **Architecture** *Diagram: Your code calls PreForm Server over HTTP on localhost. The server holds scenes and operations in memory, and reads and writes files on this machine, sends jobs to printers, and calls Formlabs web services.* ## A typical session Work follows the server's lifetime. Start the server or connect to one that is running, create a scene, and change it one request at a time. Each request builds on the state the last one left. Save the scene to a `.form` file before the server exits; the scene goes with it. **A session** **Python:** ```python from formlabs_sdk import FormlabsClient with FormlabsClient() as client: # Attach to PreForm, or PreForm Server, on port 44388. preform = client.preform.connect() # The scene lives in the server's memory... scene = preform.create_scene( machine_type="FORM-4-0", material_code="FLGPGR05", layer_thickness_mm=0.1, ) sid = scene.scene_id preform.import_models(scene_id=sid, file="part.stl") preform.auto_orient_models(scene_id=sid) # ...so save it to keep it once the server exits. preform.save_form_file( scene_ids=[sid], file="part.form" ) ``` **cURL:** ```bash curl -X POST "http://localhost:44388/v1/scene/create/" \ -H "Content-Type: application/json" \ -d '{ "machine_type": "FORM-4-0", "material_code": "FLGPGR05", "layer_thickness_mm": 0.1 }' # {"scene_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", ...} curl -X POST "http://localhost:44388/v1/scene/model/import/" \ -H "Content-Type: application/json" \ -d '{ "scene_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "file": "C:\\Models\\part.stl" }' curl -X POST "http://localhost:44388/v1/scene/model/auto-orient/" \ -H "Content-Type: application/json" \ -d '{ "scene_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }' # Write the scene to disk: it is lost when the server exits. curl -X POST "http://localhost:44388/v1/scene/save/" \ -H "Content-Type: application/json" \ -d '{ "scene_ids": [ "3fa85f64-5717-4562-b3fc-2c963f66afa6" ], "file": "C:\\Models\\part.form" }' ``` ## Asynchronous requests Slicing, importing a large CAD file and generating supports can take minutes. Endpoints that can run long accept `?async=true` and return `202 Accepted` at once, with an `operation_id` and an `operation_url` (also in the `Location` header). Poll the `operation_url` until `status` is `SUCCEEDED` or `FAILED`; `result` then holds the response the endpoint would have returned synchronously. Like scenes, operations do not survive a restart. Without `async`, a call blocks until it finishes or reaches the server's 10-minute timeout. **Asynchronous request** **Python:** ```python from formlabs_sdk import FormlabsClient with FormlabsClient() as client: preform = client.preform.connect() # The SDK sends ?async=true and polls for you. result = preform.import_models( scene_id="3fa85f64-5717-4562-b3fc-2c963f66afa6", file="assembly.step", on_progress=lambda p: print(f"{p:.0%}"), timeout=600, ) ``` **cURL:** ```bash curl -X POST "http://localhost:44388/v1/scene/model/import/?async=true" \ -H "Content-Type: application/json" \ -d '{ "scene_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "file": "C:\\Models\\assembly.step" }' # Poll the operation_url until status is SUCCEEDED or FAILED. curl "http://localhost:44388/v1/operation/get/?operation_id=7c9e6679-7425-40de-944b-e07fc1f90ae7" ``` **Response** **202 Accepted:** ```json { "operation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "operation_url": "/v1/operation/get/?operation_id=7c9e6679-7425-40de-944b-e07fc1f90ae7", "message": "Operation accepted. Poll GET /v1/operation/get/?operation_id=7c9e6679-7425-40de-944b-e07fc1f90ae7" } ``` **Polled: SUCCEEDED:** ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "SUCCEEDED", "progress": 1.0, "result": { "new_models": [{ "model_id": "c2a37eb2-8e4b-4b8e-9f37-1f5f2c1a9d10" }] } } ``` ## Errors The API uses conventional HTTP status codes, and every error body is an `error` object with a `code` and a `message`. **Program against `code`, never against `message`.** Codes are stable SCREAMING_SNAKE_CASE identifiers from the [ErrorCode](https://developer.formlabs.com/api-reference/errors.md) registry; new ones may appear without a version change, so handle codes you don't recognize. Messages are for people and may change in any release. Each endpoint's *Error codes* table lists the codes it returns and their causes. **Error response** **Python:** ```python from formlabs_sdk import FormlabsClient, PreFormApiError with FormlabsClient() as client: preform = client.preform.connect() sid = "3fa85f64-5717-4562-b3fc-2c963f66afa6" try: scene = preform.get_scene(scene_id=sid) except PreFormApiError as error: # Match on the code, never on the message. if error.code != "INPUT_ERROR": raise print("No such scene") ``` **JSON:** ```json { "error": { "code": "INPUT_ERROR", "message": "Scene with given scene_id not found" } } ``` **HTTP status codes** | Status | Meaning | | --- | --- | | `200 OK` | The request succeeded. | | `202 Accepted` | Started with `?async=true`. Poll the returned operation. | | `400 Bad Request` | The request was malformed or referenced something that does not exist. | | `404 Not Found` | A referenced resource, such as a model or a connected printer, does not exist, or the path is not a v1 endpoint. The code is `NOT_FOUND`. | | `409 Conflict` | The server's current state prevents the request: a referenced resource is busy with another operation, or the request needs more scenes than the server can hold. | | `422 Unprocessable Entity` | The request is well-formed and names a real scene, but the scene's current configuration does not support it. | | `500 Internal Server Error` | Something failed inside the server that the request did not cause. | ## Conventions ### Identifiers Scenes, models and operations have UUIDs. Responses give them bare (`3fa85f64-5717-4562-b3fc-2c963f66afa6`); requests also accept the braced form (`{3fa85f64-...}`). ### Enums Enum values are SCREAMING_SNAKE_CASE. **Request enums are closed:** a value outside the list fails with `400 JSON_SCHEMA_MATCH_ERROR`. Later releases may accept new values, which older servers reject the same way. **Response enums are open:** schemas that list their values under `x-extensible-enum`, instead of `enum`, may gain values in any release without a version change, so treat an unrecognized value as unknown instead of failing. ### Created models Requests that add models return their ids, in no particular order. When they derive from an existing model (copies, split bodies, a drilled plug), the response names it too, so you never need to diff the scene. **Enum in a response** ```json { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "status": "IN_PROGRESS", "progress": 0.42 } ``` ## Resources **[Server Info](https://developer.formlabs.com/api-reference/server-info.md)** Endpoint for checking that the server is up and reachable. 1 endpoint **[Authentication](https://developer.formlabs.com/api-reference/authentication.md)** Endpoints for checking and managing your Formlabs Dashboard account login state. Endpoints that call Formlabs web services need a login session, and say so in their descriptions. 3 endpoints **[Scenes](https://developer.formlabs.com/api-reference/scenes.md)** Create, load, and enumerate scenes. A scene is the top-level unit of work: it holds a printer/material/print-setting combination and the models placed within that printer's build volume. 12 endpoints **[Modifying Models](https://developer.formlabs.com/api-reference/modifying-models.md)** Endpoints that take actions on models within a scene. 24 endpoints **[Printability Checks](https://developer.formlabs.com/api-reference/printability-checks.md)** Scene printability checks. These endpoints analyze the scene and report problems. They never modify it. 7 endpoints **[Print Settings](https://developer.formlabs.com/api-reference/print-settings.md)** The catalog of machine/material/layer-thickness/print-setting combinations Formlabs ships settings for, and validation of a candidate combination against that catalog. 3 endpoints **[Operations](https://developer.formlabs.com/api-reference/operations.md)** Poll and manage the long-running operations started by endpoints called with `?async=true`. `/v1/operation/get/` returns one operation's status and, once finished, its result. See the *Asynchronous requests* section of the introduction. 3 endpoints **[Devices](https://developer.formlabs.com/api-reference/devices.md)** Enumerate printers and Fleet Control printer groups PreForm knows about, discover new ones on the network, and upload firmware to them. Discovering Dashboard-registered printers and Fleet Control queues requires a Dashboard login (`/v1/login/`). 3 endpoints **[Errors](https://developer.formlabs.com/api-reference/errors.md)** The shared error envelope and the registry of stable error codes. See the *Errors* section of the introduction for the contract. Reference **[Schemas](https://developer.formlabs.com/api-reference/schemas.md)** The 135 request and response schemas. [Download the OpenAPI specification](https://developer.formlabs.com/reference/openapi-v1.yml) # Resources ## Releases and downloads **[Release notes](https://developer.formlabs.com/resources/release-notes.md)** Where to find what changed in each release of the PreForm API, PreForm, and printer firmware. **[Downloads](https://developer.formlabs.com/resources/downloads.md)** PreForm Server, PreForm, and the Formlabs Python SDK, Dashboard developer credentials, and the bundle for each example. ## Running and building **[CLI & command-line options](https://developer.formlabs.com/resources/preform-server-cli.md)** PreFormServer's options, such as `--port` and `--scene-cache-size`, how to start the server, and the environment variable that turns off its telemetry. **[For coding agents](https://developer.formlabs.com/resources/for-coding-agents.md)** The `llms.txt` index, the Markdown copy of every page, and the OpenAPI specification, and the lines that point a coding agent at them. ## Help **[Support & forum](https://developer.formlabs.com/resources/support.md)** Where to ask questions and discuss the PreForm API and the Formlabs Python SDK. ## What's next - [Downloads](https://developer.formlabs.com/resources/downloads.md) - [For coding agents](https://developer.formlabs.com/resources/for-coding-agents.md) - [Create your first script](https://developer.formlabs.com/getting-started/quickstart.md) # Release notes The release notes for the PreForm API, PreForm, and printer firmware are published on the Formlabs support site. - **PreForm API:** [Formlabs API downloads and release notes](https://formlabs.com/support/Formlabs-API-downloads-and-release-notes/) lists each PreForm Server release, with its download and what changed in the API. - **PreForm:** [PreForm release notes](https://formlabs.com/support/PreForm-Release-Notes/) lists what changed in each PreForm release, including the print settings and materials it adds. - **Printer firmware:** [Firmware release notes](https://formlabs.com/support/Firmware-Release-Notes/) lists what changed in each firmware release for Formlabs printers. ## What's next - [Downloads](https://developer.formlabs.com/resources/downloads.md) - [API reference](https://developer.formlabs.com/api-reference.md) # Downloads The Formlabs Python SDK needs PreForm Server or PreForm, and the Formlabs Web API needs developer credentials from Dashboard. Each example ships as its own bundle. ## Software The downloads for each API: - [PreForm Server and release notes](https://formlabs.com/support/Formlabs-API-downloads-and-release-notes/) - [PreForm](https://formlabs.com/software/preform/) - [Formlabs Python SDK](https://github.com/Formlabs/formlabs-api-python) - [Dashboard developer credentials](https://dashboard.formlabs.com/#developer) ## Example bundles Each example page links its own bundle: | Bundle | Example | | --- | --- | | [printability-report.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/printability-report.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/printability-report)) | [Printability report](https://developer.formlabs.com/examples/printability-report.md), with five sample STL files in `order-1234`, one for each outcome | | [instant-quote.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/instant-quote.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/instant-quote)) | [Instant quote](https://developer.formlabs.com/examples/instant-quote.md), with a sample price list and a sample bracket STL file | | [dental-presets.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/dental-presets.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/dental-presets)) | [Dental presets](https://developer.formlabs.com/examples/dental-presets.md), with `presets.toml` and four sample STL files | | [print-setting-rollout.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/print-setting-rollout.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/print-setting-rollout)) | [Print setting rollout](https://developer.formlabs.com/examples/print-setting-rollout.md), with three queued Form 4 jobs and two `.fps` files | | [part-labels.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/part-labels.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/part-labels)) | [Part labels](https://developer.formlabs.com/examples/part-labels.md), with `model_octopus.form` and `orders.csv` | | [web-sls-packer.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha/web-sls-packer.zip) ([source](https://github.com/Formlabs/formlabs-api-python/tree/v1.0.0-alpha/examples/web-sls-packer)) | [Web SLS Packer](https://developer.formlabs.com/examples/web-sls-packer.md) | ## What's next - [Examples](https://developer.formlabs.com/examples.md) - [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md) # Support & forum Formlabs Support and the Formlabs community forum answer questions about the PreForm API, the Formlabs Python SDK, and the printers and software they drive. - **Formlabs Support:** [formlabs.com/support](https://formlabs.com/support/) has the guides and troubleshooting articles for Formlabs printers, PreForm, and Dashboard, and is where you contact the Formlabs support team. - **Formlabs forum:** [forum.formlabs.com](https://forum.formlabs.com/) is where you can ask other Formlabs users and developers, and share what you've built. - **Provide Feedback & Report Bugs** Use [this form](https://formlabs.qualtrics.com/jfe/form/SV_eyOAX6uxuRsltnE) to provide feedback and report bugs directly to the PreForm API team. ## What's next - [Release notes](https://developer.formlabs.com/resources/release-notes.md) - [Errors](https://developer.formlabs.com/api-reference/schemas/errorcode.md) # CLI & command-line options `PreFormServer` is a command-line application. It serves the PreForm API on the port you pass, and `--help` lists its options. ## Options PreForm Server accepts the following options: | Option | Effect | | --- | --- | | `-p`, `--port ` | The port to serve the PreForm API on. Required. | | `--scene-cache-size ` | The maximum number of scenes the server keeps. When the limit is exceeded, the server deletes the least recently used scene. The default is 30, and the maximum is 10000. | | `--export-support-bundle` | Bundle the structured diagnostic logs and text logs into one archive for Formlabs support, then exit. | | `-v`, `--version` | Print the version. | | `-h`, `--help` | Print the options. | ## Start the server Start the server on port 44388: **macOS:** ```bash /Applications/PreFormServer.app/Contents/MacOS/PreFormServer --port 44388 ``` **Windows:** ```powershell C:\PreFormServer\PreFormServer.exe --port 44388 ``` The server prints `READY FOR INPUT` when it accepts requests. The SDK's `launch()` method passes `--port` and waits for that line. ## Environment variables PreForm Server reads the following variable: | Variable | Effect | | --- | --- | | `DISABLE_PREFORMSERVER_TELEMETRY` | Set to `1` to turn off telemetry. The server reports the telemetry state on its first line of output. | To pass variables to a server the SDK starts, use the `env` argument of `launch()`. ## What's next - [Headless PreForm Server](https://developer.formlabs.com/guides/headless-preform-server.md) # For coding agents Coding agents such as Claude Code, Cursor, and GitHub Copilot write better PreForm automations when they read these docs as text. Every page on this site is published in forms an agent reads directly, generated from the same source as the pages at build time. ## Machine-readable entry points | Path | What it holds | | --- | --- | | [`/llms.txt`](https://developer.formlabs.com/llms.txt) | An index of every page with a one-line summary, the points to know before writing code against the SDK, and the link to the specification, following [llmstxt.org](https://llmstxt.org) | | [`/llms-full.txt`](https://developer.formlabs.com/llms-full.txt) | Every guide and example page in one Markdown file, for an agent that reads the whole site into its context | | A page's URL with `.md` for its trailing slash | That page as Markdown, with its code samples inlined and its links pointing at other pages' Markdown copies. This page is [`/resources/for-coding-agents.md`](https://developer.formlabs.com/resources/for-coding-agents.md) | | An API reference page's Markdown copy, such as [`/api-reference/scenes.md`](https://developer.formlabs.com/api-reference/scenes.md) | A list of that page's endpoints, each with its method and path, its SDK method, and a link to its own Markdown copy, such as `/api-reference/scenes/create-scene.md`. Each schema has one too, such as `/api-reference/schemas/scene.md`, so an agent fetches the one endpoint it needs | | [`/reference/openapi-v1.yml`](https://developer.formlabs.com/reference/openapi-v1.yml) | The PreForm API v1 specification, OpenAPI 3 in YAML | The API reference pages are generated from the specification, and so is the Python SDK's client, so what the specification describes is what the SDK does. Each SDK method is an operationId from the specification in snake_case. Every HTML page also declares its Markdown copy with ``. ## Point your agent at the docs Add these lines to the instructions file your agent reads in your project, such as `AGENTS.md`, `CLAUDE.md`, or `.cursor/rules`: ```markdown ## Formlabs PreForm automation This project uses the Formlabs Python SDK (`formlabs-sdk`, imported as `formlabs_sdk`) to drive PreForm and PreForm Server. Before writing or changing code that calls it: - Read https://developer.formlabs.com/llms.txt and follow its links to the pages you need. - Look up endpoints, fields, and error codes in https://developer.formlabs.com/reference/openapi-v1.yml or the API reference's Markdown pages. Don't guess method or field names. - Start from the Python sample on the endpoint's reference page. ``` An agent that loads a whole site before it starts takes [`llms-full.txt`](https://developer.formlabs.com/llms-full.txt) instead. It holds the guides and the examples; the endpoints are in the API reference's Markdown pages and the specification. ## What's next - [API reference](https://developer.formlabs.com/api-reference.md) - [Install the Developer Kit](https://developer.formlabs.com/getting-started/install.md)