Skip to content

Requirements

This page lists what LayerForge does today, derived from the code and tests as of 2026-09. It describes current behavior, not intended behavior. Where the two differ, the difference is listed under Known gaps. The intended behavior for alignment, which the tool does not yet meet, is on the Alignment requirements page.

The same behavior is written as a formal specification in specs/layerforge.allium. The rule names there match the IDs here.

Purpose

LayerForge slices a 3D mesh into horizontal layers. It writes one SVG file per layer. Each SVG has the layer outline, numbered reference marks, and a slice number. A person cuts the layers, stacks them, and uses the marks to align them. The marks are meant to be holes cut through the sheet, and the layers must be alignable in exactly one way. The tool does not check that yet (see G-4).

How to read this page

  • FR is a functional requirement. NFR is a non-functional requirement. C is a constraint.
  • Code names the module and symbol (path::symbol). Line numbers are left out because they drift.
  • Tests names the test files that cover the requirement. A dash means no test covers it directly.
  • Paths are relative to src/layerforge/ for code and tests/ for tests.

Functional requirements

Command line

ID Requirement Code Tests
FR-1 The layerforge command takes --stl-file. If it is missing, the command prompts for it, after the config file and every option have been checked (FR-30, NFR-5), so a bad option never leaves the person typing a path first. cli.py::cli test_cli, test_cli_checks_before_prompt
FR-2 --layer-height is a number greater than 0. The layer height is the thickness of the sheet. The default is 3 mm, stated in --units (FR-31), or the layer_height key of the config file (FR-30). A value of 0 or less stops the command with Invalid value for --layer-height: must be > 0 and exit code 2. So does nan or inf, with must be a finite number. The check runs before the --stl-file prompt. cli.py::resolve_settings test_process_model_validation, test_cli, test_cli_checks_before_prompt, test_settings
FR-3 --scale-factor and --target-height, when given, must be greater than 0. Both fail the same way as FR-2, and nan or inf fails too. The check runs before the --stl-file prompt. cli.py::resolve_settings test_process_model_validation, test_cli_checks_before_prompt
FR-4 --scale-factor and --target-height cannot be used together. The command prints Only one of scale_factor or target_height can be provided. and exits with code 1, whatever the values are. The conflict is reported before a bad value (FR-2, FR-3), after a bad config file (FR-30), and before the --stl-file prompt. process_model checks in the same order as the command. cli.py::resolve_settings, cli.py::cli test_cli, test_cli_checks_before_prompt
FR-5 --output-folder sets where SVG files go. The default is output. The folder and its parents are created if missing. A folder that is an existing file, or that would have to be made inside one, stops the command with Invalid value for --output-folder: <file> is not a folder and exit code 2, before the --stl-file prompt. <file> is the existing path in the way, which for a folder inside a file is the file, not the path that was typed. A dangling symlink counts. An empty or blank folder text stops it with Invalid value for --output-folder: must not be empty. A folder that cannot be written, or that would have to be made in a folder that cannot be written, stops it with Invalid value for --output-folder: cannot write to <folder>: Permission denied, where <folder> is the nearest folder that exists. A name that the system refuses, such as one over 255 characters, stops it with cannot use <folder text>: File name too long, where <folder text> is the whole text typed. Any other error while the path is looked up (permission denied on a parent, a null byte) uses the same form. If the scale and target conflict is also present, the command exits with code 1, because that check comes first; the folder is checked last. All exit with code 2 before the prompt. The check makes and removes a temporary file in the nearest folder that exists, and makes no folder, so a run that fails later leaves nothing behind. utils/file_operations.py, writers/svg_writer.py, cli.py::_check_output_folder test_file_operations, test_end_to_end, test_cli_checks_before_prompt
FR-6 Mark options: --kerf (the width of material the tool removes; default 0.3 mm, stated in --units; not negative), --mark-size (default none, which gives the size of FR-21; a number greater than 0), --mark-tolerance (default none, which is 0.1 times the mark size; not negative), --mark-min-distance (default none, which is the mark size; not negative), --available-shapes (comma-separated, default circle,square,triangle,arrow), --mark-angle (degrees, default 0.0), --cut-color (default red) and --engrave-color (default black), each any SVG colour (a name, #rgb, #rrggbb or rgb(r,g,b); an empty value, none, currentColor and a wrong keyword case are errors). The numbers must be finite. --mark-size must be greater than 0, and --kerf, --mark-tolerance and --mark-min-distance must not be negative. Shape names are trimmed and empty items dropped. The angle is converted to radians. Each of these can also come from the config file (FR-30), and a bad colour exits with code 2 and names the option or the key. The checks run before the --stl-file prompt. cli.py::resolve_settings test_cli, test_cli_checks_before_prompt
FR-30 A setting comes from the command line first, then a TOML config file, then its default. The file is --config PATH, or layerforge.toml in the current directory if it exists. Keys: units, layer_height, kerf, and in the [marks] table size, tolerance, min_distance, shapes (a list), angle (degrees), min_web_ratio, min_hole_ratio and min_hole_kerf_factor, and in the [output] table cut_color, engrave_color and hairline_width (above 0; the default is 0.01 mm, stated in --units, and it has no option). The last three have no command-line option: the web ratio is the least material between holes, as a multiple of the layer height (default 0.5, not negative), and the two hole factors give the least hole size (FR-21; defaults 1, above 0, and 1.5, not negative). The options --scale-factor, --target-height, --stl-file and --output-folder are not settings. An unknown key, a wrong type, or a value that is not finite or out of range (FR-2, FR-6) stops the command with exit code 2 and <file>: <key>: <reason>. When a file is used, the command prints Using settings from <file> to stderr once, so a stray layerforge.toml cannot change a run in silence. The file is read once. It is checked before the --stl-file prompt and before the value checks of the other options, so a bad file is reported before an option conflict (FR-4) or a bad option value. click's own errors come first, whatever order the options are typed: an unknown option, a wrong type such as --layer-height abc, a missing value, a --config path that does not exist. They also exit with code 2. --help shows the help and exits with code 0 even when the file is bad, in either order, and prints no Using settings line. It still loses to a click error: --help --bogus exits with code 2. A bad file value fails even when an option overrides it. A bad option value names the option instead. cli.py::_read_settings_file, cli.py::resolve_settings, settings.py::read_config_file, settings.py::merge_settings test_settings, test_cli, test_cli_checks_before_prompt
FR-31 --units is mm, cm or in. The default is mm, or the units key of the config file (FR-30). It states the unit of the mesh and of every length option, and it sets the unit of each SVG's width and height (FR-26). An STL file has no unit, so no number that you give is converted: with --units in, a model 30 across is 30 inches. The two defaults that depend on the material, the layer height (3 mm) and the kerf (0.3 mm), are millimetres and are stated in the unit of the run when nobody set them, so with --units in the default sheet is 0.118 inches. The mark size, minimum distance and tolerance follow the sheet, so they follow the unit too. Any other value stops the command with Invalid value for '--units': 'ft' is not one of 'mm', 'cm', 'in'. and exit code 2, before the --stl-file prompt. cli.py::cli, settings.py::merge_settings, units.py, svg/svg_generator.py test_cli_checks_before_prompt, test_settings, test_end_to_end, test_svg_output

Loading and scaling

ID Requirement Code Tests
FR-7 The mesh is read with trimesh. A file that holds more than one geometry, no geometry, or a mesh with no height is rejected with a ValueError. The command turns it into Cannot load '<file>': <reason> with exit code 1. A missing or unreadable file fails the same way. models/loading/implementations/trimesh_loader.py, models/model_factory.py test_trimesh_loader, test_model_factory, test_process_model_validation
FR-8 With --scale-factor, the mesh is scaled uniformly by that factor about the coordinate origin. models/model_factory.py::_scale_mesh test_model_factory, test_end_to_end
FR-9 With --target-height, the mesh is scaled uniformly so its height (maximum z minus minimum z) equals the target. models/model_factory.py::_scale_mesh test_model_factory, test_end_to_end
FR-10 The mesh is not moved after scaling: every slice keeps the x and y of the model, wherever the mesh lies. models/model.py::Model.calculate_slice_contours test_model_contours

Slicing

ID Requirement Code Tests
FR-11 The model height is maximum z minus minimum z of the mesh bounds. A mesh with no height is rejected when the model is created (NFR-5). models/slicing/slicer_service.py::slice_model, models/model_factory.py::create_model test_slicer_service, test_process_model_validation
FR-12 The mesh is divided from its lowest point upwards into layers of layer_height. The last layer is shorter if the height is not a multiple. There are ceil(height / layer_height) layers, at least one. Each slice is cut at the middle of its layer, so no cut lies on the bottom or top face. A mesh 10 high from z = 0 with layer height 3 gives the positions 1.5, 4.5, 7.5 and 9.5. A mesh centred on z = 0 is sliced over its whole height. models/slicing/slicer_service.py::calculate_slice_positions test_slicer_service, test_end_to_end
FR-13 Each position is a horizontal plane (normal +z). The cut through the mesh gives closed loops. The loops are combined by the even-odd rule: a loop inside another is a hole, and a loop inside that hole is solid again. Each polygon is in the model's x and y coordinates, so every slice shares one frame. A plane that cuts nothing gives an empty list of polygons. models/model.py::calculate_slice_contours test_model_contours, test_end_to_end
FR-14 One slice is made per position, in order and numbered from 0. Empty slices are kept. models/slicing/slicer_service.py::slice_model test_end_to_end

Reference marks

ID Requirement Code Tests
FR-15 Each polygon gets at most one mark. models/reference_marks/reference_mark_calculator.py::get_stable_marks test_reference_mark_calculator, test_reference_mark_property
FR-16 Candidate points for a polygon are its centroid, if the centroid is inside the polygon, plus random points inside its bounding box that fall inside the polygon (up to 40 tries, 4 wanted). The random generator has a fixed seed, so the same polygon always gives the same points. models/reference_marks/reference_mark_calculator.py::_sample_points test_calculator_get_potential_marks
FR-17 A point qualifies only if it is at least min_distance from the polygon boundary and from marks already chosen in the same slice. The whole hole must fit (TR-5). The size of a new mark is known here, and its shape and angle are not, so the mark is taken as a disc that holds the outline of every available shape at any angle. Its radius is the farthest reach of those outlines from the centre: half the size for the square, triangle and arrow, and a little more for the circle (FR-27). The disc must lie inside the boundary by at least the web (marks.min_web_ratio times the layer height, FR-30), and two discs must be at least the web apart. A sampled point must also lie farther than tolerance from every stored mark and every mark already chosen, because a point in that range would be taken for that mark (FR-19). same test_reference_mark_property, test_mark_footprint
FR-18 An earlier mark that qualifies and lies inside the polygon is reused. Otherwise the qualifying candidate with the highest stability score wins. The score is the sum of distances between all pairs of points, the candidate and the marks already chosen. same test_calculator_get_potential_marks, test_reference_mark_property
FR-19 Marks are shared across all slices in one run. A chosen point that lies within tolerance of an earlier mark is that mark: it takes the stored coordinates, shape, size and angle. When several stored marks are in range, the nearest wins, and the earliest of equally near ones. The slice's tolerance decides both when a point is taken for a stored mark and when a new mark is stored, so the two cannot disagree. The manager's own tolerance applies only when a caller gives none (#108). models/slicing/slice.py::process_reference_marks, models/reference_marks/reference_mark_manager.py test_slice_mark_inheritance, test_slice_process_reference_marks, test_reference_mark_manager
FR-20 A new mark takes the first configured shape that no mark uses yet. If all are in use, it takes the first configured shape. Its angle comes from the options. models/slicing/slice.py::_select_unique_shape test_slice_process_reference_marks
FR-21 A new mark's size is --mark-size when it is set. Otherwise it is the larger of marks.min_hole_ratio (1) times the layer height and marks.min_hole_kerf_factor (1.5) times the kerf: 3 for the default sheet of 3 mm and kerf of 0.3 mm. It does not depend on where the mark lies or on the size of the model. When --mark-min-distance is not set it is the mark size, and when --mark-tolerance is not set it is 0.1 times the mark size (TR-6, TR-10). All three are worked out once per run from the layer height, and every slice and the mark store share them. A size below the least size logs one warning for the run and the run goes on (The mark size S is below the least hole size L for a sheet of H and a kerf of K. Holes this small may not cut cleanly.). models/reference_marks/config.py::ReferenceMarkConfig.resolved, models/slicing/slicer_service.py test_slice_mark_size, test_reference_mark_config, test_end_to_end
FR-22 After marks are chosen, a mark is dropped when its centre is closer than min_distance to a contour boundary or to a mark already kept, when its hole (the outline of its shape, at its size and angle, FR-27) does not lie wholly inside one contour, when the material between the hole and a boundary is thinner than the web, or when its hole overlaps or comes within the web of the hole of a mark already kept. The web is marks.min_web_ratio times the layer height (0.5 times by default). A Slice needs its layer height. The earlier mark stays. If a slice then has a contour with no mark, one warning is logged for the slice (No reference mark fits N of M contours in slice I. Try a smaller --mark-min-distance or --mark-size.). The slice is still written. models/reference_marks/reference_mark_adjuster.py, models/reference_marks/footprint.py test_mark_footprint, test_reference_mark_adjuster, test_reference_mark_adjuster_extra, test_slice_process_reference_marks, test_end_to_end
FR-23 The mark settings are checked when created: available_shapes must not be empty, tolerance and min_distance must not be negative, size, when set, must be greater than 0, and size, tolerance, min_distance and angle must be finite. Violations raise ValueError. models/reference_marks/config.py test_reference_mark_config

SVG output

ID Requirement Code Tests
FR-24 One SVG file is written per slice, named slice_000.svg, slice_001.svg and so on, including empty slices (a slice whose cut misses the mesh). writers/svg_writer.py, utils/file_operations.py test_file_operations, test_svg_output, test_end_to_end
FR-25 Each SVG has, in order: the polygon outlines (--cut-color, a hairline, no fill, class="outline"; a polygon with holes has one outline for the outer edge and one for each hole), the marks (same colour, width and fill, class="mark"), and the text Slice N once per polygon. The label sits at the polygon centroid. If that is outside the polygon, it sits at the centre of the bounding box. If that is outside too, it sits at a point that is inside the polygon. svg/slice_svg_drawer.py test_svg_output
FR-26 Points are drawn at (x, −y) in model units, so the picture has the same handedness as the model seen from above (SVG's y axis points down). Marks are flipped the same way, including their angle. Every SVG has the same viewBox. It holds the outlines of all slices plus a margin of 5% of the larger side, so layers can be laid over each other. The root sets width and height to the viewBox width and height followed by the unit of --units (FR-31), for example 33.0cm, so every file has the same physical size and a laser program can read it. The stroke width of a cut line is output.hairline_width, written as plain decimal text with no exponent (0.01 mm in inches is 0.00039370078740157485). The label has fill of --engrave-color and a font-size of 1/20 of the viewBox's larger side. When no slice has an outline, there is no viewBox, and width and height are 100%. svg/svg_generator.py, svg/slice_svg_drawer.py test_svg_output, test_end_to_end
FR-27 Mark shapes, all unfilled and all closed outlines. Every shape is anchored at its centre, and its size is the diameter of the smallest circle around that centre that holds the outline. Circle. Square: four corners on that circle, so the side is the size divided by √2. Triangle: isosceles with a 40° apex and all three corners on the circle. Arrow: a closed polygon of seven vertices, with the tip and the two tail corners on the circle. Every shape is drawn in the cut colour. Angle 0 points along +x (the apex of the triangle, the tip of the arrow) and angles turn counter-clockwise. Each shape gives its outline as a polygon (a circle as a polygon of 64 vertices just outside the true circle, for checks; the checks use this polygon and not the exact circle that the drawing cuts, see below) and its rotational symmetry: circle unlimited, square 4, triangle 1, arrow 1. Angle turns the shape. Angles are in radians everywhere inside the package; only the --mark-angle option takes degrees. The polygon of the circle reaches 5.00603 from the centre at size 10, which is 0.12% past the true radius of 5 (0.0018 at size 3). The checks accept a circle only when this polygon fits, so a circle that passes never crosses an edge, and one that fails by less than 0.12% of its radius is dropped though the drawn circle would fit (decided in #157). The calculator uses the same reach (FR-17), so it does not choose a point that the adjuster then drops. svg/drawing/strategies/*, domain/shapes/* test_shape_contract, test_svg_output, test_arrow_drawing_strategy
FR-28 ShapeFactory.get_shape raises ValueError: Unknown shape type for an unknown shape name. The command checks --available-shapes against the registered shapes first, and an unknown or empty list stops it with Invalid value for --available-shapes and exit code 2. A Python caller that gives Slice.adjust_marks a mark with an unregistered shape name, or the calculator an unregistered name in available_shapes, gets the same ValueError. It is not caught and logged, so no check is skipped for the other marks of the slice (#162). svg/drawing/shape_factory.py, cli.py::resolve_settings, models/slicing/slice.py test_shape_factory, test_process_model_validation, test_cli_checks_before_prompt, test_mark_footprint
FR-29 Code can add shapes (register_shape; a shape class must define outline(), FR-27) and mesh loaders (LoaderFactory.register_loader). The CLI uses only the four built-in shapes and the trimesh loader. svg/drawing/shape_factory.py, models/loading/__init__.py test_shape_factory, test_loader_factory

Non-functional requirements

ID Requirement Where it is enforced
NFR-1 Python 3.12 or newer. CI runs 3.12, 3.13 and 3.14 on Linux. pyproject.toml (requires-python), .github/workflows/tests.yaml
NFR-2 Runtime dependencies are declared in pyproject.toml and locked in uv.lock: click, pydantic, scipy, networkx, shapely, svgwrite, trimesh. Installing the package pulls in all of them. pyproject.toml, uv.lock
NFR-3 Every pull request passes ruff check, ruff format --check, pyright (strict on src/) and the full test suite. .github/workflows/tests.yaml
NFR-4 The command installs as layerforge with uv tool install, and the wheel contains every module. pyproject.toml ([project.scripts])
NFR-5 Bad option values fail before any work starts, with a message that names the option. Click reports usage errors with exit code 2. A bad config file also exits with 2, with a message that names the file and the key (FR-30). A conflict between options exits with 1. The command asks for --stl-file only after these checks pass. A missing, unreadable or empty mesh file, or a mesh with no height, stops the command with a message that names the file and exit code 1. cli.py, models/model_factory.py
NFR-6 Documentation builds with mkdocs build --strict and deploys to GitHub Pages on each push to main. .github/workflows/docs.yaml
NFR-7 The code is licensed CC BY-NC 4.0. A commercial license is offered separately. LICENSE, COMMERCIAL_LICENSE
NFR-8 The tool logs only through the standard logging module and sets no log configuration. Warnings therefore reach stderr through Python's last-resort handler. models/slicing/slice.py

The earlier goals to bundle all dependencies into one binary and to run without a Python install are withdrawn. They depended on PyOxidizer, which was removed because its build file was an unedited template that could not build the tool.

Constraints

  • C-1. Slicing is along the z axis only, with z pointing up.
  • C-2. One mesh per file. The mesh has no unit. --units (default mm) states it and labels the size of each SVG (FR-31). Nothing converts a number that you give. Only the defaults for the layer height and the kerf are stated in the unit (FR-31).
  • C-3. Mesh formats are whatever trimesh can read. Only STL is tested.
  • C-4. All work happens in one process, in memory. Mesh size is limited by memory.

Known gaps

These are places where current behavior differs from the intent in the project docs, or where behavior is likely to surprise. Each was checked against the current code.

ID Gap Evidence Effect
G-4 (#60) Only one mark per polygon (FR-15), and nothing checks that adjacent layers can be aligned in exactly one way. get_stable_marks. The first mark is usually a circle, which has no direction. One mark fixes position but not rotation. A cube or cylinder can be stacked rotated. Target: TR-1 to TR-4, TR-12.
G-5 (#61) Shapes are chosen by list order, not by need (FR-20). Once all are used, every new mark is the first shape. _select_unique_shape The first mark is a circle and cannot fix rotation. The algorithm page says shapes cycle. Target: TR-8.
G-7 (#63) Marks are shared by all slices, not chosen for pairs of adjacent layers (FR-19). One ReferenceMarkManager per run. A mark can be inherited from a slice far away, and nothing makes the shared marks lie in both outlines. Target: TR-9.
G-16 (#73) Overlapping shells in one mesh give false holes (FR-13). Decided 2026-09-25: a documented limit, not fixed. Two 20 mm boxes, 10 mm apart in x, cut to one polygon of area 566.7 with a hole. The real region is one solid of area 600. The even-odd rule treats an overlap as a hole. Users merge bodies before export (see Getting Started). trimesh drops loop winding after section().to_2D(), so a union of the loops cannot tell a cavity from an overlap. A later opt-in union of the mesh with manifold3d before slicing would work for watertight bodies.
G-18 (#75) The label can cross an outline (FR-25). The centroid of a concave polygon can lie near an edge. The text runs over the outline. The first mark and the number can also sit on the same point. Target: TR-11.

Fixing these is not part of this page. Each has its own issue, given in brackets.