Metadata-Version: 2.5
Name: lamina3d
Version: 0.2.4
Summary: Lamina: free Slicer for Fusion 360 alternative. Turns 3D models into flat cut parts (stacked, interlocked, radial, ribbed, folded panels) for laser, CNC, plasma and vinyl cutters
Project-URL: Homepage, https://marcelfarres.github.io/lamina/
Project-URL: Source, https://github.com/marcelfarres/lamina
Project-URL: Try it in your browser, https://marcelfarres.github.io/lamina/app/?ref=pypi
Project-URL: Changelog, https://github.com/marcelfarres/lamina/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/marcelfarres/lamina/issues
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: 123d make,cnc,dxf,laser cutting,nesting,papercraft,pepakura,plasma cutting,sculpture,sliceforms,slicer for fusion 360,stl,svg,unfold
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: End Users/Desktop
Classifier: Intended Audience :: Manufacturing
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Artistic Software
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.11
Requires-Dist: ezdxf
Requires-Dist: fast-simplification
Requires-Dist: fastapi
Requires-Dist: lxml>=6.1.3
Requires-Dist: manifold3d
Requires-Dist: numpy
Requires-Dist: pillow
Requires-Dist: python-multipart
Requires-Dist: rectpack
Requires-Dist: reportlab
Requires-Dist: rtree
Requires-Dist: scikit-image
Requires-Dist: scipy
Requires-Dist: shapely>=2.0
Requires-Dist: trimesh>=4.4
Requires-Dist: uvicorn
Provides-Extra: cad
Requires-Dist: cadquery; extra == 'cad'
Description-Content-Type: text/markdown

# Lamina

