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

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

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/<id>/, next to app.py.

Add parts#

The /jobs/<id>/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 and the app's other packages first; with uv, uv run installs them all from the example's pyproject.toml.

Terminal
pip install fastapi uvicorn jinja2 python-multipart numpy trimesh
python app.py
Terminal
uv run app.py

Open http://localhost:8323 in your browser.

Feedback & Bug Reports