From 6f1eaf283db85d93142e8d5d86bcd0e6435c46a5 Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Fri, 18 Jul 2025 13:05:43 +0100 Subject: [PATCH] Add scaffolding generation for overhangs --- README.md | 15 ++++++++ parametric_cad/__init__.py | 2 ++ parametric_cad/scaffolding.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_scaffolding.py | 14 ++++++++ 4 files changed, 99 insertions(+) create mode 100644 parametric_cad/scaffolding.py create mode 100644 tests/test_scaffolding.py diff --git a/README.md b/README.md index 9f6c141..5cd2a1f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,21 @@ unioned = combine(boxes) result = safe_difference(unioned, Cylinder(0.5, 1).mesh()) ``` +## Overhang Scaffolding + +`generate_scaffolding` creates simple cylindrical supports beneath +downward facing surfaces that exceed a chosen overhang angle. The supports +are meant to be easy to remove after printing. + +```python +from parametric_cad import generate_scaffolding, Box, combine + +base = Box(20, 20, 10) +ledge = Box(10, 10, 5).at(15, 5, 10) +model = combine([base, ledge]) +supports = generate_scaffolding(model) +``` + ## License This project is licensed under the [MIT License](LICENSE). diff --git a/parametric_cad/__init__.py b/parametric_cad/__init__.py index b66c21c..11a75f3 100644 --- a/parametric_cad/__init__.py +++ b/parametric_cad/__init__.py @@ -11,6 +11,7 @@ from .primitives.sphere import Sphere from .mechanisms.butthinge import ButtHinge from .export.stl import STLExporter from .printability import PrintabilityValidator +from .scaffolding import generate_scaffolding __all__ = [ "tm", @@ -26,6 +27,7 @@ __all__ = [ "ButtHinge", "STLExporter", "PrintabilityValidator", + "generate_scaffolding", "Polygon", "Point", "box", diff --git a/parametric_cad/scaffolding.py b/parametric_cad/scaffolding.py new file mode 100644 index 0000000..2625e4e --- /dev/null +++ b/parametric_cad/scaffolding.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import numpy as np + +from .core import tm + + +def generate_scaffolding( + mesh: tm.Trimesh, + *, + max_angle_deg: float = 45.0, + support_radius: float = 0.5, + grid_size: float = 5.0, + sections: int = 8, +) -> tm.Trimesh: + """Return support scaffolding for downward overhangs of ``mesh``. + + Parameters + ---------- + mesh: + Mesh to analyze for overhangs. + max_angle_deg: + Angles from vertical greater than this require support. + support_radius: + Radius of the cylindrical support columns. + grid_size: + Grid spacing for clustering support columns. ``0`` disables clustering. + sections: + Number of cylinder sections used to generate the columns. + """ + normals = mesh.face_normals + centers = mesh.triangles_center + min_z = float(mesh.bounds[0, 2]) + + angles = np.degrees(np.arccos(np.clip(normals[:, 2], -1.0, 1.0))) + overhang = (angles > 90.0 + max_angle_deg) & (centers[:, 2] > min_z + 1e-6) + if not np.any(overhang): + return tm.Trimesh() + + pts = centers[overhang] + if grid_size > 0: + xy = np.round(pts[:, :2] / grid_size) * grid_size + unique_xy, inverse = np.unique(xy, axis=0, return_inverse=True) + top_z = np.zeros(len(unique_xy)) + for i in range(len(unique_xy)): + top_z[i] = pts[inverse == i][:, 2].max() + else: + unique_xy = pts[:, :2] + top_z = pts[:, 2] + + supports = [] + for (x, y), z in zip(unique_xy, top_z): + height = z - min_z + if height <= 0: + continue + cyl = tm.creation.cylinder( + radius=support_radius, height=height, sections=sections + ) + cyl.apply_translation([0, 0, height / 2]) + cyl.apply_translation([x, y, min_z]) + supports.append(cyl) + + if not supports: + return tm.Trimesh() + return tm.util.concatenate(supports) + + +__all__ = ["generate_scaffolding"] diff --git a/tests/test_scaffolding.py b/tests/test_scaffolding.py new file mode 100644 index 0000000..265229a --- /dev/null +++ b/tests/test_scaffolding.py @@ -0,0 +1,14 @@ +import pytest +from parametric_cad import Box, combine, generate_scaffolding + + +def test_scaffolding_generation(): + base = Box(2.0, 2.0, 1.0) + overhang = Box(1.0, 1.0, 0.5).at(1.5, 0.5, 1.0) + mesh = combine([base, overhang]) + scaff = generate_scaffolding(mesh) + assert isinstance(scaff, type(mesh)) + # Should extend from model base upwards + assert scaff.bounds[0, 2] == pytest.approx(mesh.bounds[0, 2]) + assert scaff.bounds[1, 2] <= mesh.bounds[1, 2] + assert scaff.vertices.shape[0] > 0