[![test](https://github.com/marcelfarres/lamina/actions/workflows/test.yml/badge.svg)](https://github.com/marcelfarres/lamina/actions/workflows/test.yml)
[![docker](https://github.com/marcelfarres/lamina/actions/workflows/docker.yml/badge.svg)](https://github.com/marcelfarres/lamina/actions/workflows/docker.yml)
[![pages](https://github.com/marcelfarres/lamina/actions/workflows/pages.yml/badge.svg)](https://marcelfarres.github.io/lamina/app/)
[![status](https://img.shields.io/badge/status-early%20alpha-f5b642)](https://github.com/marcelfarres/lamina/blob/v0.2.4/docs/roadmap.md)
[![python](https://img.shields.io/badge/python-3.11%2B-4f8cff)](https://www.python.org/downloads/)
[![image](https://img.shields.io/badge/ghcr.io-lamina%3Alatest-4f8cff?logo=docker&logoColor=white)](https://github.com/marcelfarres/lamina/pkgs/container/lamina)
[![licence](https://img.shields.io/badge/licence-AGPL--3.0-5fcf80)](https://github.com/marcelfarres/lamina/blob/v0.2.4/LICENSE)
[![ko-fi](https://img.shields.io/badge/Ko--fi-support-ff5e5b?logo=kofi&logoColor=white)](https://ko-fi.com/marcelfarres)

**Turn a 3D model into flat parts you can cut, and check that they go back together.**

Lamina is a free replacement for Autodesk's discontinued *Slicer for Fusion 360* (and *123D Make* before it). Load a model, pick a construction
technique, and it computes every part, checks the assembly as a physical object (and fixes or explains what does
not work), nests the parts on your sheets and exports cut files for a laser, CNC router, plasma table or vinyl
cutter. It runs on your own machine; your models never leave it.

**A quick word about the word "slicer"**, because it trips almost everyone up: this is not the 3D-printing kind.
Lamina works out the flat pieces — layers, stacked slices, egg-crate slots, ribs, or a whole surface unfolded flat —
that you cut from plywood, acrylic, cardboard, paper or steel and then put back together into the shape.

## ▶ [Try it in your browser now](https://marcelfarres.github.io/lamina/app/?ref=github-readme)

No install, no account, nothing uploaded — the Python runs in your own browser.

[![The Lamina interface: parameters on the left, a 3D preview of a radially sliced head, cut sheets below](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/hero.jpg)](https://marcelfarres.github.io/lamina/app/?ref=github-readme)

**[Landing page with screenshots and clips](https://marcelfarres.github.io/lamina/?ref=github-readme)** ·
[parity with the original, option by option](https://github.com/marcelfarres/lamina/blob/v0.2.4/docs/roadmap.md) · [how the unfolding works](https://github.com/marcelfarres/lamina/blob/v0.2.4/docs/folded-panels.md)

## The five ways to build a model

Each one is a clip on the [landing page](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques);
the stills below are the same scenes.

| | |
|---|---|
| [![Stacked: parallel slices with dowels](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/stacked_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques)<br>**Stacked** — parallel sections, touching or spaced, held by dowels, pegs or spacers | [![Interlocked: two slotted families forming an egg-crate](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/interlocked_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques)<br>**Interlocked** — two slotted families, egg-crate |
| [![Radial: half-slices fanned around an axis with locking rings](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/radial_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques)<br>**Radial** — half-slices around an axis, locked by rings | [![Curve: ribs following a curve through the model](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/curve_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques)<br>**Curve** — ribs that follow a bend, not the grid |
| [![Folded: a surface unfolded into flat panels with score lines](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/folded_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#folded)<br>**Folded** — the surface unfolded flat, papercraft nets as Pepakura makes them, with 13 joint types | [![A cube net cut with the tongue joint](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/joint_tongue.png)](https://marcelfarres.github.io/lamina/?ref=github-readme#folded)<br>**Thirteen joints** — seam, tab, gear, tongue, puzzle, laced… |
| [![Radial with an axis through each ball of a dumbbell, at right angles to each other, and the plane between them at the neck](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/axes_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#techniques)<br>**Radial, an axis per lobe** — a fan per ball, one spine through all of them | [![The plane between two lobes of the snowman selected and tilted](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/lobes_poster.jpg)](https://marcelfarres.github.io/lamina/?ref=github-readme#editing)<br>**Where lobes meet** — a plane you select, move and tilt like any slice |

## What it checks, and what you get

| | |
|---|---|
| ![The checks panel listing errors and one-click fixes](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/checks.jpg) | ![Two cut sheets with nested, labelled parts](https://raw.githubusercontent.com/marcelfarres/lamina/v0.2.4/docs/media/sheets.jpg) |
| **Before you cut**, every part is checked as a physical object: too thin, severed by a slot, held by nothing, larger than your sheet — most with a one-click fix. | **Nesting and cut files**: parts packed against each other's outlines, engraved labels, and SVG, DXF, PDF or EPS with the usual cut / score / engrave layers. |

*(GitHub renders stills only. The moving versions of all of these are on the
[landing page](https://marcelfarres.github.io/lamina/?ref=github-readme).)*

## Why it exists

I built sculptures with Autodesk's Slicer for Fusion 360 for years. It is discontinued, it was hard to use, it
is old, and it never had the parameters I actually needed — I kept wanting to do more than it allowed. I looked
for something with the same possibilities and found nothing, and nothing open source that I could improve myself.
So I started from the use cases I had, every project I had actually made or wanted to make with it, and built
those. So far it has been a pleasure to use: projects that sat in my backlog for years are getting made because
the part between the model and the cut files no longer fights back.

I hope it is useful to you too. If you make something with it, share it — **show what you made** on the Export
tab or the landing page sends a photo by email (or the
[gallery issue form](https://github.com/marcelfarres/lamina/issues/new?template=made.yml)), and with your OK it
goes in the [gallery](https://marcelfarres.github.io/lamina/#made), credited as you like. Or post it on Instagram
or X with **#lamina3d**.

**About how it was made.** A large part of this code was written with AI assistance, directed and reviewed by
me. I am conscious of what that costs, in energy and in its wider effects on people and nature, and I try to use
it deliberately. What it made possible is that a tool I had wanted for a long time exists, works well, is open
source under the AGPL so nobody can close it, and can put a little more art, beauty and playfulness into the
world. That trade felt worth making; you are welcome to disagree, and to make it better.

## Status: early days — and this is where you come in

Lamina is very new. It is an alpha, written by one person, and it will do things you do not expect yet. Every
construction technique of the original is in and the physical checks go further than it ever did, but there are
certainly bugs in here that I have not met.

**Please go and find them.** If something breaks, comes out wrong, or simply annoys you, I would love to hear about
it — every report makes the next person's cut come out better, and it is the quickest way this gets genuinely good.
Nothing is too small to mention.

One habit worth keeping while it is this young: read the checks, and test-cut using the calibration bar on every
sheet before you commit expensive material.

**Found a bug?** There are two ways to say so, and neither is better than the other.

- **report a problem**, in the app's header and on the landing page. Write a sentence and it fills in the rest:
  the technique, every setting, what the checks said, and — unless you untick the box — the model itself, saved
  ready to attach, which is what makes a report reproducible. It shows you exactly what it has gathered, then
  opens either a prefilled email to `lamina.3d.app@gmail.com` or the prefilled GitHub issue form, and you press
  send. **Email needs no account anywhere**; an issue is public, an email is not.
- **[The issue form](https://github.com/marcelfarres/lamina/issues/new?template=bug_report.yml)**, filled in by
  hand. It asks for the model and the settings that reproduce the problem; the quickest way to give both is
  **save project** in the UI, which writes one file with the model embedded and every parameter in it — the same
  file the button attaches.

**A machine or material that cuts well for you?** Each has a **share** button beside it on the Sheet & fit tab
that sends that one thing the same two ways (email, or the
[machine or material issue form](https://github.com/marcelfarres/lamina/issues/new?template=preset.yml)): a
machine's kerf, slot offset, corner relief, tool diameter and bed; a material's thickness and sheet sizes. Nothing
else travels. A machine that is not on the list: set the numbers, press **+** beside the machine to keep it under
its own name, then share it. They are added to the built-in lists by hand, so the next person with the same one
starts from your numbers.

## Quick start

**Never used a terminal? You do not need one.**
[Download the ZIP](https://github.com/marcelfarres/lamina/archive/refs/heads/main.zip), unzip it, and double-click
the starter for your computer in the folder that appears:

| your computer | double-click this |
|---|---|
| Windows | `Start Lamina (Windows).bat` — SmartScreen may ask: **More info → Run anyway** |
| macOS | `Start Lamina (Mac).command` — if it refuses: right-click → **Open** → **Open** |
| Linux | `Start Lamina (Linux).sh` — mark it executable and choose "Run in Terminal" |

It asks once whether it may install [uv](https://docs.astral.sh/uv/) (a small tool that fetches Python and the
libraries into your own user folder), downloads them — a few minutes, once — and opens
<http://localhost:8000>. Leave the text window open while you work; closing it stops Lamina. Every start checks for
a new release and installs it ([Updating](#updating)). There is no signed
`.dmg`/`.exe` installer: Apple and Microsoft both charge yearly for the signature a free tool would need, so the
starters are the same convenience without it.

**From a terminal** — [uv](https://docs.astral.sh/uv/) fetches Python and everything else; the starters run exactly
this:

```bash
uv tool install lamina3d     # "lamina3d[cad]" adds STEP / BREP import
lamina3d                     # opens http://localhost:8000 (--port to change it)
```

If the shell says `lamina3d` is not found, uv's tool folder is not on your PATH yet: run `uv tool update-shell` and
open a new terminal, or start it with `uv tool run lamina3d`.

**To work on Lamina itself**, from a clone:

```bash
git clone https://github.com/marcelfarres/lamina.git
cd lamina
uv sync                                          # add --extra cad for STEP / BREP import
uv run uvicorn web.app:app --reload --port 8000  # then open http://localhost:8000
```

### Or run it as a server

The image on GitHub Container Registry carries everything; the machine that runs it never needs the source.

```bash
docker run -d -p 8000:8000 -v lamina-jobs:/app/working-files ghcr.io/marcelfarres/lamina:latest
```

### Updating

The browser version is always the newest. A local copy moves only to a tagged release, whose package is installed
and made to slice before it is published:

| how you run it | how it updates |
|---|---|
| the double-click starter | by itself: every start runs `uv tool install --upgrade lamina3d`; offline it starts the version you have |
| `uv tool install` | `uv tool upgrade lamina3d` |
| Docker | `docker pull ghcr.io/marcelfarres/lamina:latest`, then start it again (`docker compose pull && docker compose up -d`) |
| a clone | `git pull && uv sync` |

The what's-new dialog opens by itself on the first start after an update. An older starter (one that says `uv sync` inside) never updates
— download the ZIP once more and use the new one; your jobs are in the old folder's `working-files/`.

**No server at all:** the same page runs its Python in a web worker (Pyodide) when nothing answers `api/`. That is
the public demo, at [marcelfarres.github.io/lamina/app](https://marcelfarres.github.io/lamina/app/?ref=github-readme) and on
[Hugging Face](https://marcelfarres-lamina.static.hf.space/app/index.html). [deploy/build_site.py](https://github.com/marcelfarres/lamina/blob/v0.2.4/deploy/build_site.py)
assembles that site — landing page, app, core + web zipped, the bundled models, the pure-Python packages pinned to
`uv.lock` — and the `pages` workflow publishes it to GitHub Pages, and to the Hugging Face static Space when the
`HF_TOKEN` secret is set, on every push to `main`; the deploy only goes out after
[tests/test_e2e_site.py](https://github.com/marcelfarres/lamina/blob/v0.2.4/tests/test_e2e_site.py) has loaded the built site in a real browser and sliced with every
technique. The first visit downloads about 60 MB of Python (cached afterwards, then it starts in a few seconds);
slicing runs at roughly native speed and your models never leave the browser. What differs from the local version:
STEP import is not available, folded panels are decimated by vertex clustering instead of quadric decimation (a
little more angular), and the 3D-print labels are cut through rather than engraved. Visit counting: **none at all in
the copy you self-host or run from Docker** — there is no script and no endpoint in it. The published site counts
visits with GoatCounter (no cookies, no IP stored, so no consent banner) when the `COUNT_URL` repository variable is
set: the landing page records the visit, the referrer, which link was clicked and which clips played, and the demo
records that it was opened. What someone *does* in the demo — technique, whether the model came from the examples or
their own computer, which formats they export — is sent while the box at the foot of its Model tab is ticked. It is
ticked by default and unticking it switches this off for good on that browser.

### How long a slice takes

The browser version is the whole program, compiled to WebAssembly and running on your own CPU, so it is a little
slower than the local version rather than a lot. Neither one makes you wait in the dark: **your model appears in the
3D view as soon as the build has finished with the mesh** — seconds in, while the slices are still being cut — and
the overlay lists the stages of the slice with the one it is on, `step 5 of 9 · sectioning · 12 of 34 · 46 %`, so a
long wait has a shape. A second pass (auto-fix adding crossing slices) says so and keeps the bar moving forward
instead of dropping back to 20 %. After twenty seconds the browser version also says, in words, that the local one
is faster. Measured on the bundled examples at their presets (one core of a 2024 laptop, Chrome; the local column
is `uv run`, the Docker image is the same):

| example | technique | faces | parts | local | browser |
|---|---|---|---|---|---|
| head_igea (scan, remeshed and smoothed) | radial 14 × 6 | 281 k | 34 | 12 s | 24 s |
| bunny (scan) | stacked, random dowels | 187 k | 64 | 13 s | 31 s |
| cow_spot (scan) | stacked, dowels | 89 k | 67 | 7 s | 11 s |
| pear (scan) | radial 10 × 5 | 71 k | 25 | 4 s | 6 s |
| snowman | radial 10 × 6 | 14 k | 28 | 5 s | ~8 s |
| everything else (synthetic shapes) | any | ≤ 20 k | ≤ 36 | 0.1 – 4 s | 0.2 – 7 s |

What costs time: the face count of the model after Modify Form (a fine `shrinkwrap` pitch on a big model makes a
large mesh), the number of slices that cross each other (every crossing pair is a ray cast and an insertion check),
and `smooth` passes. Every slice logs its seconds per stage to the browser console (`lamina timing`), and the
local server also appends them to `working-files/timing.log`. If a slice takes minutes in the
browser, it is a model well beyond these: run the local version, or reduce the model first (a coarser
`shrinkwrap`, a decimated mesh).

[docker-compose.yml](https://github.com/marcelfarres/lamina/blob/v0.2.4/docker-compose.yml) is the same thing as a stack for Portainer / Dockge / Komodo: paste it,
and `docker compose pull && docker compose up -d` moves it to the newest build. Every push to `main` publishes
`:latest`, a `v*` tag publishes that version, and jobs live in the `/app/working-files` volume.

Several people can share one server: each browser tab holds a random id, everything it slices lives under that id,
and no other tab can name it. A tab nobody has touched for `LAMINA_TTL_HOURS` (24 by default, 0 = never) is deleted,
models and all, the next time anyone slices; the header's **clear my data** button deletes a tab's own at once.
Uploads are not capped by default — your own computer's memory is the only limit, and a cap that refused a 40 MB
mesh on a laptop with 32 GB was simply wrong. Set `LAMINA_MAX_UPLOAD_MB` when the server faces other people, where a
stranger's 2 GB scan is not yours to hold. `WEB_CONCURRENCY` is how many slices run at the same
time (one uvicorn worker each, about 300 MB). Lamina still has no login of its own, so put it behind your reverse
proxy's authentication if the server is reachable from the internet.

The Model tab opens on a bundled example; [examples/](https://github.com/marcelfarres/lamina/blob/v0.2.4/examples/README.md) holds 24 of them, and each opens on the
technique and parameters that suit it ([examples/presets.json](https://github.com/marcelfarres/lamina/blob/v0.2.4/examples/presets.json) — the shape standing the right
way up, sliced without an error; a test keeps that true). The same pipeline runs headless, so it can be scripted:

```bash
uv run python -m core.plan examples/egg.stl --mode interlocked --set nx=5 ny=4 thickness=3 --out out/egg
uv run python -m core.export out/egg.json --out out/egg_cut --fmt svg dxf pdf eps --labels
uv run python -m core.export out/egg.json --out out/egg_pieces --per-piece                  # one file per part
uv run python -m core.solid out/egg.json --out out/egg.stl                                  # assembled solid; --part X-3 for one part
uv run python -m core.solid out/egg.json --proto out/egg_proto --scale 0.5 --labels groove  # scaled 3D-print prototype set
uv run python -m core.plan examples/egg.stl --mode folded --list-params                     # every parameter with its help text
```

## What it does

### Five construction techniques

The original's six techniques as five modes: its *3D Slices* is stacked with `surface=outer`.

| mode | what you get | parts |
|---|---|---|
| `stacked` | parallel sections, touching or with an empty `space`; outline `mid` / `outer` (Slicer's 3D Slices: sand down to shape) / `inner`; connected by **dowels** (6 hole shapes), flat **pegs**, or tabbed **spacers** that keep the space; points aligned, random per pair, or along your 3D lines | `Z-n`, connectors `P-n` |
| `interlocked` | two slotted families, egg-crate; notch ratio / flare / relief; grid rotation; extra slices at chosen positions | `X-n`, `Y-n` |
| `radial` | half-slices around an axis (move it with `center`, turn the fan with `angle`) locked by horizontal ring slices with radial slots; ring count or spacing. **Several axes**: a fan per lobe — an axis through each of a dumbbell's balls, one per ball of a snowman, tilted to follow it. Neighbouring lobes are parted by a flat plane between their axes — a translucent part in the 3D view that you click and move or tilt like any slice — so the fans never run into each other, and a **spine** — one full plane through every axis, cut whole and slotted into every ring — ties the lobes together: automatic, found from how the axes lie (on one line, any number; on one plane, exactly one; on no plane, an error naming the fix). Every axis is drawn in the 3D view where it passes through the model: drag it whole or by either end (it stays on the plane the other axes share, so the spine survives the drag), alt-click the model for a parallel axis through that point, `+` to split one in two, alt-click an axis to remove it | `R-na/b` (`R2-na/b` per axis), rings `Z-n`, spines `SP-n` |
| `curve` | ribs perpendicular to a curve through the model (its centre line by default, following the body across the plane too; drag the blue dots, alt-click to add or remove one, or type them in the `curve` table), so they follow a bend instead of staying parallel; locked together by spine slices. The turn between neighbouring ribs is limited so they do not meet inside the model. **Branches**: a straight line per leg, arm or tail, each with ribs square to it and a spine of its own that reaches back into the body and slots into the body's ribs, so a horse's legs are held instead of cut across by body ribs. Shift+alt-click a hoof to add one, drag its ends, alt-click it to remove it; a branch that starts past the body's ribs is an error, not a loose leg | `R-n`, spines `K-n`; branch 2: ribs `R2-n`, spine `K2`, joint `J-2` |
| `folded` | surface unfolded into flat panels with score lines (strategies flat / strip / area, or auto); thirteen joints: seam, tab, multitab, diamond, ticked, gear, tongue, puzzle, rivet, laced, loops, strip, rib, each sized to its triangle; **separate** mode cuts every triangle alone. The model need not be closed: panels are cut from the surface, so a clothing pattern, a mask or a shell with a neck hole is panelled as it is — its boundary edges stay open and carry no joints | `P-n`, strips `S-n`, ribs `R-n` |

Common to all: model `rotate` (three angles) and slicing `center`, per-slice `offset` / `tilt` / `roll` / `thick` /
`skip` (delete), `size` (a target size on any axis) with `scale` multiplying it, `up_axis`, Modify Form (`shrinkwrap`,
`hollow`, `thicken`, `round`, `smooth`), kerf, slot offset, and splitting of oversize parts with puzzle tabs.

### Checks and fixes

Besides per-part checks (too small, too thin, severed by slots, holes near the outline, overlapping cuts), every plan
is checked as an **assembly**: each region of each slice must reach the main body through a slot, a connector or
glued contact; a head held only by slices that never touch the body is reported as a separate group. `autofix=add`
(the default) holds regions nothing holds the way the technique holds things — crossing slices placed where their own
section reaches the rest of the assembly, rings, spines — up to two rounds, and lists everything it did. It never
deletes a piece of the model: what still cannot be held stays on the plan with its error. (Left out, and said so: a
shaving a slot cuts off its own part and a speck where a slice only grazes the surface, both thinner than the minimum
wall everywhere — too small to cut or handle.) Every remaining error and most warnings carry one-click fixes: add a slice
through the region, move it, delete the group, round the model, change the notch ratio, split the part.

A coverage figure says how much of the model's surface the parts represent, so a leg or an ear that no slice reaches
does not slip through. It carries the most signal for stacked and folded work; dense slicings score close to 100 %.

### Cut sheets and export

- **Nesting** with no-fit polygons, after [Deepnest](https://github.com/Jack000/Deepnest): largest part first, each
  tried at its minimum-rectangle angle plus quarter turns and placed at the lowest, leftmost point where it clears
  every placed part, so a part slides into a placed part's cavity or hole (a disc into a ring, a small radial
  section into a big one). Above 150 parts a rectangle packer takes over; measured on jobs of that size it packs the
  same and runs far faster. Usage per sheet is shown.
- **One thickness per sheet**: a part whose thickness you changed is cut from another piece of material, so it is
  nested on sheets of its own. Every sheet title says the stock it is cut from, the sheet header in the UI repeats
  it, and as soon as a job mixes thicknesses the file names carry it (`sheet2 6mm.svg`, `Z-2 6mm x1.svg`) — a sheet cut
  from the wrong stock fits nothing. Every file name also carries the material it is cut from (`sheet1 plywood.svg`).
- **One sheet** ignores the sheet height: every part goes on one strip as wide as the sheet and as long as it needs,
  to be cut apart at the machine.
- **Labels** are engraved beside the part with a leader line wherever there is room; only a fully crowded sheet puts
  one on the part.
- **An assembly key** comes with the cut files (`assembly-key.txt`): what this technique's labels mean — `R-2a` is the
  *a* half of the second radial plane, `Z-3` the third ring up — then every part with its centre, what it slots onto,
  in the order the 3D view builds them. The same legend is shown in the Export tab, so the scheme is clear before the
  cut rather than guessed after it. A tickbox leaves the file out of the zip.
- **Puzzle mode** (`label_style=code`) engraves a two-letter code instead of the position, and names the per-piece
  files after it, so nothing on the parts says where they go. The codes are seeded by the job, so re-slicing engraves
  the same ones; the app, the Parts table and the 3D view keep the real labels — they are the plans you consult when
  you give up — and the key is the only way back. Leave it out of the zip and the puzzle stays a puzzle.
- **An assembly video**: the steps slider played and recorded off the 3D view as a `.webm`, made in the page itself —
  nothing uploaded, nothing rendered twice.
- **Cut files**: SVG (opens directly in LightBurn, the Glowforge app and xTool Creative Space) and DXF in mm / cm /
  in, multi-page PDF, EPS per sheet, or one file per piece, each with or without the red label layer (one switch for
  every file). Layers follow Slicer's Cut Layout: OUTER blue, INNER green (slots, holes), SCORE yellow (folds: solid mountain, dashed valley, dotted perforate), LABEL red (labels, leaders,
  seam numbers, sheet border). The zip is named `<project or model>_v<revision>_<technique>_…`, and so is every file
  in it: they all start with the design, `egg_v1.0 sheet1 plywood.svg`, so the cut files of two designs unzipped
  into one folder never collide or mix.
- **Identical pieces once**: in per-piece mode a shape that occurs several times is exported one time, with the
  quantity last in the file name and on the label. A per-piece file is named design, part, material, thickness,
  quantity, in that order and separated by spaces, and it always ends in its quantity, `x1` included
  (`egg_v1.0 Z-1 plywood x4.svg`, `egg_v1.0 Z-2 plywood x1.svg`); a `cut-list.txt` says which parts each file
  covers and gives each part's width × height in your units.
- **The model's name on every part**: each engraved label starts with it (`bunny Z-3`), so parts of two models cut
  together are never mixed up. The project's name when it has one, else the model's file name; room for it is kept
  when the parts are nested, and `label_model` turns it off.
- **Weight**: the Export tab weighs the parts as cut (holes and slots taken out, each part at its own thickness, one
  line per stock when a job mixes them), the sheets they are nested on and the dowels, from the material's density. Every
  material on the list has a typical density you can correct, and a material of your own (+ beside the material)
  keeps the density you give it.
- **Scale check**: every export carries a calibration bar, 10 cm or 4 in following the units, to cut first and
  measure before committing to the whole set.
- **Kerf compensation is off by default**: many machines compensate their own kerf, and compensating twice ruins
  the fit. Turn it on under Machine & cut compensation when yours does not.
- **Solids**: STL of the assembled model or one part, and a **prototyping set** for test-printing a design: every part
  flat at a scale or target size, thickness raised to a printable minimum, label engraved as a groove or cut through,
  as one STL per part plus a 3MF that OrcaSlicer or PrusaSlicer opens as separate named objects. The label is bold,
  at least the letter height you ask for in printed millimetres (5 mm by default, whatever the scale), and sits
  where the part is widest, away from slots and edges; a part with no room for it gets none and README.txt in the
  zip names it.
- **Fit tests** before the model: five small assemblies with the job's joints (its thickness, connectors, and every
  radial half-slice, since that is what wedges) at slot offsets of ±1, ±2 steps around the current one, each part
  marked with its offset. Cut or print, assemble: the loosest that still holds is the offset to set. There are two,
  because a fit is a clearance in millimetres, not a ratio, and belongs to a process and a material: the **cut-file
  fit test** (Downloads) is at 1:1 for the real machine and predicts the model's fit; the **print fit test**
  (Prototyping) dials in the print's own **print slot offset**, in printed millimetres whatever the scale, which the
  prototype set uses in place of the job's.
- **Every zip carries the project file** (`….lamina.json`) that the UI's *open project* reads back: technique, every
  parameter and the uploaded model itself, so a printed part or a cut file found later leads back to what made it.

### The web UI

Dark, five tabs (Model · Technique · Sheet & fit · Checks · Export). Every change re-slices; the 3D view and the cut
sheets are always the same plan. Hover a parameter name for its help.

- **3D view**: drag to orbit, wheel to zoom, right-drag to pan. Click a part to select it: a yellow arrow slides it
  along its normal, shift-drag tilts, ctrl-drag rolls, and the panel gets sliders for offset / tilt / roll /
  thickness and a delete button. Only those move it: a plain drag on a selected part turns the view, and
  Ctrl+Z or the ↶ undo button takes back the last 60 changes. The selected part is highlighted on the cut sheet,
  clicking a part on the sheet selects it in 3D, and clicking a warning does the same. Alt-click adds or removes a
  dowel (stacked), a curve control point (curve) or a radial axis; shift+alt-click a hoof, hand or tail tip adds a
  curve branch out to it. `explode` and `steps` sliders animate the
  assembly; `look` previews the material.
- **Dowels in a stack** (`placement`): **aligned** puts the same points straight through every layer, one rod each;
  alt-click a layer to add one there, alt-click it to remove it, or type exact x, y under `dowels` — that is how you
  move one. **random** picks new points for every pair of layers, spread as far apart as the pair allows; they
  cannot be placed by hand. **lines** are straight dowels you draw: a start and an end in the model's x, y, z (0, 0, 0
  is its middle), every pair of layers the line passes through gets a hole. `+ line` draws one up the middle, and
  alt-click draws one up through the point you click (again on it to remove it). Two lines hold a layer; one lets it
  turn. A layer nothing joins is never deleted — the Checks tab says what would hold it.
- **Model**: quarter-turn buttons and three angles to re-orient it, scale or target size, and Modify Form: shrinkwrap,
  hollow, thicken, round (drops features thinner than a radius) and smooth (Taubin passes for pointy vertices).
- **Material, machine, sheet**: 21 materials — paper, card, cardboard, greyboard, plywood, MDF, basswood, balsa,
  acrylic, polypropylene, foam board, EVA foam, steel, stainless steel, aluminium, brass, copper, leather, fabric, and
  PLA and PETG to 3D-print the parts at full size (whole layers for thicknesses, print beds for sheets: picking one
  ticks **3D printed**, the page counts plates and the Export tab offers one 3MF per plate instead of cut files). The
  material sets its stock thicknesses (sheet steel by Manufacturers' Standard Gauge and stainless by its own gauge
  table, copper by ounces per square foot, chipboard by points, plate, plywood, acrylic and model wood by fraction of
  an inch, each shown in mm and decimal inches), the sheet sizes it comes in (4 × 8 and 5 × 10 ft, A-series, balsa
  strips, foam board sheets, laser beds), its density and the 3D look; only machines that can cut it are offered.
  The machine (CO2 laser, fiber laser, plasma, CNC router, knife, by hand, 3D printer, or one listed by name)
  sets kerf, slot offset and corner relief. Saved manufacturing presets keep all of it.
- **View cube**: the cube in the corner of the 3D view turns with the camera; click a face for a flat top, front or
  side view.
- **Session and projects**: the session belongs to the browser tab, so a refresh resumes where you were; a second tab
  is a second session, and on a shared server nobody sees anyone else's models or cut files. Project save / open
  writes one file with the model embedded. Units mm / cm / in for every length field and for the DXF.

## How it is built

```text
web/app.py  (FastAPI)  ──▶  core/plan.py  ──▶  plan.json ──▶  core/export.py   SVG/DXF/PDF/EPS (OUTER, INNER, SCORE, LABEL)
web/static/index.html         │                            ──▶  core/solid.py    assembled / per-part STL, 3MF prototype set
(three.js, live)              ▼                            ──▶  browser          3D view from the facets in plan.json
                     core/modes/*.py   one file per technique (plug-in): Slice objects with a frame + cuts (+ facets)
                     core/unfold.py    mesh → flat panels (greedy flattest-edge spanning tree, overlap-checked)
                     core/notch.py     slots where families cross (ray casts, ratio, flare, relief)
                     core/checks.py    physical checks and auto-fix
                     core/split.py     split oversize parts with puzzle tabs      core/nest.py  outline nesting
                     core/geometry.py  frames, mesh sections → shapely polygons, dowel shapes
```

- **All geometry is computed in Python** (trimesh sections → shapely polygons). The browser only extrudes finished
  polygons; the STL export extrudes the same polygons with trimesh.
- **A construction technique is one file** in `core/modes/` (see `interlocked.py`, about 50 lines): declare `Param`s and
  return `Slice` objects (frame + thickness + optional clip + cuts, or a ready `raw` profile for synthetic parts).
  Sections, per-slice edits, checks, splitting, nesting, export and the web form are shared and automatic.
- Labels follow Slicer: `Axis-Slice` (`X-3`, `Z-6`), split pieces `Axis-Slice-Part` (`X-3-1`).

### Fit and cut compensation

| parameter | acts on | effect |
|---|---|---|
| `compensate`, `kerf` | every outline, at export | when on, outer edges grow by kerf/2 and slots and holes shrink by kerf/2, so the cut part comes out at nominal size; off by default because most machines compensate their own kerf |
| `slot_offset` | slot width | Slicer's "Slot Offset": nominal slot = crossing slice thickness; + looser, − press fit |
| `notch_ratio` | slot depth | where the two families meet along a crossing (0.5 = half and half) |
| `notch_factor`, `notch_angle` | slot mouth | Slicer's Notch Factor: flares the entrance so parts find the slot |
| `relief`, `tool_d` | slot corners | dog-bone / T-bone relief for router or plasma |
| `thick` / `offset` / `tilt` / `roll` | one slice | per-slice thickness, position along its normal, rotation about its in-plane axes |

Slot depth is exact per crossing: the line where two slices meet is cast against the mesh and every in-material
segment gets its own slot (a torus crossing gets two).

### Physical checks

Errors mean it will not work, warnings mean look at it. Part too small or too thin (minimum rotated rectangle) ·
thin bridge (erosion) · slots or holes sever the part · region floats (no slot, dowel or core holds it) · no crossing
slice · not connected to the main assembly (region-level connectivity through slots, connectors and glued contact) ·
plane misses material · does not fit the sheet (auto-split when `split` is on) · two slots overlap · hole too close
to the outline · folded: joint shrunk or skipped for its triangle, listed by seam number · folded: the material does
not fold at that thickness (plywood, MDF, acrylic, balsa, foam board, printed plastic, or card and metal past their
limit), with a one-click switch to faces joined by ribs · slot opens away from the insertion direction · a boxy model
a few degrees off square (the Model tab offers **square it up**). Every message says what to do about it.

## Tests

The `test` workflow runs the suite on every push and pull request; `docker` and `pages` publish from `main` only,
and `pages` deploys nothing until the built site has sliced with every technique in a real browser.

Two of them drive a real browser, because that is where the bugs people report actually live:
[tests/test_ui.py](https://github.com/marcelfarres/lamina/blob/v0.2.4/tests/test_ui.py) runs the app against a real server and does what a person does — fills every
box of every technique and checks each value reached the plan, walks the undo history end to end and back, uploads
a broken model, saves and reopens a project, downloads the cut files — and fails on any uncaught JavaScript error
or a status line that never settles. [tests/test_e2e_site.py](https://github.com/marcelfarres/lamina/blob/v0.2.4/tests/test_e2e_site.py) does the same for the
published browser build. Both need `LAMINA_UI=1` / `LAMINA_E2E=1` and Playwright's Chromium, so they stay out of
the quick run.

```bash
uv run ruff check                       # lint (pyflakes, bugbear, bandit, pyupgrade); the test workflow runs it first
uv run pytest tests -n auto             # pipeline, nesting, checks, export, the web API, the browser build's code path,
                                        # and every slider end / choice / toggle of every technique (tests/test_params.py);
                                        # -n auto spreads it over every core: about 4 min on 12, 32 in one process
uv run --with pytest-cov pytest --cov=core --cov=web --cov-report=term-missing   # coverage: 88 % of core + web
uv run python tests/browser_env.py working-files/jobs   # the whole matrix without the compiled extras Pyodide lacks
uv run --with playwright playwright install chromium
LAMINA_UI=1 uv run --with playwright pytest tests/test_ui.py   # the app itself, driven like a person: every parameter, undo, no number cut off (20 min)
python deploy/build_site.py
LAMINA_E2E=1 uv run --with playwright pytest tests/test_e2e_site.py   # the built site, end to end, in Chromium
uv run python tests/test_unfold.py      # folded-panel correctness: refold, area, seams, joints (a few minutes)
uv run python tests/run_matrix.py       # regression matrix of model × mode × feature → working-files/matrix/contact.png
uv run python -m core.testmodels examples/   # regenerate the synthetic example shapes
uv run --with playwright python scripts/media/record.py [scene …]   # re-record the landing-page stills and clips (needs node + ffmpeg)
```

The landing-page media are re-recorded on every release tag by `.github/workflows/media.yml` (or by hand from the
Actions tab), and committed back to main, so they always show the version people download.

## Folder layout

```text
core/        planner, modes, unfold, notch, checks, nest, split, export, solid
web/         FastAPI app + single-page UI (vendored three.js)
examples/    24 test models: 18 synthetic + a scanned head, a porcelain horse statue, three animals and the bunny (terms in examples/README.md)
docs/        index.html + media/ (the GitHub Pages site), roadmap.md, folded-panels.md, original-slicer-reference.md
tests/       pytest suite, test_ui.py (the app in a browser), test_unfold.py, run_matrix.py
```

### Serving the docs

[The landing page](https://marcelfarres.github.io/lamina/) is `docs/`, served by GitHub Pages from `main` — plain
HTML with no build step, so any static server shows exactly what Pages will:

```bash
uv run python -m http.server 8080 --directory docs   # then open http://localhost:8080
```

The `.md` files next to it (`roadmap.md`, `folded-panels.md`) are read on GitHub rather than through the page.

`deploy/build_site.py` assembles what actually gets published — the landing page plus the app running its Python
in the browser. Its one optional repository variable, set in the `pages` workflow, is `COUNT_URL`, the GoatCounter
endpoint; unset means nothing is counted anywhere.

**report a problem** needs no configuration at all: `web/static/feedback.js` gathers the report and opens a
prefilled email or the GitHub issue form, so it behaves identically published, in Docker and offline. The address
it writes to is one constant at the top of that file; a fork changes that line.

## Contributing

Issues and pull requests are welcome. A new construction technique is one file in `core/modes/`; checks, nesting
and export come for free. Run the tests above before opening a pull request, and say which example and settings
you checked the change on.

## Support

Lamina is free and always will be, and none of that depends on anyone giving me anything. If you like it or any of
my other projects and you want to support the work, there is [Ko-fi](https://ko-fi.com/marcelfarres) — only if you
can comfortably afford it, and with no pressure at all.

Other ways that help just as much: tell someone who cuts sheet material that this exists, show me what you made, or
open an issue when something goes wrong. A good bug report is worth more to me than a coffee.

## Licence

Lamina is free software under the [GNU Affero General Public License v3.0](https://github.com/marcelfarres/lamina/blob/v0.2.4/LICENSE). You can use it, change it and
share it. Anyone who distributes a modified version, or offers it to others as a service, must publish their
source under the same licence.

**What you make with it is yours.** The cut files, parts and objects you produce are not covered by the licence;
sell them, give them away, do as you like. Only the program itself is copyleft. The bundled example models have
their own terms, listed in [examples/README.md](https://github.com/marcelfarres/lamina/blob/v0.2.4/examples/README.md).
