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 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
+ [![CI](https://github.com/scottshamansky/solidsmith/actions/workflows/ci.yml/badge.svg)](https://github.com/scottshamansky/solidsmith/actions/workflows/ci.yml)
33
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
34
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
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
+ ![parametric honeycomb bin rendered from three angles](docs/images/honeycomb_bin.png)
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
+ ![two-color SDF pebble planter](docs/images/pebble_planter.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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