solidsmith 0.2.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.
- solidsmith-0.2.0/LICENSE +21 -0
- solidsmith-0.2.0/PKG-INFO +164 -0
- solidsmith-0.2.0/README.md +135 -0
- solidsmith-0.2.0/pyproject.toml +50 -0
- solidsmith-0.2.0/setup.cfg +4 -0
- solidsmith-0.2.0/src/solidsmith/__init__.py +47 -0
- solidsmith-0.2.0/src/solidsmith/export.py +92 -0
- solidsmith-0.2.0/src/solidsmith/ops.py +96 -0
- solidsmith-0.2.0/src/solidsmith/part.py +41 -0
- solidsmith-0.2.0/src/solidsmith/preview.py +91 -0
- solidsmith-0.2.0/src/solidsmith/printers.py +50 -0
- solidsmith-0.2.0/src/solidsmith/py.typed +0 -0
- solidsmith-0.2.0/src/solidsmith/report.py +151 -0
- solidsmith-0.2.0/src/solidsmith/sdf.py +193 -0
- solidsmith-0.2.0/src/solidsmith/workflow.py +88 -0
- solidsmith-0.2.0/src/solidsmith.egg-info/PKG-INFO +164 -0
- solidsmith-0.2.0/src/solidsmith.egg-info/SOURCES.txt +19 -0
- solidsmith-0.2.0/src/solidsmith.egg-info/dependency_links.txt +1 -0
- solidsmith-0.2.0/src/solidsmith.egg-info/requires.txt +9 -0
- solidsmith-0.2.0/src/solidsmith.egg-info/top_level.txt +1 -0
- solidsmith-0.2.0/tests/test_solidsmith.py +226 -0
solidsmith-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scott Shamansky
|
|
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.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solidsmith
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Forge watertight, print-ready 3D parts from Python: robust booleans, colored 3MF export, and multi-view previews.
|
|
5
|
+
Author: Scott Shamansky
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/scottshamansky/solidsmith
|
|
8
|
+
Project-URL: Issues, https://github.com/scottshamansky/solidsmith/issues
|
|
9
|
+
Keywords: 3d-printing,parametric,stl,3mf,trimesh,cad,maker
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Manufacturing
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: numpy>=1.24
|
|
20
|
+
Requires-Dist: scipy>=1.10
|
|
21
|
+
Requires-Dist: trimesh>=4.10
|
|
22
|
+
Requires-Dist: manifold3d>=2.5
|
|
23
|
+
Requires-Dist: matplotlib>=3.7
|
|
24
|
+
Requires-Dist: networkx>=2.8
|
|
25
|
+
Requires-Dist: lxml>=4.9
|
|
26
|
+
Requires-Dist: scikit-image>=0.22
|
|
27
|
+
Requires-Dist: rtree>=1.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# solidsmith
|
|
31
|
+
|
|
32
|
+
[](https://github.com/scottshamansky/solidsmith/actions/workflows/ci.yml)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
**Forge watertight, print-ready 3D parts from Python.**
|
|
37
|
+
|
|
38
|
+
solidsmith is the missing glue between [trimesh](https://trimesh.org) and your
|
|
39
|
+
printer: booleans that don't fall over, 3MF export that keeps its colors, a
|
|
40
|
+
printability report, and quick multi-view renders to iterate against — the
|
|
41
|
+
scaffolding every scripted-CAD project ends up reinventing, packaged once.
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
## Why script your models?
|
|
46
|
+
|
|
47
|
+
Parametric code beats mouse-driven CAD when you make things repeatedly:
|
|
48
|
+
|
|
49
|
+
- **Every dimension is a variable.** Resize a bin, thicken a wall, or regenerate
|
|
50
|
+
a whole family of sizes by editing constants, not dragging faces.
|
|
51
|
+
- **Geometry you authored is geometry you own.** Marketplaces increasingly
|
|
52
|
+
require sellers of printed goods to use their own original designs. A model
|
|
53
|
+
built from your own code is original by construction, and generic patterns
|
|
54
|
+
(honeycombs, gears, chamfers) stay free for everyone.
|
|
55
|
+
- **Designs diff, review, and version like any other code.**
|
|
56
|
+
|
|
57
|
+
## What trimesh leaves to you (and solidsmith does)
|
|
58
|
+
|
|
59
|
+
| Pain | What solidsmith does |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Booleans fail on real-world meshes | `union` / `difference` / `intersection` run the robust manifold engine with mesh hygiene (`clean`) before and after |
|
|
62
|
+
| 3MF export silently drops color | `write_3mf` injects real `<basematerials>` and tags each body, so multi-color models open in the slicer with filaments already assigned |
|
|
63
|
+
| "Will it print?" found out the hard way | `check` reports watertightness per body, bed fit, plate contact, walls too thin for the nozzle, and stray debris bodies — against a named printer profile (`printer="bambu_a1_mini"`) or your own `Printer` |
|
|
64
|
+
| Seeing your part means opening a slicer | `render_views` renders shaded PNGs from named camera angles in about a second, no GUI or GPU |
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from solidsmith import Part, check, difference, rounded_prism, render_views, write_3mf
|
|
70
|
+
|
|
71
|
+
shell = rounded_prism((80, 50, 30), radius=8)
|
|
72
|
+
cavity = rounded_prism((74, 44, 30), radius=5)
|
|
73
|
+
cavity.apply_translation((0, 0, 3))
|
|
74
|
+
tray = difference(shell, cavity)
|
|
75
|
+
|
|
76
|
+
print(check(tray)) # watertight? fits the bed? on the plate?
|
|
77
|
+
write_3mf(Part(tray, color=(226, 122, 40), name="tray"), "tray.3mf")
|
|
78
|
+
render_views(tray, "tray.png") # shaded views, one PNG
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
✔ watertight (1 body)
|
|
83
|
+
✔ 80.0 × 50.0 × 30.0 mm on a 256 × 256 × 256 bed (Bambu X1C / P1S)
|
|
84
|
+
✔ first layer on the plate (z=0)
|
|
85
|
+
31.0 cm³ · 564 triangles
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Multi-color is just more `Part`s — one per filament — exported together:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
write_3mf([Part(body, (30, 120, 220), "body"),
|
|
92
|
+
Part(cap, (240, 200, 40), "cap")], "model.3mf")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
| Model | Run it |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| Honeycomb storage bin — vented walls, supports-free label tab, every dimension a parameter | `python examples/honeycomb_bin.py` |
|
|
100
|
+
| Pebble planter — SDF-sculpted organic form, hollow with a drainage hole, split into two filament colors | `python examples/pebble_planter.py` |
|
|
101
|
+
| Hex bit organizer — 16 slide-fit sockets for 1/4" bits, built on `workflow.main` with preview/final modes | `python examples/bit_organizer.py preview` |
|
|
102
|
+
| Two-tone phone stand — angled boolean cuts, a charging-cable channel, one AMS color swap, checked against a named printer profile | `python examples/phone_stand.py preview` |
|
|
103
|
+
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+
Each example writes STL + colored 3MF + a rendered preview into `out/` and
|
|
107
|
+
prints its printability report. Add `--fast` while you iterate.
|
|
108
|
+
|
|
109
|
+
## Install
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/scottshamansky/solidsmith
|
|
113
|
+
cd solidsmith
|
|
114
|
+
pip install -e .
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Requires Python 3.9+. Dimensions are millimeters throughout; the default bed
|
|
118
|
+
is a 256 mm cube (Bambu P1/P2/X1 class) and every check takes your own `bed=`.
|
|
119
|
+
|
|
120
|
+
## Sculpting (SDF)
|
|
121
|
+
|
|
122
|
+
Alongside the hard-edged boolean toolkit, `solidsmith.sdf` models organic
|
|
123
|
+
shapes as blended signed distance fields — functional primitives, smooth
|
|
124
|
+
unions that read as clay fillets, `offset`/`shell` for hollowing, and
|
|
125
|
+
marching-cubes meshing with Taubin smoothing:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from solidsmith import sdf
|
|
129
|
+
|
|
130
|
+
body = sdf.smooth_union(12, sdf.sphere((0, 0, 30), 25),
|
|
131
|
+
sdf.ellipsoid((0, 18, 22), (20, 26, 16)))
|
|
132
|
+
solid = sdf.intersect(body, sdf.plane()) # flat print base at z=0
|
|
133
|
+
mesh = sdf.mesh(solid, bounds=((-30, -30, 0), (30, 50, 50)), pitch=0.5)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## The iteration loop
|
|
137
|
+
|
|
138
|
+
`workflow.main` wraps the loop every scripted model settles into: `preview`
|
|
139
|
+
builds coarse and renders a PNG in seconds; `final` cuts the print-quality
|
|
140
|
+
files; and every preview is archived to `previews/` — script included — so
|
|
141
|
+
each iteration's look and the code that produced it stay side by side.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from solidsmith import workflow
|
|
145
|
+
|
|
146
|
+
def build(fast: bool):
|
|
147
|
+
...
|
|
148
|
+
return parts # a mesh, a Part, or a list of them
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
workflow.main(build, name="widget")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`python widget.py preview`, tweak, repeat — `python widget.py final` once
|
|
155
|
+
the design is locked.
|
|
156
|
+
|
|
157
|
+
## Roadmap
|
|
158
|
+
|
|
159
|
+
- More examples and an overhang/support-need check
|
|
160
|
+
- PyPI release
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# solidsmith
|
|
2
|
+
|
|
3
|
+
[](https://github.com/scottshamansky/solidsmith/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
**Forge watertight, print-ready 3D parts from Python.**
|
|
8
|
+
|
|
9
|
+
solidsmith is the missing glue between [trimesh](https://trimesh.org) and your
|
|
10
|
+
printer: booleans that don't fall over, 3MF export that keeps its colors, a
|
|
11
|
+
printability report, and quick multi-view renders to iterate against — the
|
|
12
|
+
scaffolding every scripted-CAD project ends up reinventing, packaged once.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
## Why script your models?
|
|
17
|
+
|
|
18
|
+
Parametric code beats mouse-driven CAD when you make things repeatedly:
|
|
19
|
+
|
|
20
|
+
- **Every dimension is a variable.** Resize a bin, thicken a wall, or regenerate
|
|
21
|
+
a whole family of sizes by editing constants, not dragging faces.
|
|
22
|
+
- **Geometry you authored is geometry you own.** Marketplaces increasingly
|
|
23
|
+
require sellers of printed goods to use their own original designs. A model
|
|
24
|
+
built from your own code is original by construction, and generic patterns
|
|
25
|
+
(honeycombs, gears, chamfers) stay free for everyone.
|
|
26
|
+
- **Designs diff, review, and version like any other code.**
|
|
27
|
+
|
|
28
|
+
## What trimesh leaves to you (and solidsmith does)
|
|
29
|
+
|
|
30
|
+
| Pain | What solidsmith does |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Booleans fail on real-world meshes | `union` / `difference` / `intersection` run the robust manifold engine with mesh hygiene (`clean`) before and after |
|
|
33
|
+
| 3MF export silently drops color | `write_3mf` injects real `<basematerials>` and tags each body, so multi-color models open in the slicer with filaments already assigned |
|
|
34
|
+
| "Will it print?" found out the hard way | `check` reports watertightness per body, bed fit, plate contact, walls too thin for the nozzle, and stray debris bodies — against a named printer profile (`printer="bambu_a1_mini"`) or your own `Printer` |
|
|
35
|
+
| Seeing your part means opening a slicer | `render_views` renders shaded PNGs from named camera angles in about a second, no GUI or GPU |
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from solidsmith import Part, check, difference, rounded_prism, render_views, write_3mf
|
|
41
|
+
|
|
42
|
+
shell = rounded_prism((80, 50, 30), radius=8)
|
|
43
|
+
cavity = rounded_prism((74, 44, 30), radius=5)
|
|
44
|
+
cavity.apply_translation((0, 0, 3))
|
|
45
|
+
tray = difference(shell, cavity)
|
|
46
|
+
|
|
47
|
+
print(check(tray)) # watertight? fits the bed? on the plate?
|
|
48
|
+
write_3mf(Part(tray, color=(226, 122, 40), name="tray"), "tray.3mf")
|
|
49
|
+
render_views(tray, "tray.png") # shaded views, one PNG
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
✔ watertight (1 body)
|
|
54
|
+
✔ 80.0 × 50.0 × 30.0 mm on a 256 × 256 × 256 bed (Bambu X1C / P1S)
|
|
55
|
+
✔ first layer on the plate (z=0)
|
|
56
|
+
31.0 cm³ · 564 triangles
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Multi-color is just more `Part`s — one per filament — exported together:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
write_3mf([Part(body, (30, 120, 220), "body"),
|
|
63
|
+
Part(cap, (240, 200, 40), "cap")], "model.3mf")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
| Model | Run it |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| Honeycomb storage bin — vented walls, supports-free label tab, every dimension a parameter | `python examples/honeycomb_bin.py` |
|
|
71
|
+
| Pebble planter — SDF-sculpted organic form, hollow with a drainage hole, split into two filament colors | `python examples/pebble_planter.py` |
|
|
72
|
+
| Hex bit organizer — 16 slide-fit sockets for 1/4" bits, built on `workflow.main` with preview/final modes | `python examples/bit_organizer.py preview` |
|
|
73
|
+
| Two-tone phone stand — angled boolean cuts, a charging-cable channel, one AMS color swap, checked against a named printer profile | `python examples/phone_stand.py preview` |
|
|
74
|
+
|
|
75
|
+

|
|
76
|
+
|
|
77
|
+
Each example writes STL + colored 3MF + a rendered preview into `out/` and
|
|
78
|
+
prints its printability report. Add `--fast` while you iterate.
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/scottshamansky/solidsmith
|
|
84
|
+
cd solidsmith
|
|
85
|
+
pip install -e .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Requires Python 3.9+. Dimensions are millimeters throughout; the default bed
|
|
89
|
+
is a 256 mm cube (Bambu P1/P2/X1 class) and every check takes your own `bed=`.
|
|
90
|
+
|
|
91
|
+
## Sculpting (SDF)
|
|
92
|
+
|
|
93
|
+
Alongside the hard-edged boolean toolkit, `solidsmith.sdf` models organic
|
|
94
|
+
shapes as blended signed distance fields — functional primitives, smooth
|
|
95
|
+
unions that read as clay fillets, `offset`/`shell` for hollowing, and
|
|
96
|
+
marching-cubes meshing with Taubin smoothing:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from solidsmith import sdf
|
|
100
|
+
|
|
101
|
+
body = sdf.smooth_union(12, sdf.sphere((0, 0, 30), 25),
|
|
102
|
+
sdf.ellipsoid((0, 18, 22), (20, 26, 16)))
|
|
103
|
+
solid = sdf.intersect(body, sdf.plane()) # flat print base at z=0
|
|
104
|
+
mesh = sdf.mesh(solid, bounds=((-30, -30, 0), (30, 50, 50)), pitch=0.5)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## The iteration loop
|
|
108
|
+
|
|
109
|
+
`workflow.main` wraps the loop every scripted model settles into: `preview`
|
|
110
|
+
builds coarse and renders a PNG in seconds; `final` cuts the print-quality
|
|
111
|
+
files; and every preview is archived to `previews/` — script included — so
|
|
112
|
+
each iteration's look and the code that produced it stay side by side.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from solidsmith import workflow
|
|
116
|
+
|
|
117
|
+
def build(fast: bool):
|
|
118
|
+
...
|
|
119
|
+
return parts # a mesh, a Part, or a list of them
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
workflow.main(build, name="widget")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`python widget.py preview`, tweak, repeat — `python widget.py final` once
|
|
126
|
+
the design is locked.
|
|
127
|
+
|
|
128
|
+
## Roadmap
|
|
129
|
+
|
|
130
|
+
- More examples and an overhang/support-need check
|
|
131
|
+
- PyPI release
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solidsmith"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Forge watertight, print-ready 3D parts from Python: robust booleans, colored 3MF export, and multi-view previews."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Scott Shamansky" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy>=1.24",
|
|
15
|
+
"scipy>=1.10",
|
|
16
|
+
"trimesh>=4.10",
|
|
17
|
+
"manifold3d>=2.5",
|
|
18
|
+
"matplotlib>=3.7",
|
|
19
|
+
"networkx>=2.8",
|
|
20
|
+
"lxml>=4.9",
|
|
21
|
+
"scikit-image>=0.22",
|
|
22
|
+
"rtree>=1.0",
|
|
23
|
+
]
|
|
24
|
+
keywords = [
|
|
25
|
+
"3d-printing",
|
|
26
|
+
"parametric",
|
|
27
|
+
"stl",
|
|
28
|
+
"3mf",
|
|
29
|
+
"trimesh",
|
|
30
|
+
"cad",
|
|
31
|
+
"maker",
|
|
32
|
+
]
|
|
33
|
+
classifiers = [
|
|
34
|
+
"Development Status :: 4 - Beta",
|
|
35
|
+
"Intended Audience :: Manufacturing",
|
|
36
|
+
"License :: OSI Approved :: MIT License",
|
|
37
|
+
"Programming Language :: Python :: 3",
|
|
38
|
+
"Topic :: Multimedia :: Graphics :: 3D Modeling",
|
|
39
|
+
"Topic :: Scientific/Engineering",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/scottshamansky/solidsmith"
|
|
44
|
+
Issues = "https://github.com/scottshamansky/solidsmith/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
solidsmith = ["py.typed"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""solidsmith — forge watertight, print-ready 3D parts from Python.
|
|
2
|
+
|
|
3
|
+
The missing glue between trimesh and your printer: booleans that don't fall
|
|
4
|
+
over, 3MF export that keeps its colors, a printability report, and quick
|
|
5
|
+
multi-view renders to iterate against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from solidsmith import printers, sdf, workflow
|
|
9
|
+
from solidsmith.export import write_3mf, write_stl
|
|
10
|
+
from solidsmith.ops import (
|
|
11
|
+
clean,
|
|
12
|
+
concat,
|
|
13
|
+
difference,
|
|
14
|
+
hex_prism,
|
|
15
|
+
intersection,
|
|
16
|
+
rounded_prism,
|
|
17
|
+
union,
|
|
18
|
+
)
|
|
19
|
+
from solidsmith.part import Part
|
|
20
|
+
from solidsmith.printers import Printer
|
|
21
|
+
from solidsmith.preview import DEFAULT_VIEWS, render_views
|
|
22
|
+
from solidsmith.report import DEFAULT_BED, PrintReport, check
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DEFAULT_BED",
|
|
28
|
+
"DEFAULT_VIEWS",
|
|
29
|
+
"Part",
|
|
30
|
+
"PrintReport",
|
|
31
|
+
"Printer",
|
|
32
|
+
"printers",
|
|
33
|
+
"check",
|
|
34
|
+
"clean",
|
|
35
|
+
"concat",
|
|
36
|
+
"difference",
|
|
37
|
+
"hex_prism",
|
|
38
|
+
"intersection",
|
|
39
|
+
"render_views",
|
|
40
|
+
"rounded_prism",
|
|
41
|
+
"sdf",
|
|
42
|
+
"union",
|
|
43
|
+
"workflow",
|
|
44
|
+
"write_3mf",
|
|
45
|
+
"write_stl",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Export meshes to print-ready files.
|
|
2
|
+
|
|
3
|
+
STL is the easy half. 3MF is the interesting one: trimesh writes valid 3MF
|
|
4
|
+
geometry but silently drops color, so every body opens gray in the slicer.
|
|
5
|
+
`write_3mf` post-processes the archive it writes — injecting a real
|
|
6
|
+
``<basematerials>`` resource and tagging each body with a material index —
|
|
7
|
+
so a multi-color model opens with its filament assignments already in place.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import trimesh
|
|
17
|
+
|
|
18
|
+
from solidsmith.ops import concat
|
|
19
|
+
from solidsmith.part import Part, as_parts
|
|
20
|
+
|
|
21
|
+
_CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
|
|
22
|
+
_MODEL_ENTRY = "3D/3dmodel.model"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_stl(parts, path) -> Path:
|
|
26
|
+
"""Write one binary STL. Multiple parts are concatenated (STL has no color)."""
|
|
27
|
+
path = Path(path)
|
|
28
|
+
parts = as_parts(parts)
|
|
29
|
+
mesh = parts[0].mesh if len(parts) == 1 else concat(*(p.mesh for p in parts))
|
|
30
|
+
mesh.export(path)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_3mf(parts, path) -> Path:
|
|
35
|
+
"""Write a single 3MF containing every part as a separately colored body."""
|
|
36
|
+
path = Path(path)
|
|
37
|
+
parts = as_parts(parts)
|
|
38
|
+
|
|
39
|
+
scene = trimesh.Scene()
|
|
40
|
+
for i, part in enumerate(parts):
|
|
41
|
+
# zero-padded prefix pins a stable object order in the archive
|
|
42
|
+
scene.add_geometry(part.mesh, geom_name=f"{i:03d}_{part.name}")
|
|
43
|
+
scene.export(path)
|
|
44
|
+
|
|
45
|
+
_inject_materials(path, parts)
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _inject_materials(path: Path, parts: "list[Part]") -> None:
|
|
50
|
+
"""Rewrite the 3MF's model XML with basematerials + per-object material refs."""
|
|
51
|
+
with zipfile.ZipFile(path, "r") as zf:
|
|
52
|
+
entries = {info.filename: zf.read(info.filename) for info in zf.infolist()}
|
|
53
|
+
if _MODEL_ENTRY not in entries:
|
|
54
|
+
raise ValueError(f"{path} has no {_MODEL_ENTRY}; not a 3MF written by trimesh?")
|
|
55
|
+
|
|
56
|
+
ET.register_namespace("", _CORE_NS)
|
|
57
|
+
root = ET.fromstring(entries[_MODEL_ENTRY])
|
|
58
|
+
ns = {"m": _CORE_NS}
|
|
59
|
+
resources = root.find("m:resources", ns)
|
|
60
|
+
if resources is None:
|
|
61
|
+
raise ValueError("3MF model has no <resources> element")
|
|
62
|
+
|
|
63
|
+
objects = [
|
|
64
|
+
obj for obj in resources.findall("m:object", ns)
|
|
65
|
+
if obj.find("m:mesh", ns) is not None
|
|
66
|
+
]
|
|
67
|
+
if len(objects) != len(parts):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"expected {len(parts)} mesh objects in the 3MF, found {len(objects)}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
used_ids = [int(obj.get("id")) for obj in resources.findall("m:object", ns)]
|
|
73
|
+
material_id = str(max(used_ids) + 1)
|
|
74
|
+
|
|
75
|
+
materials = ET.Element(f"{{{_CORE_NS}}}basematerials", {"id": material_id})
|
|
76
|
+
for part in parts:
|
|
77
|
+
r, g, b = (int(c) for c in part.color)
|
|
78
|
+
ET.SubElement(
|
|
79
|
+
materials,
|
|
80
|
+
f"{{{_CORE_NS}}}base",
|
|
81
|
+
{"name": part.name, "displaycolor": f"#{r:02X}{g:02X}{b:02X}"},
|
|
82
|
+
)
|
|
83
|
+
resources.insert(0, materials)
|
|
84
|
+
|
|
85
|
+
for index, obj in enumerate(objects):
|
|
86
|
+
obj.set("pid", material_id)
|
|
87
|
+
obj.set("pindex", str(index))
|
|
88
|
+
|
|
89
|
+
entries[_MODEL_ENTRY] = ET.tostring(root, encoding="UTF-8", xml_declaration=True)
|
|
90
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
91
|
+
for name, data in entries.items():
|
|
92
|
+
zf.writestr(name, data)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Boolean modeling that stays watertight.
|
|
2
|
+
|
|
3
|
+
trimesh's default boolean backend rejects meshes that are fine in practice;
|
|
4
|
+
the manifold engine is far more robust but still rewards mesh hygiene on the
|
|
5
|
+
way in and out. These wrappers bake both in, so `difference(a, b)` is a thing
|
|
6
|
+
you can stop thinking about.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import trimesh
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def clean(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
16
|
+
"""Scrub a mesh in place: weld vertices, drop degenerate faces, fix winding.
|
|
17
|
+
|
|
18
|
+
Marching-cubes and heavily boolean'd meshes accumulate slivers that make
|
|
19
|
+
later booleans fail; running this between operations keeps them reliable.
|
|
20
|
+
"""
|
|
21
|
+
mesh.merge_vertices()
|
|
22
|
+
mesh.update_faces(mesh.nondegenerate_faces())
|
|
23
|
+
mesh.remove_unreferenced_vertices()
|
|
24
|
+
mesh.fix_normals()
|
|
25
|
+
return mesh
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _boolean(kind: str, meshes: "list[trimesh.Trimesh]") -> trimesh.Trimesh:
|
|
29
|
+
prepared = [clean(m.copy()) for m in meshes]
|
|
30
|
+
result = getattr(trimesh.boolean, kind)(prepared, engine="manifold")
|
|
31
|
+
return clean(result)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def union(*meshes: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
35
|
+
"""Fuse meshes into one solid."""
|
|
36
|
+
if len(meshes) == 1:
|
|
37
|
+
return clean(meshes[0].copy())
|
|
38
|
+
return _boolean("union", list(meshes))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def difference(base: trimesh.Trimesh, *cutters: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
42
|
+
"""Subtract every cutter from base."""
|
|
43
|
+
if not cutters:
|
|
44
|
+
return clean(base.copy())
|
|
45
|
+
return _boolean("difference", [base, *cutters])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def intersection(*meshes: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
49
|
+
"""Keep only the volume common to all meshes."""
|
|
50
|
+
return _boolean("intersection", list(meshes))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def concat(*meshes: trimesh.Trimesh) -> trimesh.Trimesh:
|
|
54
|
+
"""Append meshes without any boolean (touching bodies that print as one)."""
|
|
55
|
+
return trimesh.util.concatenate(list(meshes))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rounded_prism(
|
|
59
|
+
extents,
|
|
60
|
+
radius: float,
|
|
61
|
+
sections: int = 64,
|
|
62
|
+
) -> trimesh.Trimesh:
|
|
63
|
+
"""Rectangular prism with rounded vertical corners, sitting on z=0.
|
|
64
|
+
|
|
65
|
+
extents is (x, y, z) in mm; radius rounds the four vertical edges. Built
|
|
66
|
+
from boxes plus corner cylinders so it needs no 2D triangulation deps,
|
|
67
|
+
and clamps the radius rather than erroring when it exceeds the footprint.
|
|
68
|
+
"""
|
|
69
|
+
x, y, z = (float(v) for v in extents)
|
|
70
|
+
if min(x, y, z) <= 0:
|
|
71
|
+
raise ValueError(f"extents must be positive, got {extents}")
|
|
72
|
+
radius = max(0.0, min(float(radius), x / 2 - 1e-3, y / 2 - 1e-3))
|
|
73
|
+
|
|
74
|
+
if radius < 1e-6:
|
|
75
|
+
prism = trimesh.creation.box((x, y, z))
|
|
76
|
+
else:
|
|
77
|
+
core_x = trimesh.creation.box((x, y - 2 * radius, z))
|
|
78
|
+
core_y = trimesh.creation.box((x - 2 * radius, y, z))
|
|
79
|
+
corners = []
|
|
80
|
+
for sx in (-1.0, 1.0):
|
|
81
|
+
for sy in (-1.0, 1.0):
|
|
82
|
+
cyl = trimesh.creation.cylinder(radius=radius, height=z, sections=sections)
|
|
83
|
+
cyl.apply_translation((sx * (x / 2 - radius), sy * (y / 2 - radius), 0))
|
|
84
|
+
corners.append(cyl)
|
|
85
|
+
prism = union(core_x, core_y, *corners)
|
|
86
|
+
|
|
87
|
+
prism.apply_translation((0, 0, z / 2))
|
|
88
|
+
return prism
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def hex_prism(circumradius: float, height: float, point_up: bool = True) -> trimesh.Trimesh:
|
|
92
|
+
"""Hexagonal prism along z, centered at the origin (honeycomb building block)."""
|
|
93
|
+
hexagon = trimesh.creation.cylinder(radius=circumradius, height=height, sections=6)
|
|
94
|
+
if point_up:
|
|
95
|
+
hexagon.apply_transform(trimesh.transformations.rotation_matrix(np.pi / 6, (0, 0, 1)))
|
|
96
|
+
return hexagon
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""A mesh plus how it should appear when exported or rendered."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
import trimesh
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Part:
|
|
13
|
+
"""One printable body: a mesh, a display color, and a name.
|
|
14
|
+
|
|
15
|
+
Color is sRGB 0-255. A multi-color model is just a list of Parts (one
|
|
16
|
+
per filament); exported together they become a single 3MF that opens in
|
|
17
|
+
the slicer with every body already assigned its color.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
mesh: trimesh.Trimesh
|
|
21
|
+
color: Tuple[int, int, int] = (140, 140, 145)
|
|
22
|
+
name: str = "part"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def as_parts(obj) -> "list[Part]":
|
|
26
|
+
"""Normalize a Part, a mesh, or a sequence of either into a list of Parts."""
|
|
27
|
+
if isinstance(obj, Part):
|
|
28
|
+
return [obj]
|
|
29
|
+
if isinstance(obj, trimesh.Trimesh):
|
|
30
|
+
return [Part(obj)]
|
|
31
|
+
parts = []
|
|
32
|
+
for i, item in enumerate(obj):
|
|
33
|
+
if isinstance(item, Part):
|
|
34
|
+
parts.append(item)
|
|
35
|
+
elif isinstance(item, trimesh.Trimesh):
|
|
36
|
+
parts.append(Part(item, name=f"part_{i}"))
|
|
37
|
+
else:
|
|
38
|
+
raise TypeError(f"expected Part or Trimesh, got {type(item).__name__}")
|
|
39
|
+
if not parts:
|
|
40
|
+
raise ValueError("no parts given")
|
|
41
|
+
return parts
|