cut3mf 0.1.0__py3-none-any.whl
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.
- cut3mf/__init__.py +11 -0
- cut3mf/__main__.py +5 -0
- cut3mf/cli.py +80 -0
- cut3mf/core.py +159 -0
- cut3mf-0.1.0.dist-info/METADATA +110 -0
- cut3mf-0.1.0.dist-info/RECORD +9 -0
- cut3mf-0.1.0.dist-info/WHEEL +4 -0
- cut3mf-0.1.0.dist-info/entry_points.txt +2 -0
- cut3mf-0.1.0.dist-info/licenses/LICENSE +21 -0
cut3mf/__init__.py
ADDED
|
@@ -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__"]
|
cut3mf/__main__.py
ADDED
cut3mf/cli.py
ADDED
|
@@ -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())
|
cut3mf/core.py
ADDED
|
@@ -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,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
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cut3mf/__init__.py,sha256=MT-GglMXk8nmTUmIBls9xOrK6PXBG0tDouWnfKikheQ,442
|
|
2
|
+
cut3mf/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
cut3mf/cli.py,sha256=aRulNEmQLEhMtWXWwlo8W28JPBYuUZ-oLq40Ij86TiE,3144
|
|
4
|
+
cut3mf/core.py,sha256=6IG0L90sV_WoXV5i2swx9z4xu1HDci5AmtX6Q7OZ_VU,5208
|
|
5
|
+
cut3mf-0.1.0.dist-info/METADATA,sha256=2yiNog1Mz-AXLo8HOSJRmWIJ0Mv0ygHYjtoIZaOZU4A,4022
|
|
6
|
+
cut3mf-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
cut3mf-0.1.0.dist-info/entry_points.txt,sha256=D9apIWniI-ZDLfadbvh_SQUNe9MDfO2Jw8PTWAnkSY8,43
|
|
8
|
+
cut3mf-0.1.0.dist-info/licenses/LICENSE,sha256=Ms1llSYy2UX_v1xxNi3yb89rpUYlQncYR7TurGxZ0Xc,1070
|
|
9
|
+
cut3mf-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|