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

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:
@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:
@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:
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.
pip install fastapi uvicorn jinja2 python-multipart numpy trimesh
python app.pyuv run app.pyOpen http://localhost:8323 in your browser.