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 checks each pair of adjacent layers before it writes (FR-32); G-4 says what it does not check.
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 andtests/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), --number-height (default none, which is 5 mm stated in --units; a number greater than 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 and --number-height 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. When the two colours are equal as text, ignoring case, the run logs one warning before slicing and goes on. The checks run before the --stl-file prompt. |
cli.py::resolve_settings, cli.py::_run |
test_cli, test_cli_checks_before_prompt, test_laser_output |
| 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, in the [number] table height (above 0; default 5 mm, stated in --units, option --number-height) and width_factor (above 0, default 0.6, no option), 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 |
| FR-32 | After slicing and before any file is written, every pair of overlapping pieces in adjacent slices is checked. The marks both slices hold, with the centre inside both pieces, are the pair's shared marks. A pair with none, or whose shared marks a turn other than none maps onto themselves, is a failure. Without --allow-unaligned (checks.allow_unaligned) the command prints one line per failure, naming both slices, both pieces and a point inside both (in the coordinates of the model, y up), and Nothing was written. Use --allow-unaligned to write the files anyway. to stderr, writes no file and exits with code 1. With it, each failure is a warning, the files are written and the exit code is 0. --no-allow-unaligned beats a config file that sets checks.allow_unaligned to true: the command line wins, as for every setting. A model of one layer passes without a message, and so do two pieces that overlap by less than checks.min_overlap_area: they are not a pair (TR-9). A number that does not fit stays a warning of the drawer. |
models/slicing/alignment_check.py, cli.py::_run |
test_alignment_check, test_alignment_check_cli, test_allow_unaligned_setting, test_alignment_end_to_end |
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. A slice piece that cannot be repaired into a polygon with area (UnrepairableContourError, from adjacent_pairs) is turned into Cannot slice '<file>': slice N, piece M is not a valid polygon and cannot be repaired. The mesh may be broken at that height. with exit code 1 (#211). |
models/loading/implementations/trimesh_loader.py, models/model_factory.py, cli.py::_run, models/slicing/adjacency.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 contour gets at most one mark per pairing with the contour of the previous slice it overlaps (TR-9). A contour that overlaps two contours of the other slice, at a split or a merge, can carry up to two marks, one per pairing; a contour with no such overlap gets none, and the lowest and highest slice have only one neighbour each. | models/reference_marks/pair_marking.py::plan_marks |
test_pair_marking, test_pair_local_marks_end_to_end |
| 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 for a pair's shared region only if it lies inside the region — the pair's overlap of the two pieces' outlines, already eroded by min_distance (TR-9) — and is farther from each piece's own boundary than min_distance, or the reach of the mark's shape (FR-27) plus the web, whichever is larger. The web is marks.min_web_ratio times the layer height (FR-30). It must also be farther than that same margin, with the reach term doubled, from every mark it must avoid: marks already placed on either piece by a different pairing, and any candidate carried from the pairing before it. The shape is chosen before the point (FR-20, #198), so the reach used is the one shape actually chosen, not the largest reach over every listed shape. |
models/reference_marks/reference_mark_calculator.py::_fits_region, _clear_of_gap |
test_reference_mark_property, test_mark_footprint, test_disc_matches_chosen_shape |
| FR-18 | For each pair, the marks carried from the pairing before it on the lower piece are tried first, in the order given; the first one that still qualifies for the new region and stays clear of every mark to avoid is reused unchanged (TR-10) — its stored coordinates, shape, size and angle all stay. Otherwise a fresh point is the first of the sample points (FR-16) that qualifies and lies farther than tolerance from every mark to avoid. This is first fit, not the highest stability score of a candidate set: no score is computed. |
models/reference_marks/reference_mark_calculator.py::choose_mark_for_pair |
test_reference_mark_calculator, test_pair_marking |
| FR-19 | Mark reuse is local to one pair of adjacent slices (TR-9, #63). A mark chosen for one pairing is carried forward only to the single pairing immediately after it, on the same piece (FR-18); the moment it stops fitting there, it is retired — simply left out of the next pairing's candidates — and never revived from an earlier layer, however far back. Retirement never looking further back than the immediate neighbour does not, by itself, keep a mark out of the whole run: when the geometry never stops fitting it, nothing forces a retirement, so a straight, undrifting shape gets one mark that is reused at every pairing and ends up shared across the entire run. Known, accepted gap in TR-9's "no mark in every layer" clause; tracked in #215, not built here. A run of one slice has no adjacent pairing and so gets no marks at all. | models/reference_marks/pair_marking.py::plan_marks, models/reference_marks/reference_mark_calculator.py::choose_mark_for_pair |
test_pair_marking, test_pair_local_marks_end_to_end |
| FR-20 | A new mark takes the shape with the least symmetry among the configured shapes: the lowest symmetry order first (a triangle or an arrow, then a square, then a circle), then the larger outline at one size, then the name that sorts first. With the defaults that is the triangle. The order of the list does not matter, and neither do the shapes of other marks. Its angle comes from the options. | models/reference_marks/shape_choice.py::choose_shape |
test_shape_choice |
| 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 piece is left with no mark, the pair it belongs to fails the check of FR-32. |
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_pair_local_marks_end_to_end, 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 number once per polygon: the slice index as text (0, 1, ...), text-anchor="middle", font-family="sans-serif", font-weight="bold", no transform, no stroke, and font-size equal to --number-height (default 5 mm, stated in --units). The centre of its box is the point farthest from every cut: its box is number.width_factor (0.6) × the height per digit, by the height, and it lies inside the polygon, the kerf away from the outline, the holes and the footprint of every mark (models/slicing/number.py). When no such place exists, the number is at the point of the polygon that is farthest from its edge, and one warning names the slice and the number of pieces that lack room. It also gives the tallest height that fits every such piece (Try --number-height 4.83, rounded down to 3 significant digits, in the unit of the run), or says that no height fits, and it names the config key beside the option (number.height). |
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 number has fill of --engrave-color. 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 set symmetry_order in its own body, or register_shape raises ValueError) 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(defaultmm) 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) | The check of FR-32 looks at each pair of adjacent layers on its own. It does not prove that a whole stack rebuilds one way, and a straight shape still gets one mark that is a hole in every layer (TR-9, #215). A pair shares at most one mark, so the baseline of TR-4 is not checked. | check_alignment, plan_marks, ReferenceMarkCalculator.choose_mark_for_pair. A shape with a direction is used when one is listed (FR-20), so one mark fixes rotation as well as position. |
A stack can pass the check and still have a bore through every layer. Target: TR-1, TR-4, TR-9. |
| G-32 (#213) | Two marks on one piece can have the same shape, so adjacent layers can be laid shifted with the wrong two holes lined up (TR-8). | plan_marks reuses a carried mark and chooses a fresh one without looking at the other marks on the piece or its neighbours; check_alignment tests only the shared marks. Measured: slice 11 of the sheared cylinder of #107 holds two triangles, and two legs merging into one top leave the top with two triangles. |
A stack can pass the check and still be assembled in a wrong way. Only two shapes have a direction (#230). Target: TR-8. Draft design: docs/superpowers/specs/2026-10-09-distinct-marks-and-mark-cap-design.md. |
| 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. |
Fixing these is not part of this page. Each has its own issue, given in brackets.