solidsmith 0.2.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.
- solidsmith/__init__.py +47 -0
- solidsmith/export.py +92 -0
- solidsmith/ops.py +96 -0
- solidsmith/part.py +41 -0
- solidsmith/preview.py +91 -0
- solidsmith/printers.py +50 -0
- solidsmith/py.typed +0 -0
- solidsmith/report.py +151 -0
- solidsmith/sdf.py +193 -0
- solidsmith/workflow.py +88 -0
- solidsmith-0.2.0.dist-info/METADATA +164 -0
- solidsmith-0.2.0.dist-info/RECORD +15 -0
- solidsmith-0.2.0.dist-info/WHEEL +5 -0
- solidsmith-0.2.0.dist-info/licenses/LICENSE +21 -0
- solidsmith-0.2.0.dist-info/top_level.txt +1 -0
solidsmith/__init__.py
ADDED
|
@@ -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
|
+
]
|
solidsmith/export.py
ADDED
|
@@ -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)
|
solidsmith/ops.py
ADDED
|
@@ -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
|
solidsmith/part.py
ADDED
|
@@ -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
|
solidsmith/preview.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Multi-view PNG renders with no GUI, GPU, or external renderer.
|
|
2
|
+
|
|
3
|
+
matplotlib's 3D toolkit is slow for interaction but fine for stills: flat
|
|
4
|
+
Lambert shading, three or four named camera angles, one image. The point is
|
|
5
|
+
a feedback loop measured in seconds — iterate on the cheap render and only
|
|
6
|
+
slice when the shape is right.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from solidsmith.part import as_parts
|
|
14
|
+
|
|
15
|
+
#: name -> (elevation, azimuth) in degrees
|
|
16
|
+
DEFAULT_VIEWS = {
|
|
17
|
+
"front": (8, -90),
|
|
18
|
+
"iso": (26, -52),
|
|
19
|
+
"side": (8, 0),
|
|
20
|
+
"top": (74, -90),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_LIGHT = np.array([0.35, -0.45, 0.82])
|
|
24
|
+
_LIGHT = _LIGHT / np.linalg.norm(_LIGHT)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_views(parts, path, views=None, dpi: int = 140, background: str = "white"):
|
|
28
|
+
"""Render every part into one PNG with a subplot per camera angle.
|
|
29
|
+
|
|
30
|
+
``parts`` may be a mesh, a Part, or a sequence of either; ``views`` maps
|
|
31
|
+
view names to (elevation, azimuth) tuples and defaults to DEFAULT_VIEWS.
|
|
32
|
+
Returns the path it wrote.
|
|
33
|
+
"""
|
|
34
|
+
import matplotlib
|
|
35
|
+
|
|
36
|
+
matplotlib.use("Agg")
|
|
37
|
+
import matplotlib.pyplot as plt
|
|
38
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
39
|
+
|
|
40
|
+
parts = as_parts(parts)
|
|
41
|
+
views = dict(views) if views else dict(DEFAULT_VIEWS)
|
|
42
|
+
|
|
43
|
+
spans_all = np.concatenate([p.mesh.bounds for p in parts], axis=0)
|
|
44
|
+
max_edge = 0.04 * float(np.ptp(spans_all, axis=0).max())
|
|
45
|
+
|
|
46
|
+
# one combined triangle soup so depth sorting works across parts;
|
|
47
|
+
# long edges get subdivided first because the painter's algorithm
|
|
48
|
+
# mis-sorts large triangles and streaks flat faces
|
|
49
|
+
triangles, colors = [], []
|
|
50
|
+
for part in parts:
|
|
51
|
+
mesh = part.mesh
|
|
52
|
+
if max_edge > 0 and len(mesh.faces):
|
|
53
|
+
mesh = mesh.subdivide_to_size(max_edge, max_iter=12)
|
|
54
|
+
normals = np.nan_to_num(np.asarray(mesh.face_normals, dtype=np.float64))
|
|
55
|
+
# multiply-and-sum rather than matmul: Accelerate BLAS on macOS emits
|
|
56
|
+
# spurious floating-point warnings for tiny matmuls
|
|
57
|
+
lambert = np.clip(np.sum(normals * _LIGHT, axis=1), 0.0, 1.0)
|
|
58
|
+
# shade in linear light, then back to sRGB, so colors stay saturated
|
|
59
|
+
base = (np.array(part.color, dtype=np.float64) / 255.0) ** 2.2
|
|
60
|
+
lit = base[None, :] * (0.35 + 0.65 * lambert)[:, None]
|
|
61
|
+
triangles.append(mesh.triangles)
|
|
62
|
+
colors.append(np.clip(lit, 0.0, 1.0) ** (1 / 2.2))
|
|
63
|
+
triangles = np.concatenate(triangles)
|
|
64
|
+
colors = np.concatenate(colors)
|
|
65
|
+
|
|
66
|
+
bounds_min = triangles.reshape(-1, 3).min(axis=0)
|
|
67
|
+
bounds_max = triangles.reshape(-1, 3).max(axis=0)
|
|
68
|
+
spans = np.maximum(bounds_max - bounds_min, 1e-6)
|
|
69
|
+
pad = 0.04 * spans.max()
|
|
70
|
+
|
|
71
|
+
n = len(views)
|
|
72
|
+
fig = plt.figure(figsize=(3.7 * n, 4.1), facecolor=background)
|
|
73
|
+
for i, (name, (elev, azim)) in enumerate(views.items(), start=1):
|
|
74
|
+
ax = fig.add_subplot(1, n, i, projection="3d", facecolor=background)
|
|
75
|
+
# edges painted like their faces hide antialiasing seams between triangles
|
|
76
|
+
collection = Poly3DCollection(
|
|
77
|
+
triangles, facecolors=colors, edgecolors=colors, linewidths=0.3
|
|
78
|
+
)
|
|
79
|
+
ax.add_collection3d(collection)
|
|
80
|
+
ax.set_xlim(bounds_min[0] - pad, bounds_max[0] + pad)
|
|
81
|
+
ax.set_ylim(bounds_min[1] - pad, bounds_max[1] + pad)
|
|
82
|
+
ax.set_zlim(bounds_min[2] - pad, bounds_max[2] + pad)
|
|
83
|
+
ax.set_box_aspect(spans + 2 * pad)
|
|
84
|
+
ax.view_init(elev=elev, azim=azim)
|
|
85
|
+
ax.set_axis_off()
|
|
86
|
+
ax.set_title(name, fontsize=10, color="#666666", pad=2)
|
|
87
|
+
|
|
88
|
+
fig.subplots_adjust(left=0.01, right=0.99, top=0.94, bottom=0.02, wspace=0.02)
|
|
89
|
+
fig.savefig(path, dpi=dpi, facecolor=background)
|
|
90
|
+
plt.close(fig)
|
|
91
|
+
return path
|
solidsmith/printers.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Named printers, so checks judge against a real machine instead of a magic bed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Printer:
|
|
11
|
+
"""One machine's limits as seen by the printability checks.
|
|
12
|
+
|
|
13
|
+
``bed`` is the usable build volume in mm. ``nozzle`` is the installed
|
|
14
|
+
nozzle diameter in mm; walls thinner than one extrusion of it cannot
|
|
15
|
+
be printed and future checks use it as the floor for feature size.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
bed: Tuple[float, float, float]
|
|
20
|
+
nozzle: float = 0.4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
BAMBU_X1C = Printer("Bambu X1C / P1S", (256.0, 256.0, 256.0))
|
|
24
|
+
BAMBU_P2S = Printer("Bambu P2S", (256.0, 256.0, 256.0))
|
|
25
|
+
BAMBU_A1 = Printer("Bambu A1", (256.0, 256.0, 256.0))
|
|
26
|
+
BAMBU_A1_MINI = Printer("Bambu A1 mini", (180.0, 180.0, 180.0))
|
|
27
|
+
PRUSA_MK4 = Printer("Prusa MK4", (250.0, 210.0, 220.0))
|
|
28
|
+
ENDER_3 = Printer("Creality Ender 3", (220.0, 220.0, 250.0))
|
|
29
|
+
|
|
30
|
+
#: What `check` assumes when no printer is named.
|
|
31
|
+
DEFAULT_PRINTER = BAMBU_X1C
|
|
32
|
+
|
|
33
|
+
PRINTERS = {
|
|
34
|
+
"bambu_x1c": BAMBU_X1C,
|
|
35
|
+
"bambu_p1s": BAMBU_X1C,
|
|
36
|
+
"bambu_p2s": BAMBU_P2S,
|
|
37
|
+
"bambu_a1": BAMBU_A1,
|
|
38
|
+
"bambu_a1_mini": BAMBU_A1_MINI,
|
|
39
|
+
"prusa_mk4": PRUSA_MK4,
|
|
40
|
+
"ender_3": ENDER_3,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def printer(name: str) -> Printer:
|
|
45
|
+
"""Look up a built-in profile by slug, e.g. ``printer("bambu_a1_mini")``."""
|
|
46
|
+
try:
|
|
47
|
+
return PRINTERS[name]
|
|
48
|
+
except KeyError:
|
|
49
|
+
known = ", ".join(sorted(PRINTERS))
|
|
50
|
+
raise KeyError(f"unknown printer {name!r}; built-ins: {known}") from None
|
solidsmith/py.typed
ADDED
|
File without changes
|
solidsmith/report.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Answer "will this print?" before wasting filament on finding out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import trimesh.proximity
|
|
10
|
+
|
|
11
|
+
from solidsmith.part import as_parts
|
|
12
|
+
from solidsmith.printers import DEFAULT_PRINTER, Printer
|
|
13
|
+
from solidsmith.printers import printer as _lookup_printer
|
|
14
|
+
|
|
15
|
+
#: Build volume of `printers.DEFAULT_PRINTER`, mm. Kept for back-compat;
|
|
16
|
+
#: prefer `check(parts, printer=...)` over passing a bare bed.
|
|
17
|
+
DEFAULT_BED = DEFAULT_PRINTER.bed
|
|
18
|
+
|
|
19
|
+
_PLATE_TOLERANCE = 0.5 # mm; how far off z=0 still counts as "on the plate"
|
|
20
|
+
_DEBRIS_VOLUME = 1.0 # mm³; a disconnected body smaller than this is boolean debris
|
|
21
|
+
_WALL_SAMPLES = 512 # thickness rays per part; enough to catch any thin panel
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _min_wall(mesh) -> float:
|
|
25
|
+
"""Thinnest spot found by shooting rays inward from sampled faces, mm."""
|
|
26
|
+
faces = len(mesh.faces)
|
|
27
|
+
if faces <= _WALL_SAMPLES:
|
|
28
|
+
idx = np.arange(faces)
|
|
29
|
+
else:
|
|
30
|
+
idx = np.random.default_rng(0).choice(faces, _WALL_SAMPLES, replace=False)
|
|
31
|
+
thickness = trimesh.proximity.thickness(
|
|
32
|
+
mesh, mesh.triangles_center[idx], normals=mesh.face_normals[idx], method="ray"
|
|
33
|
+
)
|
|
34
|
+
thickness = thickness[np.isfinite(thickness)]
|
|
35
|
+
return float(thickness.min()) if len(thickness) else float("inf")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PrintReport:
|
|
40
|
+
"""What the mesh looks like from a printer's point of view."""
|
|
41
|
+
|
|
42
|
+
watertight: bool
|
|
43
|
+
bodies: int
|
|
44
|
+
extents: Tuple[float, float, float]
|
|
45
|
+
volume_cm3: float
|
|
46
|
+
triangles: int
|
|
47
|
+
fits_bed: bool
|
|
48
|
+
bed: Tuple[float, float, float]
|
|
49
|
+
printer: str
|
|
50
|
+
on_plate: bool
|
|
51
|
+
warnings: List[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def summary(self) -> str:
|
|
54
|
+
def mark(ok: bool) -> str:
|
|
55
|
+
return "✔" if ok else "✖"
|
|
56
|
+
|
|
57
|
+
x, y, z = self.extents
|
|
58
|
+
bx, by, bz = self.bed
|
|
59
|
+
body_note = "1 body" if self.bodies == 1 else f"{self.bodies} bodies"
|
|
60
|
+
lines = [
|
|
61
|
+
f"{mark(self.watertight)} watertight ({body_note})",
|
|
62
|
+
f"{mark(self.fits_bed)} {x:.1f} × {y:.1f} × {z:.1f} mm "
|
|
63
|
+
f"on a {bx:.0f} × {by:.0f} × {bz:.0f} bed ({self.printer})",
|
|
64
|
+
f"{mark(self.on_plate)} first layer on the plate (z=0)",
|
|
65
|
+
f" {self.volume_cm3:.1f} cm³ · {self.triangles:,} triangles",
|
|
66
|
+
]
|
|
67
|
+
lines.extend(f"⚠ {w}" for w in self.warnings)
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return self.summary()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check(parts, bed=None, printer=None) -> PrintReport:
|
|
75
|
+
"""Inspect a mesh (or Parts) and report printability basics.
|
|
76
|
+
|
|
77
|
+
``printer`` is a `printers.Printer` or a built-in slug like
|
|
78
|
+
``"bambu_a1_mini"``; it defaults to `printers.DEFAULT_PRINTER`. ``bed``
|
|
79
|
+
remains as a shorthand for "custom machine, just this build volume" —
|
|
80
|
+
pass one or the other, not both.
|
|
81
|
+
|
|
82
|
+
Watertightness is judged per body — touching multi-color bodies are each
|
|
83
|
+
expected to be a closed solid on their own, even though their union is
|
|
84
|
+
what gets printed.
|
|
85
|
+
"""
|
|
86
|
+
if printer is not None and bed is not None:
|
|
87
|
+
raise ValueError("pass bed= or printer=, not both")
|
|
88
|
+
if isinstance(printer, str):
|
|
89
|
+
printer = _lookup_printer(printer)
|
|
90
|
+
if printer is None:
|
|
91
|
+
printer = DEFAULT_PRINTER if bed is None else Printer("custom bed", tuple(bed))
|
|
92
|
+
bed = printer.bed
|
|
93
|
+
|
|
94
|
+
parts = as_parts(parts)
|
|
95
|
+
meshes = [p.mesh for p in parts]
|
|
96
|
+
|
|
97
|
+
watertight = all(m.is_watertight for m in meshes)
|
|
98
|
+
volume = sum(float(m.volume) for m in meshes if m.is_watertight)
|
|
99
|
+
triangles = int(sum(len(m.faces) for m in meshes))
|
|
100
|
+
|
|
101
|
+
lows = np.min([m.bounds[0] for m in meshes], axis=0)
|
|
102
|
+
highs = np.max([m.bounds[1] for m in meshes], axis=0)
|
|
103
|
+
extents = tuple(float(v) for v in (highs - lows))
|
|
104
|
+
fits = all(e <= b + 1e-6 for e, b in zip(extents, bed))
|
|
105
|
+
|
|
106
|
+
z_low = float(lows[2])
|
|
107
|
+
on_plate = abs(z_low) <= _PLATE_TOLERANCE
|
|
108
|
+
|
|
109
|
+
warnings = []
|
|
110
|
+
if not watertight:
|
|
111
|
+
leaky = [p.name for p in parts if not p.mesh.is_watertight]
|
|
112
|
+
warnings.append(
|
|
113
|
+
"not watertight: " + ", ".join(leaky) + " — run ops.clean() or check booleans"
|
|
114
|
+
)
|
|
115
|
+
if not fits:
|
|
116
|
+
warnings.append("model exceeds the bed; scale it down or split the print")
|
|
117
|
+
if z_low > _PLATE_TOLERANCE:
|
|
118
|
+
warnings.append(f"model floats {z_low:.1f} mm above the plate")
|
|
119
|
+
if z_low < -_PLATE_TOLERANCE:
|
|
120
|
+
warnings.append(f"model extends {-z_low:.1f} mm below the plate")
|
|
121
|
+
|
|
122
|
+
for p in parts:
|
|
123
|
+
if not p.mesh.is_watertight:
|
|
124
|
+
continue # geometry is unreliable; the watertight warning covers it
|
|
125
|
+
thin = _min_wall(p.mesh)
|
|
126
|
+
if thin < printer.nozzle:
|
|
127
|
+
warnings.append(
|
|
128
|
+
f"'{p.name}' has walls down to {thin:.2f} mm — too thin for "
|
|
129
|
+
f"the {printer.nozzle:.2f} mm nozzle"
|
|
130
|
+
)
|
|
131
|
+
bodies = p.mesh.split(only_watertight=False)
|
|
132
|
+
specks = sum(1 for b in bodies if abs(float(b.volume)) < _DEBRIS_VOLUME)
|
|
133
|
+
if len(bodies) > 1 and specks:
|
|
134
|
+
plural = "bodies" if specks != 1 else "body"
|
|
135
|
+
warnings.append(
|
|
136
|
+
f"'{p.name}' has {specks} stray {plural} under "
|
|
137
|
+
f"{_DEBRIS_VOLUME:.0f} mm³ — boolean debris? try clean()"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return PrintReport(
|
|
141
|
+
watertight=watertight,
|
|
142
|
+
bodies=len(parts),
|
|
143
|
+
extents=extents,
|
|
144
|
+
volume_cm3=volume / 1000.0,
|
|
145
|
+
triangles=triangles,
|
|
146
|
+
fits_bed=fits,
|
|
147
|
+
bed=tuple(float(b) for b in bed),
|
|
148
|
+
printer=printer.name,
|
|
149
|
+
on_plate=on_plate,
|
|
150
|
+
warnings=warnings,
|
|
151
|
+
)
|
solidsmith/sdf.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Sculpt organic shapes as blended signed distance fields.
|
|
2
|
+
|
|
3
|
+
Booleans give you crisp mechanical edges; SDFs give you clay. Describe a
|
|
4
|
+
shape as distance functions over space, blend them with smooth minimums so
|
|
5
|
+
joints read as fillets instead of seams, then pull a watertight mesh out
|
|
6
|
+
with marching cubes and Taubin smoothing.
|
|
7
|
+
|
|
8
|
+
A field is any callable mapping an (N, 3) array of points to (N,) signed
|
|
9
|
+
distances, negative inside. Compose them functionally:
|
|
10
|
+
|
|
11
|
+
body = sdf.smooth_union(12, sdf.sphere((0, 0, 30), 25),
|
|
12
|
+
sdf.ellipsoid((0, 18, 22), (20, 26, 16)))
|
|
13
|
+
solid = sdf.intersect(body, sdf.plane()) # flat print base at z=0
|
|
14
|
+
mesh = sdf.mesh(solid, bounds=((-30, -30, 0), (30, 50, 50)), pitch=0.5)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import trimesh
|
|
21
|
+
|
|
22
|
+
from solidsmith.ops import clean
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------ primitives
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sphere(center, radius: float):
|
|
28
|
+
center = np.asarray(center, dtype=np.float64)
|
|
29
|
+
|
|
30
|
+
def field(p):
|
|
31
|
+
return np.linalg.norm(p - center, axis=-1) - radius
|
|
32
|
+
|
|
33
|
+
return field
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ellipsoid(center, radii):
|
|
37
|
+
"""Inigo Quilez's ellipsoid distance bound — exact enough for blending."""
|
|
38
|
+
center = np.asarray(center, dtype=np.float64)
|
|
39
|
+
radii = np.asarray(radii, dtype=np.float64)
|
|
40
|
+
|
|
41
|
+
def field(p):
|
|
42
|
+
q = (p - center) / radii
|
|
43
|
+
k0 = np.linalg.norm(q, axis=-1)
|
|
44
|
+
k1 = np.linalg.norm(q / radii, axis=-1)
|
|
45
|
+
return np.where(k1 > 0, k0 * (k0 - 1.0) / np.maximum(k1, 1e-12), -radii.min())
|
|
46
|
+
|
|
47
|
+
return field
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def capsule(a, b, radius_a: float, radius_b: "float | None" = None):
|
|
51
|
+
"""Line-segment capsule from a to b; give two radii for a taper."""
|
|
52
|
+
a = np.asarray(a, dtype=np.float64)
|
|
53
|
+
b = np.asarray(b, dtype=np.float64)
|
|
54
|
+
r2 = radius_a if radius_b is None else radius_b
|
|
55
|
+
|
|
56
|
+
def field(p):
|
|
57
|
+
pa = p - a
|
|
58
|
+
ba = b - a
|
|
59
|
+
h = np.clip((pa @ ba) / (ba @ ba), 0.0, 1.0)
|
|
60
|
+
radius = radius_a + (r2 - radius_a) * h
|
|
61
|
+
return np.linalg.norm(pa - h[..., None] * ba, axis=-1) - radius
|
|
62
|
+
|
|
63
|
+
return field
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def rounded_box(center, extents, radius: float):
|
|
67
|
+
center = np.asarray(center, dtype=np.float64)
|
|
68
|
+
half = np.asarray(extents, dtype=np.float64) / 2.0 - radius
|
|
69
|
+
|
|
70
|
+
def field(p):
|
|
71
|
+
q = np.abs(p - center) - half
|
|
72
|
+
outside = np.linalg.norm(np.maximum(q, 0.0), axis=-1)
|
|
73
|
+
inside = np.minimum(q.max(axis=-1), 0.0)
|
|
74
|
+
return outside + inside - radius
|
|
75
|
+
|
|
76
|
+
return field
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def plane(normal=(0, 0, 1), point=(0, 0, 0)):
|
|
80
|
+
"""Half-space keeping everything on the side the normal points toward."""
|
|
81
|
+
normal = np.asarray(normal, dtype=np.float64)
|
|
82
|
+
normal = normal / np.linalg.norm(normal)
|
|
83
|
+
point = np.asarray(point, dtype=np.float64)
|
|
84
|
+
|
|
85
|
+
def field(p):
|
|
86
|
+
return -((p - point) @ normal)
|
|
87
|
+
|
|
88
|
+
return field
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def vertical_cylinder(center_xy, radius: float):
|
|
92
|
+
"""Infinite cylinder along z — cap it by intersecting with planes."""
|
|
93
|
+
center = np.asarray(center_xy, dtype=np.float64)
|
|
94
|
+
|
|
95
|
+
def field(p):
|
|
96
|
+
return np.linalg.norm(p[..., :2] - center, axis=-1) - radius
|
|
97
|
+
|
|
98
|
+
return field
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ----------------------------------------------------------------- combinators
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _smin(a, b, k: float):
|
|
105
|
+
h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0)
|
|
106
|
+
return b + (a - b) * h - k * h * (1.0 - h)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def union(*fields):
|
|
110
|
+
return lambda p: np.minimum.reduce([f(p) for f in fields])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def intersect(*fields):
|
|
114
|
+
return lambda p: np.maximum.reduce([f(p) for f in fields])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def subtract(base, *cutters):
|
|
118
|
+
cut = union(*cutters)
|
|
119
|
+
return lambda p: np.maximum(base(p), -cut(p))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def smooth_union(k: float, *fields):
|
|
123
|
+
"""Union with radius-k fillets where surfaces meet (the clay look)."""
|
|
124
|
+
|
|
125
|
+
def field(p):
|
|
126
|
+
values = [f(p) for f in fields]
|
|
127
|
+
out = values[0]
|
|
128
|
+
for v in values[1:]:
|
|
129
|
+
out = _smin(out, v, k)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
return field
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def smooth_subtract(k: float, base, *cutters):
|
|
136
|
+
"""Subtract with a radius-k fillet along the cut."""
|
|
137
|
+
cut = union(*cutters)
|
|
138
|
+
return lambda p: -_smin(-base(p), cut(p), k)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def offset(base, distance: float):
|
|
142
|
+
"""Positive distance inflates the solid; negative erodes it."""
|
|
143
|
+
return lambda p: base(p) - distance
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def shell(base, thickness: float):
|
|
147
|
+
"""Hollow a solid into a wall of the given thickness."""
|
|
148
|
+
return lambda p: np.abs(base(p)) - thickness / 2.0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------- meshing
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def mesh(
|
|
155
|
+
field,
|
|
156
|
+
bounds,
|
|
157
|
+
pitch: float = 0.8,
|
|
158
|
+
smooth_iterations: int = 10,
|
|
159
|
+
) -> trimesh.Trimesh:
|
|
160
|
+
"""Sample the field on a grid and extract a watertight surface.
|
|
161
|
+
|
|
162
|
+
bounds is ((x0, y0, z0), (x1, y1, z1)) in mm and should enclose the
|
|
163
|
+
solid with a little margin; pitch is the grid spacing (smaller = finer,
|
|
164
|
+
cost grows with the cube). Taubin smoothing removes the marching-cubes
|
|
165
|
+
staircase without shrinking the shape.
|
|
166
|
+
"""
|
|
167
|
+
from skimage import measure
|
|
168
|
+
|
|
169
|
+
lo = np.asarray(bounds[0], dtype=np.float64)
|
|
170
|
+
hi = np.asarray(bounds[1], dtype=np.float64)
|
|
171
|
+
if np.any(hi <= lo):
|
|
172
|
+
raise ValueError(f"bad bounds {bounds}")
|
|
173
|
+
|
|
174
|
+
steps = np.maximum(np.ceil((hi - lo) / pitch).astype(int) + 1, 2)
|
|
175
|
+
axes = [np.linspace(lo[i], hi[i], steps[i]) for i in range(3)]
|
|
176
|
+
grid = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1)
|
|
177
|
+
volume = field(grid.reshape(-1, 3)).reshape(grid.shape[:3])
|
|
178
|
+
|
|
179
|
+
if volume.min() > 0 or volume.max() < 0:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"field never crosses zero inside bounds — nothing to mesh "
|
|
182
|
+
f"(min {volume.min():.2f}, max {volume.max():.2f})"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
spacing = tuple((hi[i] - lo[i]) / (steps[i] - 1) for i in range(3))
|
|
186
|
+
vertices, faces, _, _ = measure.marching_cubes(volume, level=0.0, spacing=spacing)
|
|
187
|
+
result = trimesh.Trimesh(vertices=vertices + lo, faces=faces)
|
|
188
|
+
|
|
189
|
+
clean(result)
|
|
190
|
+
if smooth_iterations > 0:
|
|
191
|
+
trimesh.smoothing.filter_taubin(result, lamb=0.5, nu=-0.53, iterations=smooth_iterations)
|
|
192
|
+
clean(result)
|
|
193
|
+
return result
|
solidsmith/workflow.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The preview/final iteration loop, packaged.
|
|
2
|
+
|
|
3
|
+
Scripted models converge on the same habit: every run is either a *preview*
|
|
4
|
+
(coarse, fast, renders a PNG to look at) or a *final* (fine, slow, writes
|
|
5
|
+
the files you slice), and every preview is archived — script included — so
|
|
6
|
+
the look of each iteration and the code that produced it stay side by side.
|
|
7
|
+
`main` turns a build function into that CLI:
|
|
8
|
+
|
|
9
|
+
from solidsmith import workflow
|
|
10
|
+
|
|
11
|
+
def build(fast: bool):
|
|
12
|
+
...
|
|
13
|
+
return parts # a mesh, a Part, or a list of them
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
workflow.main(build, name="widget")
|
|
17
|
+
|
|
18
|
+
$ python widget.py preview # out/widget_preview.* + previews/ archive
|
|
19
|
+
$ python widget.py final # out/widget_final.stl / .3mf
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from solidsmith.export import write_3mf, write_stl
|
|
31
|
+
from solidsmith.part import as_parts
|
|
32
|
+
from solidsmith.preview import render_views
|
|
33
|
+
from solidsmith.report import check
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def next_version(previews: Path, name: str) -> int:
|
|
37
|
+
"""First unused vN in the archive directory for this model name."""
|
|
38
|
+
pattern = re.compile(rf"^{re.escape(name)}_v(\d+)$")
|
|
39
|
+
taken = [
|
|
40
|
+
int(match.group(1))
|
|
41
|
+
for entry in previews.glob(f"{name}_v*")
|
|
42
|
+
if (match := pattern.match(entry.stem))
|
|
43
|
+
]
|
|
44
|
+
return max(taken, default=0) + 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def archive(files, script, previews: Path, name: str) -> int:
|
|
48
|
+
"""Copy this iteration's outputs plus its script into previews/ as vN."""
|
|
49
|
+
previews.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
version = next_version(previews, name)
|
|
51
|
+
for source in [*files, script]:
|
|
52
|
+
source = Path(source)
|
|
53
|
+
if source.exists():
|
|
54
|
+
shutil.copy2(source, previews / f"{name}_v{version}{source.suffix}")
|
|
55
|
+
return version
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(build, name: str, out="out", previews="previews", views=None, printer=None) -> None:
|
|
59
|
+
"""Run a build function as a preview/final CLI (see module docstring).
|
|
60
|
+
|
|
61
|
+
``build(fast: bool)`` should return a mesh, a Part, or a list of Parts —
|
|
62
|
+
coarse when fast is True, print-quality when False. ``printer`` is passed
|
|
63
|
+
through to `check` — a `printers.Printer` or slug like ``"bambu_p2s"``.
|
|
64
|
+
"""
|
|
65
|
+
parser = argparse.ArgumentParser(description=f"Build {name}")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"mode", choices=("preview", "final"), nargs="?", default="preview",
|
|
68
|
+
help="preview: coarse + PNG + archive (default); final: print files",
|
|
69
|
+
)
|
|
70
|
+
args = parser.parse_args()
|
|
71
|
+
|
|
72
|
+
parts = as_parts(build(args.mode == "preview"))
|
|
73
|
+
print(check(parts, printer=printer), "\n")
|
|
74
|
+
|
|
75
|
+
out_dir = Path(out)
|
|
76
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
stem = f"{name}_{args.mode}"
|
|
78
|
+
written = [
|
|
79
|
+
write_stl(parts, out_dir / f"{stem}.stl"),
|
|
80
|
+
write_3mf(parts, out_dir / f"{stem}.3mf"),
|
|
81
|
+
render_views(parts, out_dir / f"{stem}.png", views=views),
|
|
82
|
+
]
|
|
83
|
+
for path in written:
|
|
84
|
+
print("wrote", path)
|
|
85
|
+
|
|
86
|
+
if args.mode == "preview":
|
|
87
|
+
version = archive(written, Path(sys.argv[0]), Path(previews), name)
|
|
88
|
+
print(f"archived iteration v{version} -> {previews}/")
|
|
@@ -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,15 @@
|
|
|
1
|
+
solidsmith/__init__.py,sha256=kIRv9W-A4YoyT058BcWH9BKzfhz2-C9-kjZFf7LfHvk,1058
|
|
2
|
+
solidsmith/export.py,sha256=L8sCwp0pCkx5m-r0GI0dQIt_jG_dNb7eKlIajNGZ-GM,3232
|
|
3
|
+
solidsmith/ops.py,sha256=YNM3FKXrNX9HJ7gBTxOTM2YODOSnEswJ59SD0zqU8Zc,3492
|
|
4
|
+
solidsmith/part.py,sha256=i3gNhXSr6wSoeUBDLOgp1v5lx0giBKmb1iK3T1-C0zk,1218
|
|
5
|
+
solidsmith/preview.py,sha256=aCr0GXjGMuoNr0NGiiehbmbAUykO-OsKgWlWQLCjTRg,3644
|
|
6
|
+
solidsmith/printers.py,sha256=wzFWS9pnf6zNRm-1OhJgvcJoZSxPm6VnHySMtGCw_hA,1540
|
|
7
|
+
solidsmith/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
solidsmith/report.py,sha256=VxL0D7rWQ6J7nJZ9qEEs9z_1GBjxE10r4DtfA257A_g,5598
|
|
9
|
+
solidsmith/sdf.py,sha256=z2BCj72nV21jUJVEsUyTcmJZbfmolFYHB8BfF_fEsb0,6023
|
|
10
|
+
solidsmith/workflow.py,sha256=mslQCYIGLmxpu-OPYA_NN9kdibLMLw3Smn2t67vIPqE,3157
|
|
11
|
+
solidsmith-0.2.0.dist-info/licenses/LICENSE,sha256=Pi_9yYlugE6MxpMtueVWCRMYJd3KV8AQA6YVfWE0AII,1072
|
|
12
|
+
solidsmith-0.2.0.dist-info/METADATA,sha256=qPLAss-qYRPVWLS4P0h8Ma2Pj_-v-BaCladKd-r6flQ,6603
|
|
13
|
+
solidsmith-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
solidsmith-0.2.0.dist-info/top_level.txt,sha256=hQBsQuh6VQCwkTyNEaDFTcsreGCqXGSXFCv5BqPTHww,11
|
|
15
|
+
solidsmith-0.2.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
solidsmith
|