Development
Requirements
The functional and non-functional requirements, constraints and known gaps are
on the Requirements page. The same behavior is written as a
formal Allium specification in specs/layerforge.allium. Check it with:
allium check specs/layerforge.allium
Pseudocode
This is the intended design. Where the code differs, see the known gaps on the Requirements page. The requirements behind the mark steps are on the Alignment requirements page.
- Load the 3D Model:
- Read an STL file to load the model into the application.
- Scale the Model:
- If a scale factor is provided, scale the model by this factor.
- If a target height is provided, calculate the necessary scale factor to achieve this height and apply it to the model, ensuring the aspect ratios are maintained.
- Slice the Model into Layers:
- Determine the positions for each slice based on the specified layer height.
- For each determined position:
- Slice the model at this position.
- Project the resulting slice to a 2D plane.
- Create a List of
Polygons representing the 2D contours of the slice.
- For each slice, process the slice:
- Calculate Reference Marks:
- Evaluate candidate points using a geometric stability metric derived from GDOP.
- Choose marks so that each piece can be aligned in exactly one way with each piece it overlaps in the layer above and below, ensuring:
- Marks are holes, chosen inside the overlap of two adjacent outlines, so each shared mark is a hole in both layers.
- A mark is reused while it stays valid in the next layer and retired when it does not.
- Shapes are chosen to remove rotational symmetry: a piece with one mark gets a shape with a direction, and two marks differ in shape.
- The whole hole lies inside the piece, clear of edges, other holes and the number.
- The size of the marks follows the sheet thickness (the layer height). It does not follow the model's scale.
- The distance between marks is large enough to fix rotation with the precision needed.
- Adjust Reference Marks:
- Adjust the positions of the reference marks to avoid overlaps, using the ReferenceMarkAdjuster.
- Check alignment:
- Before any file is written, check that every piece can be aligned in exactly one way. If not, stop with an error that names the slice and piece.
- Generate SVG File:
- Draw the slice contours and the reference marks as cut lines.
- Engrave the slice number inside each piece, clear of the marks.
- Calculate Reference Marks:
- Output:
- Save the generated SVG files to the specified output directory, with each file representing a slice of the original 3D model.
Expected Workflow
- Build a :class:
Modelusing :class:ModelFactoryand the desired mesh loader. - Call :meth:
SlicerService.slice_modelto produce a list of :class:Sliceobjects. It chooses every slice's marks in one pass, withplan_marks(#63), then calls each :class:Slice'sadjust_marksto filter them to what actually fits. - Pass the processed slices to :class:
SVGGenerator(via the CLI or directly) to write SVG files.
Running the Tests
uv sync
uv run pytest
uv sync installs the runtime dependencies and the dev group (pytest and
hypothesis). Add --group docs to build the documentation with
uv run mkdocs build --strict.
Every test runs in an empty temporary directory (an autouse fixture in
tests/conftest.py), so a layerforge.toml in your checkout does not change a
result. A test that needs a file from the repo must use an absolute path.
Linting and Type Checking
uv run ruff check
uv run ruff format --check
uv run pyright
pyright runs in strict mode on src/ and standard mode on tests/ and scripts/.
CI runs all three on every pull request.
The pyright settings are the [tool.pyright] table in pyproject.toml. The command, CI
and a language server started in the repo root read it (pyright's configuration docs say
the server uses the same file). Keep it in that one place: a pyrightconfig.json would
take precedence over the table, and two files drift. The table points at the project
environment (venvPath and venv, the .venv that uv sync creates) and at src
(extraPaths), so an editor needs no extra setting beyond opening the repo root. To see
that a tool used the table, run uv run pyright --verbose: its first line reads
Loading pyproject.toml file at .... pyright accepts an unknown key with only a message
(Config contains unrecognized setting) and exit code 0, so read the output after you
edit the table.
Spec checks
scripts/check_specs.sh runs allium check on every file in specs/. It needs
allium and jq on the path. CI runs it as the specs job with a pinned allium
version and a pinned SHA-256 for the Linux tarball, and with a read-only token.
Where the allium binary comes from
Checked on 2026-09-25 (allium v3.6.1, juxt/allium-tools, and the Allium site):
- Upstream does not sign the release binaries. Its README says "not yet signed".
The release has no signature, no SBOM and no build attestation
(
gh attestation verifyreturns 404), and the workflow has no signing step. SHA256SUMS.txtin the release lists only the vsix and the LSP tarball, not the binaries. The Homebrew formula has an emptysha256 ""for both x86_64 targets.- The site's installation page says nothing about checksums or signing.
- The release workflow builds the binaries in GitHub Actions from the tagged commit, then attaches them to the release. A release asset can be replaced afterwards. A pinned hash catches that.
Decision: keep the release tarball with a pinned SHA-256, and set the pin only
after comparing the asset with the tarball its own release run built from the
tagged commit. For v3.6.1 the two matched (e00c99ae..., run 33000128435, commit
190ea5ce). This shows the pin is what upstream CI built. It does not show the
source is trustworthy, and no option here does. cargo install allium-cli --locked
would check the crate against the crates.io index, but it adds a compile step to
every run for the same trust in upstream, so it was not chosen. The job needs no
secrets and only reads the repository. The workflow sets permissions: contents: read
for all jobs, which the repository default (read) already gives.
The pin covers the binary that CI runs, the x86_64 Linux tarball. A local run uses
whatever allium is installed. On 2026-09-25 that was the Homebrew arm64 build of
the same version (3.6.1), and it printed the same diagnostic counts as CI.
Bumping allium
Do this within 90 days of the upstream release, while its build artifacts exist.
Set V to the new version. The commands assume a lightweight tag, as v3.6.1 has
(.object.type is commit). For an annotated tag, dereference it first.
V=3.6.1
tag=$(gh api repos/juxt/allium-tools/git/ref/tags/v$V --jq .object.sha)
run=$(gh run list -R juxt/allium-tools -w release-artifacts.yml -e push --status success \
--json databaseId,headSha,headBranch \
--jq "map(select(.headBranch==\"v$V\" and .headSha==\"$tag\"))[0].databaseId")
gh run download "$run" -R juxt/allium-tools -n allium-x86_64-unknown-linux-gnu -D ci
gh release download "v$V" -R juxt/allium-tools -p allium-x86_64-unknown-linux-gnu.tar.gz -D rel
shasum -a 256 ci/*.tar.gz rel/*.tar.gz # the two hashes must match
Then set ALLIUM_VERSION and ALLIUM_SHA256 in .github/workflows/tests.yaml,
upgrade the local allium, run ./scripts/check_specs.sh, and read any new
diagnostics. If the hashes differ, do not bump. Nothing notices a new release, and
being behind does not affect the check. Look at
gh release list -R juxt/allium-tools when you change a spec.
The script fails on an error diagnostic or a non-empty findings list. It does
not use the exit code, because allium check exits 1 on warnings and infos too.
These diagnostics are accepted, and the script prints their counts:
layerforge.allium: twoexternalEntity.missingSourceHintwarnings forMeshandOperator. The hint wants an import of the spec that governs the entity. trimesh and a person have no allium spec, so there is nothing to import.layerforge.allium:status.unreachableValueforSlice.planned. The value is set bySlice.created(... status: planned)inside aforloop. The checker does not see a creation inside a loop. A single creation outside the loop, as a test in a scratch copy showed, makes the warning go away.alignment.allium: unused and unreachable items that exist because the target spec has no code yet. Each goes away when its feature lands.
A new warning is not a failure, so read the counts in the log when a spec changes.
Defaults
Each default has one source in code: ReferenceMarkConfig for the marks.* keys, and
Settings for units and layer_height (settings.py reads the mark defaults from
ReferenceMarkConfig). The --help text of cli.py is built from Settings(), so it
follows a change.
tests/test_defaults_documented.py compares three copies with Settings(): the keys table
of docs/configuration.md, the config block of specs/layerforge.allium, and the
--help text. Change a default and the test fails until the table and the spec agree.
A new setting must get a row in the table, because the test also compares the keys.
Two kinds of copy are not tested:
- The prose of
docs/requirements.md(FR-2, FR-6, FR-31) and the tuning advice indocs/reference_mark_algorithm.md. Search for the old value when you change a default. - The TR-16 table of
docs/alignment_requirements.md. It holds target defaults, such as 0.1 times the mark size formarks.tolerance, that differ from today's on purpose.
ReferenceMarkConfig keeps its own ranges (ge=0, gt=0), because Python callers build it
without Settings. Settings repeats them, because it checks a file or option value before a
ReferenceMarkConfig is built.
Changelog and versions
The changelog states
the version policy. LayerForge is at 0.x, so a minor version may break users.
A pull request that changes options, defaults, exit codes, output files or public
functions adds a line under Unreleased and marks it Breaking when existing
use stops working or gives different output.
New pull requests open with a checklist from
.github/pull_request_template.md. It lists the checks and the changelog line.
Working Notes
- Run the checks so that a failure is not hidden. Do not pipe them through
tailin an&&chain, because the pipe returns the exit code oftail. Runuv run ruff formatbeforeruff checkandpyright. Trimesh.sectiontakes(plane_normal, plane_origin)when called with positional arguments. Pass both by keyword.Path3D.to_2D()without a transform re-centres every cut. Slices must use the transform inModel.calculate_slice_contoursto share one frame.- Hypothesis draws floats such as 1e-200. A hull edge that short makes GEOS divide by
zero in
boundary.distance. Round generated coordinates (issue #77). nan < 0andnan <= 0are false, so a sign check letsnanthrough. Usemath.isfinitetoo (issue #106).allium checkexits 1 on warnings, even onmain. Read itsfindingsand anyerrordiagnostics (issue #109).- After
gh pr merge,mergeablereadsUNKNOWNfor about 15 s. Fetch and check again in a separate command. trimesh.creation.extrude_polygonneeds a triangulation engine that is not installed. Tests build shapes withextrude_triangulationor the primitives intrimesh.creationinstead.- To look at an SVG, render it with
rsvg-convert -w 500 -b white in.svg -o out.png. Do not useqlmanage, which can hang. - Slice positions are the middle of each layer. A cut exactly on a face of the mesh comes out empty.
- Write a pydantic default as
Field(default=10.0, allow_inf_nan=False). With the value first, pyright strict reads the field as required and reports every call that leaves it out. click.FloatRangeacceptsnanandinf. Checkmath.isfiniteyourself, or let the strict pydantic model insettings.pydo it.gh pr create --body-fileskips.github/pull_request_template.md. Copy the checklist into the body by hand.- Run the checks in the order
ruff format,ruff check,pyright,pytest, and runpyrightafter the first edit to a model, not at the end. In #106 it reported 31 errors that pytest did not show. - zsh does not split an unquoted
$var.for a in "--x 1"; do cmd $a; donepasses one argument, and every probe then saysNo such option '--x 1'and tests nothing. Write one explicit call per case. Write scratch files with an absolute path. - click 8.5 asks for an option with
prompt=while it parses the options, so any check the command makes later comes after the question.--stl-filetherefore has noprompt=.clirunsresolve_settingsand then callsclick.prompt(#135). A parse error (an unknown option, a wrong type, a--configpath that does not exist) still comes first, and--help, which is eager, wins over every checkclimakes.CliRunnerresults have.stderrand.stdoutapart, and.outputholds both. - When a fix names one kind of bad input, probe the others of the same kind before you
close the issue. #119 moved a bad config file before the prompt, and bad option values
and the option conflict still came after it (G-28, #135). Probing the class of #135 found
two more late failures: an output folder that is a file (#144, fixed with it) and a bad
--mark-color(#145, which #83 then removed with the option). - The editor's pyright once showed errors (
No parameter named "size", an unknown import symbol) thatuv run pyrightand the CI lint job did not. They came right aftergit checkoutand scripted edits changed files outside the editor. Later the same server resolved the symbols correctly (goToDefinitiononfind_config_filegavesettings.py:120). The project venv holds no second copy of layerforge: a.pthfile points atsrc. I did not reproduce the errors, so "stale state after edits made outside the editor" is a guess. Trustuv run pyrightand the CI lint job. Path.exists()is False for a dangling symlink, butmkdirstill fails on it. Useos.path.lexistswhen a check must match what the writer does (#144, found by/code-review).- svgwrite 1.4.3, tiny profile: a
sizegiven as floats is written rounded to 4 decimals (33.1235for33.123456789), and theviewBoxin full. Pass the size as text built from the same floats, orwidthwill not equal theviewBoxwidth (#74). gh pr checks Nright aftergh pr createcan sayno checks reported. Wait a few seconds, then use--watch.-
A test that passes before the code exists proves nothing. Two CLI tests that asserted only "exit 2 and the option name" passed on click's own
No such optionmessage, so they now assert the real message. -
The intersection of two polygons can be a
GeometryCollectionof a polygon and a line when the pieces also touch along an edge elsewhere. Keep the polygon parts (shapely.get_parts) before you take an area (#89). - The tiny profile of svgwrite has no
dominant-baseline, so the number is centred bytext-anchor="middle"and a baseline shift of 0.35 x the font size. That shift and the width factor 0.6 were checked by eye only (#191). - A hypothesis property that passes on its first run proves nothing until a mutant turns it red. In #91 the first version killed 6 of 9 mutants; the survivors pointed at cases the strategy never built (a mark listed twice, a second mark on the ray of the first). Probe each survivor before you call it defensive: one of them was a real unsafe case in #90.
rotation_symmetrymerges a mark that is listed twice (TR-10). A copy weights the centroid, and the centroid is the only centre it tries.- Its cost is cubic on sets with a lot of symmetry (400 marks in a ring: 10 s) and small on sets with none (#203).
- After an edit script or a summary says a file changed, grep the file for the text. The Session F paragraph of the backlog was "done" in a summary and absent from the repo.
Common Error Messages
ModuleNotFoundError: No module named 'networkx'or'scipy'–trimeshneeds both for slicing. Both are declared dependencies, so this means the environment was not created withuv sync.