---
title: "Web SLS Packer"
description: "A web application that packs Fuse 1+ and Fuse X1 jobs from a browser, with PreForm Server behind it."
url: https://developer.formlabs.com/examples/web-sls-packer/
---

# 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.post1/examples/web-sls-packer) [Download web-sls-packer.zip](https://github.com/Formlabs/formlabs-api-python/releases/download/v1.0.0-alpha.post1/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/<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](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.
