cut3mf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest, windows-latest]
15
+ python-version: ["3.10", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - run: pip install -e ".[test]"
24
+ - run: pytest -v
@@ -0,0 +1,44 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - name: Check tag matches pyproject version
16
+ run: |
17
+ version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
18
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
19
+ echo "tag ${GITHUB_REF_NAME} does not match pyproject version ${version}" >&2
20
+ exit 1
21
+ fi
22
+ - run: pip install pytest -e .
23
+ - run: pytest -q
24
+ - run: pip install build
25
+ - run: python -m build
26
+ - uses: actions/upload-artifact@v4
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment:
35
+ name: pypi
36
+ url: https://pypi.org/p/cut3mf
37
+ permissions:
38
+ id-token: write # trusted publishing, no API token needed
39
+ steps:
40
+ - uses: actions/download-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ *_cut.3mf
cut3mf-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Schank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cut3mf-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: cut3mf
3
+ Version: 0.1.0
4
+ Summary: Cut a slab off one face of a 3MF/STL model before slicing, e.g. a helper plate holding embedded text together
5
+ Project-URL: Homepage, https://github.com/DrTom/cut3mf
6
+ Project-URL: Issues, https://github.com/DrTom/cut3mf/issues
7
+ Author: Thomas Schank
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: 3d-printing,3mf,cad,mesh,multi-material,slicer,stl,text
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Manufacturing
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: lxml
19
+ Requires-Dist: manifold3d>=3.0
20
+ Requires-Dist: networkx
21
+ Requires-Dist: numpy
22
+ Requires-Dist: trimesh>=4.0
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == 'test'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # cut3mf
28
+
29
+ Cut a slab off one face of a 3MF or STL model before slicing.
30
+
31
+ Made for a common CAD-to-slicer workaround: when text is embedded in a
32
+ surface, the many separate letter bodies often cause trouble, in the CAD tool,
33
+ on export, or in the slicer. Model a thin "helper plate" (e.g. 1 mm) on top of
34
+ the surface that holds the letters together, export, then let `cut3mf` remove
35
+ that plate. You get clean, watertight letter bodies flush with the surface,
36
+ still as separate objects, so you can assign them their own filament.
37
+
38
+ It works with any CAD tool that exports 3MF, STL or OBJ (Onshape, Fusion,
39
+ FreeCAD, SolidWorks, ...) and any slicer that imports them (Bambu Studio,
40
+ OrcaSlicer, PrusaSlicer, Cura, ...).
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pipx install cut3mf # or: pip install cut3mf
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ cut3mf part.3mf # remove the top 1 mm -> part_cut.3mf
52
+ cut3mf part.3mf -d 1.2 -o out.3mf # remove the top 1.2 mm
53
+ cut3mf part.3mf --axis bottom # plate on the bottom (same as --axis -z)
54
+ cut3mf part.3mf --at 14.5 # keep everything below Z = 14.5 mm
55
+ ```
56
+
57
+ | Option | Meaning |
58
+ |---|---|
59
+ | `-d, --depth` | Thickness to remove from the outermost face, mm (default 1.0) |
60
+ | `-a, --axis` | Face with the plate: `top`, `bottom`, `front`, `back`, `left`, `right`, or `x`, `-x`, `y`, `-y`, `z`, `-z` (default `top`) |
61
+ | `--at` | Cut at an absolute coordinate (mm) instead of a depth |
62
+ | `-o, --output` | Output file (default `<input>_cut.<ext>`) |
63
+ | `--eps` | Extra cut depth against coplanar slivers, mm (default 0.001) |
64
+
65
+ ### How it works
66
+
67
+ - All objects are measured together, so every object is cut at the same plane.
68
+ - 3MF units are respected and converted to millimeters (some tools, e.g. Onshape, export in meters). STL has no units and is taken as millimeters.
69
+ - Use 3MF if you want separate objects (e.g. for a second filament); STL stores
70
+ only one combined mesh.
71
+ - The cut uses the [Manifold](https://github.com/elalish/manifold) kernel, so cut faces are closed and results stay watertight.
72
+ - The cut goes 1 µm deeper than requested, because the plate's underside usually lies exactly on the cut plane and an exact cut leaves zero-thickness slivers.
73
+
74
+ Limits: the cut is a flat plane, and the plate must be the outermost part of the
75
+ model in the chosen direction. 3MF output keeps the objects and their names, but
76
+ not slicer project data such as filament assignments or print settings, so
77
+ cut the exported model before you set it up in the slicer.
78
+
79
+ ### Python API
80
+
81
+ ```python
82
+ from cut3mf import cut_file
83
+
84
+ result = cut_file("part.3mf", axis="bottom", depth=1.0)
85
+ print(result.output, result.kept)
86
+ ```
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ python -m venv .venv && . .venv/bin/activate
92
+ pip install -e ".[test]"
93
+ pytest
94
+ ```
95
+
96
+ ## Releasing
97
+
98
+ Releases are published to PyPI by GitHub Actions using trusted publishing.
99
+ Bump `version` in `pyproject.toml`, commit, then run:
100
+
101
+ ```bash
102
+ scripts/git_tag_and_push
103
+ ```
104
+
105
+ The script runs the tests, tags the commit as `v<version>` and pushes the tag;
106
+ the publish workflow builds and uploads that version.
107
+
108
+ ## License
109
+
110
+ MIT
cut3mf-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # cut3mf
2
+
3
+ Cut a slab off one face of a 3MF or STL model before slicing.
4
+
5
+ Made for a common CAD-to-slicer workaround: when text is embedded in a
6
+ surface, the many separate letter bodies often cause trouble, in the CAD tool,
7
+ on export, or in the slicer. Model a thin "helper plate" (e.g. 1 mm) on top of
8
+ the surface that holds the letters together, export, then let `cut3mf` remove
9
+ that plate. You get clean, watertight letter bodies flush with the surface,
10
+ still as separate objects, so you can assign them their own filament.
11
+
12
+ It works with any CAD tool that exports 3MF, STL or OBJ (Onshape, Fusion,
13
+ FreeCAD, SolidWorks, ...) and any slicer that imports them (Bambu Studio,
14
+ OrcaSlicer, PrusaSlicer, Cura, ...).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pipx install cut3mf # or: pip install cut3mf
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ cut3mf part.3mf # remove the top 1 mm -> part_cut.3mf
26
+ cut3mf part.3mf -d 1.2 -o out.3mf # remove the top 1.2 mm
27
+ cut3mf part.3mf --axis bottom # plate on the bottom (same as --axis -z)
28
+ cut3mf part.3mf --at 14.5 # keep everything below Z = 14.5 mm
29
+ ```
30
+
31
+ | Option | Meaning |
32
+ |---|---|
33
+ | `-d, --depth` | Thickness to remove from the outermost face, mm (default 1.0) |
34
+ | `-a, --axis` | Face with the plate: `top`, `bottom`, `front`, `back`, `left`, `right`, or `x`, `-x`, `y`, `-y`, `z`, `-z` (default `top`) |
35
+ | `--at` | Cut at an absolute coordinate (mm) instead of a depth |
36
+ | `-o, --output` | Output file (default `<input>_cut.<ext>`) |
37
+ | `--eps` | Extra cut depth against coplanar slivers, mm (default 0.001) |
38
+
39
+ ### How it works
40
+
41
+ - All objects are measured together, so every object is cut at the same plane.
42
+ - 3MF units are respected and converted to millimeters (some tools, e.g. Onshape, export in meters). STL has no units and is taken as millimeters.
43
+ - Use 3MF if you want separate objects (e.g. for a second filament); STL stores
44
+ only one combined mesh.
45
+ - The cut uses the [Manifold](https://github.com/elalish/manifold) kernel, so cut faces are closed and results stay watertight.
46
+ - The cut goes 1 µm deeper than requested, because the plate's underside usually lies exactly on the cut plane and an exact cut leaves zero-thickness slivers.
47
+
48
+ Limits: the cut is a flat plane, and the plate must be the outermost part of the
49
+ model in the chosen direction. 3MF output keeps the objects and their names, but
50
+ not slicer project data such as filament assignments or print settings, so
51
+ cut the exported model before you set it up in the slicer.
52
+
53
+ ### Python API
54
+
55
+ ```python
56
+ from cut3mf import cut_file
57
+
58
+ result = cut_file("part.3mf", axis="bottom", depth=1.0)
59
+ print(result.output, result.kept)
60
+ ```
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ python -m venv .venv && . .venv/bin/activate
66
+ pip install -e ".[test]"
67
+ pytest
68
+ ```
69
+
70
+ ## Releasing
71
+
72
+ Releases are published to PyPI by GitHub Actions using trusted publishing.
73
+ Bump `version` in `pyproject.toml`, commit, then run:
74
+
75
+ ```bash
76
+ scripts/git_tag_and_push
77
+ ```
78
+
79
+ The script runs the tests, tags the commit as `v<version>` and pushes the tag;
80
+ the publish workflow builds and uploads that version.
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cut3mf"
7
+ version = "0.1.0"
8
+ description = "Cut a slab off one face of a 3MF/STL model before slicing, e.g. a helper plate holding embedded text together"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Thomas Schank" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["3mf", "stl", "3d-printing", "mesh", "slicer", "cad", "multi-material", "text"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Manufacturing",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Multimedia :: Graphics :: 3D Modeling",
22
+ ]
23
+ dependencies = [
24
+ "trimesh>=4.0",
25
+ "manifold3d>=3.0",
26
+ "numpy",
27
+ "lxml",
28
+ "networkx",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest"]
33
+
34
+ [project.scripts]
35
+ cut3mf = "cut3mf.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/DrTom/cut3mf"
39
+ Issues = "https://github.com/DrTom/cut3mf/issues"
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # Tag the current commit with the version from pyproject.toml and push the tag.
3
+ # The GitHub workflow (.github/workflows/publish.yml) then builds and publishes
4
+ # that version to PyPI via Trusted Publishing.
5
+ #
6
+ # Usage: scripts/git_tag_and_push [remote] (default remote: origin)
7
+ set -euo pipefail
8
+
9
+ cd "$(dirname "${BASH_SOURCE[0]}")/.."
10
+ remote="${1:-origin}"
11
+
12
+ # Prefer the project's virtualenv (pip install -e ".[test]").
13
+ if [ -x .venv/bin/python ]; then
14
+ python=.venv/bin/python
15
+ else
16
+ python=python3
17
+ fi
18
+
19
+ version="$("${python}" -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
20
+ tag="v${version}"
21
+
22
+ if [ -n "$(git status --porcelain)" ]; then
23
+ echo "error: working tree is not clean; commit or stash first" >&2
24
+ exit 1
25
+ fi
26
+ if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
27
+ echo "error: tag ${tag} already exists (bump the version in pyproject.toml)" >&2
28
+ exit 1
29
+ fi
30
+ # The tests must pass before a version goes out.
31
+ if ! "${python}" -c 'import pytest, cut3mf' 2>/dev/null; then
32
+ echo "error: pytest or cut3mf not installed in ${python}; run: pip install -e \".[test]\"" >&2
33
+ exit 1
34
+ fi
35
+ "${python}" -m pytest -q
36
+ git fetch -q "${remote}" main 2>/dev/null || true
37
+ if ! git merge-base --is-ancestor HEAD "${remote}/main" 2>/dev/null; then
38
+ echo "note: HEAD is not on ${remote}/main yet; pushing the branch first"
39
+ git push "${remote}" HEAD:main
40
+ fi
41
+
42
+ git tag -a "${tag}" -m "cut3mf ${version}"
43
+ git push "${remote}" "${tag}"
44
+ echo "tagged and pushed ${tag}; the publish workflow releases it to PyPI"
@@ -0,0 +1,11 @@
1
+ """cut3mf: cut a slab off one face of a 3MF/STL model before slicing."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("cut3mf")
6
+ except PackageNotFoundError: # running from a source checkout
7
+ __version__ = "0.0.0"
8
+
9
+ from .core import CutError, CutResult, cut_file, cut_scene, load_scene # noqa: E402
10
+
11
+ __all__ = ["CutError", "CutResult", "cut_file", "cut_scene", "load_scene", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,80 @@
1
+ """Command-line interface for cut3mf."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import logging
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .core import CutError, cut_file
12
+
13
+ EPILOG = """\
14
+ examples:
15
+ cut3mf part.3mf remove the top 1 mm -> part_cut.3mf
16
+ cut3mf part.3mf -d 1.2 -o out.3mf remove the top 1.2 mm
17
+ cut3mf part.3mf --axis bottom plate on the bottom (same as --axis -z)
18
+ cut3mf part.3mf --at 14.5 keep everything below Z = 14.5 mm
19
+ """
20
+
21
+ _NEG_AXIS = re.compile(r"^-[xyz]$", re.IGNORECASE)
22
+
23
+
24
+ def _fix_negative_axis(argv: list[str]) -> list[str]:
25
+ """Let '--axis -z' work: argparse would read '-z' as an option."""
26
+ out, i = [], 0
27
+ while i < len(argv):
28
+ if argv[i] == "--axis" and i + 1 < len(argv) and _NEG_AXIS.match(argv[i + 1]):
29
+ out.append(f"--axis={argv[i + 1]}")
30
+ i += 2
31
+ else:
32
+ out.append(argv[i])
33
+ i += 1
34
+ return out
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ p = argparse.ArgumentParser(
39
+ prog="cut3mf",
40
+ description="Cut a slab off one face of a 3MF/STL model before slicing, "
41
+ "e.g. a 1 mm helper plate that holds embedded text together.",
42
+ epilog=EPILOG,
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ )
45
+ p.add_argument("input", type=Path, help="input mesh (.3mf, .stl, .obj, ...)")
46
+ p.add_argument("-o", "--output", type=Path,
47
+ help="output file (default: <input>_cut.<ext>)")
48
+ p.add_argument("-d", "--depth", type=float, default=1.0,
49
+ help="thickness to remove from the outermost face, mm (default 1.0)")
50
+ p.add_argument("--at", "--z", dest="at", type=float,
51
+ help="cut at this absolute coordinate (mm) instead of using --depth")
52
+ p.add_argument("-a", "--axis", default="z",
53
+ help="face carrying the plate: top, bottom, front, back, left, right, "
54
+ "or x, -x, y, -y, z, -z (default: top)")
55
+ p.add_argument("--eps", type=float, default=0.001,
56
+ help="extra cut depth against coplanar slivers, mm (default 0.001)")
57
+ p.add_argument("-q", "--quiet", action="store_true", help="only print errors")
58
+ p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
59
+ return p
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ argv = _fix_negative_axis(list(sys.argv[1:] if argv is None else argv))
64
+ args = build_parser().parse_args(argv)
65
+ logging.basicConfig(level=logging.ERROR if args.quiet else logging.INFO,
66
+ format="%(message)s")
67
+ try:
68
+ r = cut_file(args.input, args.output, axis=args.axis, depth=args.depth,
69
+ at=args.at, eps=args.eps)
70
+ except (CutError, OSError, ValueError) as e:
71
+ print(f"cut3mf: error: {e}", file=sys.stderr)
72
+ return 1
73
+ if not args.quiet:
74
+ print(f"cut at {r.axis}={r.cut:.3f} mm (outermost {r.extreme:.3f} mm), "
75
+ f"{len(r.kept)} object(s) -> {r.output}")
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ sys.exit(main())
@@ -0,0 +1,159 @@
1
+ """Core cutting logic: remove a slab from one face of a multi-object mesh."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import manifold3d as mf
9
+ import numpy as np
10
+ import trimesh
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ AXES = {"x": 0, "y": 1, "z": 2}
15
+ ALIASES = {
16
+ "top": "z", "bottom": "-z",
17
+ "back": "y", "front": "-y",
18
+ "right": "x", "left": "-x",
19
+ }
20
+ _MM = ("mm", "millimeter", "millimeters")
21
+
22
+
23
+ class CutError(Exception):
24
+ """Raised when the input cannot be cut."""
25
+
26
+
27
+ @dataclass
28
+ class CutResult:
29
+ axis: str
30
+ cut: float
31
+ extreme: float
32
+ output: Path
33
+ kept: list[str] = field(default_factory=list)
34
+ dropped: list[str] = field(default_factory=list)
35
+
36
+
37
+ def parse_axis(axis: str) -> tuple[int, float, str]:
38
+ """Return (axis index, sign, normalized name) for 'z', '-y', 'bottom', ..."""
39
+ a = ALIASES.get(axis.lower(), axis.lower())
40
+ name = a.lstrip("+-")
41
+ if name not in AXES:
42
+ raise CutError(
43
+ f"unknown axis '{axis}' (use top/bottom/front/back/left/right, "
44
+ "or x/y/z with an optional minus sign)"
45
+ )
46
+ sign = -1.0 if a.startswith("-") else 1.0
47
+ return AXES[name], sign, ("-" if sign < 0 else "") + name
48
+
49
+
50
+ def _to_manifold(mesh: trimesh.Trimesh) -> mf.Manifold:
51
+ m = mf.Mesh(
52
+ vert_properties=np.asarray(mesh.vertices, dtype=np.float32),
53
+ tri_verts=np.asarray(mesh.faces, dtype=np.uint32),
54
+ )
55
+ return mf.Manifold(m)
56
+
57
+
58
+ def _from_manifold(man: mf.Manifold) -> trimesh.Trimesh:
59
+ m = man.to_mesh()
60
+ return trimesh.Trimesh(
61
+ vertices=np.asarray(m.vert_properties)[:, :3],
62
+ faces=np.asarray(m.tri_verts),
63
+ process=True,
64
+ )
65
+
66
+
67
+ def load_scene(path: Path) -> trimesh.Scene:
68
+ """Load a mesh file as a scene in millimeters."""
69
+ scene = trimesh.load(path, force="scene")
70
+ if scene.units and scene.units not in _MM:
71
+ # Some CAD tools (e.g. Onshape) write 3MF in meters.
72
+ log.info("converting units: %s -> millimeters", scene.units)
73
+ scene = scene.convert_units("millimeters")
74
+ elif not scene.units:
75
+ log.info("file has no units, assuming millimeters")
76
+ return scene
77
+
78
+
79
+ def cut_scene(
80
+ scene: trimesh.Scene,
81
+ *,
82
+ axis: str = "z",
83
+ depth: float = 1.0,
84
+ at: float | None = None,
85
+ eps: float = 0.001,
86
+ ) -> tuple[trimesh.Scene, dict]:
87
+ """Cut a slab off one face of a scene. Returns (new scene, info dict)."""
88
+ ai, sign, axis_name = parse_axis(axis)
89
+
90
+ # Bake transforms so all objects share one coordinate system.
91
+ geoms: dict[str, trimesh.Trimesh] = {}
92
+ for node in scene.graph.nodes_geometry:
93
+ tf, gname = scene.graph[node]
94
+ g = scene.geometry[gname].copy()
95
+ g.apply_transform(tf)
96
+ geoms[node] = g
97
+ if not geoms:
98
+ raise CutError("no geometry found")
99
+
100
+ all_v = np.vstack([g.vertices for g in geoms.values()])
101
+ extreme = float(all_v[:, ai].max() if sign > 0 else all_v[:, ai].min())
102
+ cut = at if at is not None else extreme - sign * depth
103
+ # The plate's underside usually lies exactly on the cut plane; cutting
104
+ # exactly there leaves zero-thickness slivers, so cut a hair deeper.
105
+ cut -= sign * eps
106
+
107
+ # Keep the side away from the plate. Manifold keeps normal . x >= offset.
108
+ normal = [0.0, 0.0, 0.0]
109
+ normal[ai] = -sign
110
+ offset = -sign * cut
111
+
112
+ out = trimesh.Scene()
113
+ out.units = "millimeters"
114
+ kept, dropped = [], []
115
+ for name, g in geoms.items():
116
+ if not g.is_watertight:
117
+ log.warning("'%s' is not watertight, trying anyway", name)
118
+ man = _to_manifold(g)
119
+ if man.status() != mf.Error.NoError:
120
+ raise CutError(
121
+ f"'{name}' is not a valid solid ({man.status()}); "
122
+ "re-export or repair the mesh first"
123
+ )
124
+ trimmed = man.trim_by_plane(normal, offset)
125
+ res = None if trimmed.is_empty() else _from_manifold(trimmed)
126
+ if res is not None:
127
+ parts = [c for c in res.split(only_watertight=False) if abs(c.volume) > 1e-6]
128
+ res = (trimesh.util.concatenate(parts) if len(parts) > 1 else parts[0]) if parts else None
129
+ if res is None:
130
+ log.info("'%s' lies completely in the removed slab, dropped", name)
131
+ dropped.append(name)
132
+ continue
133
+ out.add_geometry(res, node_name=name, geom_name=name)
134
+ kept.append(name)
135
+
136
+ if not kept:
137
+ raise CutError(
138
+ f"nothing left after cutting at {axis_name}={cut:.3f} mm; "
139
+ "check --depth, --axis and the model's units"
140
+ )
141
+ return out, {"axis": axis_name, "cut": cut, "extreme": extreme,
142
+ "kept": kept, "dropped": dropped}
143
+
144
+
145
+ def default_output(path: Path) -> Path:
146
+ return path.with_name(path.stem + "_cut" + path.suffix)
147
+
148
+
149
+ def cut_file(
150
+ input: str | Path,
151
+ output: str | Path | None = None,
152
+ **kwargs,
153
+ ) -> CutResult:
154
+ """Cut a mesh file and write the result. kwargs go to :func:`cut_scene`."""
155
+ src = Path(input)
156
+ dst = Path(output) if output else default_output(src)
157
+ scene, info = cut_scene(load_scene(src), **kwargs)
158
+ scene.export(dst)
159
+ return CutResult(output=dst, **info)
@@ -0,0 +1,98 @@
1
+ """Tests build a model like the intended workflow: a body with text sunk 1 mm
2
+ into its top, plus a 1 mm plate above the surface that joins the letters."""
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ import trimesh
8
+
9
+ from cut3mf import CutError, cut_file
10
+ from cut3mf.cli import _fix_negative_axis, main
11
+
12
+
13
+ def _box(extents, center):
14
+ b = trimesh.creation.box(extents)
15
+ b.apply_translation(center)
16
+ return b
17
+
18
+
19
+ def _model(path: Path, units: str = "millimeters", scale: float = 1.0) -> Path:
20
+ body = _box((40, 20, 10), (0, 0, 5)) # z 0..10
21
+ l1 = _box((4, 8, 2), (-8, 0, 10)) # z 9..11
22
+ l2 = _box((4, 8, 2), (8, 0, 10))
23
+ plate = _box((24, 12, 1), (0, 0, 10.5)) # z 10..11
24
+ text = trimesh.boolean.union([l1, l2, plate], engine="manifold")
25
+ s = trimesh.Scene()
26
+ s.add_geometry(body, node_name="body", geom_name="body")
27
+ s.add_geometry(text, node_name="text", geom_name="text")
28
+ if scale != 1.0:
29
+ s = s.scaled(scale)
30
+ s.export(path)
31
+ if units != "millimeters":
32
+ # trimesh always writes unit="millimeter"; patch it like a CAD tool exporting in meters.
33
+ with zipfile.ZipFile(path) as z:
34
+ files = {n: z.read(n) for n in z.namelist()}
35
+ files["3D/3dmodel.model"] = files["3D/3dmodel.model"].replace(
36
+ b'unit="millimeter"', f'unit="{units}"'.encode(), 1)
37
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
38
+ for n, data in files.items():
39
+ z.writestr(n, data)
40
+ return path
41
+
42
+
43
+ def _load(path):
44
+ s = trimesh.load(path, force="scene")
45
+ if s.units not in (None, "millimeters"):
46
+ s = s.convert_units("millimeters")
47
+ return s
48
+
49
+
50
+ def test_top_plate_removed(tmp_path):
51
+ r = cut_file(_model(tmp_path / "m.3mf"))
52
+ assert r.output == tmp_path / "m_cut.3mf"
53
+ s = _load(r.output)
54
+ text, body = s.geometry["text"], s.geometry["body"]
55
+ parts = text.split()
56
+ assert len(parts) == 2 # letters now separate
57
+ assert all(p.is_watertight for p in parts)
58
+ assert text.volume == pytest.approx(2 * 4 * 8 * 1, rel=1e-2)
59
+ assert text.bounds[1][2] == pytest.approx(10.0, abs=0.01)
60
+ assert body.volume == pytest.approx(40 * 20 * 10, rel=1e-3)
61
+
62
+
63
+ def test_meter_units(tmp_path):
64
+ src = _model(tmp_path / "m.3mf", units="meter", scale=0.001)
65
+ r = cut_file(src)
66
+ assert r.extreme == pytest.approx(11.0, abs=0.01) # read as mm
67
+ s = _load(r.output)
68
+ assert len(s.geometry["text"].split()) == 2
69
+
70
+
71
+ def test_bottom_axis(tmp_path):
72
+ r = cut_file(_model(tmp_path / "m.3mf"), axis="bottom", depth=2)
73
+ assert r.axis == "-z"
74
+ s = _load(r.output)
75
+ assert s.geometry["body"].bounds[0][2] == pytest.approx(2.0, abs=0.01)
76
+
77
+
78
+ def test_everything_removed_raises(tmp_path):
79
+ with pytest.raises(CutError):
80
+ cut_file(_model(tmp_path / "m.3mf"), depth=100)
81
+
82
+
83
+ def test_bad_axis_raises(tmp_path):
84
+ with pytest.raises(CutError):
85
+ cut_file(_model(tmp_path / "m.3mf"), axis="w")
86
+
87
+
88
+ def test_negative_axis_argv():
89
+ assert _fix_negative_axis(["f.3mf", "--axis", "-z"]) == ["f.3mf", "--axis=-z"]
90
+ assert _fix_negative_axis(["f.3mf", "--axis", "top"]) == ["f.3mf", "--axis", "top"]
91
+
92
+
93
+ def test_cli(tmp_path, capsys):
94
+ src = _model(tmp_path / "m.3mf")
95
+ assert main([str(src), "--axis", "-z", "-o", str(tmp_path / "o.3mf")]) == 0
96
+ assert (tmp_path / "o.3mf").exists()
97
+ assert main([str(src), "-d", "100"]) == 1
98
+ assert "nothing left" in capsys.readouterr().err