ScadPy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. scadpy/__init__.py +5 -0
  2. scadpy/color/__init__.py +3 -0
  3. scadpy/color/constants/BEIGE.py +3 -0
  4. scadpy/color/constants/BLACK.py +3 -0
  5. scadpy/color/constants/BLUE.py +3 -0
  6. scadpy/color/constants/BROWN.py +3 -0
  7. scadpy/color/constants/DARK_GRAY.py +3 -0
  8. scadpy/color/constants/DEFAULT_COLOR.py +3 -0
  9. scadpy/color/constants/DEFAULT_OPACITY.py +1 -0
  10. scadpy/color/constants/GRAY.py +3 -0
  11. scadpy/color/constants/GREEN.py +3 -0
  12. scadpy/color/constants/ORANGE.py +3 -0
  13. scadpy/color/constants/RED.py +3 -0
  14. scadpy/color/constants/WHITE.py +3 -0
  15. scadpy/color/constants/YELLOW.py +3 -0
  16. scadpy/color/constants/__init__.py +29 -0
  17. scadpy/color/type/__init__.py +3 -0
  18. scadpy/color/type/color.py +3 -0
  19. scadpy/color/utils/__init__.py +3 -0
  20. scadpy/color/utils/get_random_color.py +36 -0
  21. scadpy/core/__init__.py +3 -0
  22. scadpy/core/assembly/__init__.py +5 -0
  23. scadpy/core/assembly/combinations/__init__.py +13 -0
  24. scadpy/core/assembly/combinations/concat_assemblies.py +50 -0
  25. scadpy/core/assembly/combinations/exclude_assemblies.py +135 -0
  26. scadpy/core/assembly/combinations/intersect_assemblies.py +128 -0
  27. scadpy/core/assembly/combinations/subtract_assemblies.py +151 -0
  28. scadpy/core/assembly/combinations/unify_assemblies.py +59 -0
  29. scadpy/core/assembly/topologies/__init__.py +41 -0
  30. scadpy/core/assembly/topologies/directed_edge/__init__.py +9 -0
  31. scadpy/core/assembly/topologies/directed_edge/get_assembly_directed_edge_directions.py +70 -0
  32. scadpy/core/assembly/topologies/directed_edge/get_assembly_directed_edge_to_edge.py +49 -0
  33. scadpy/core/assembly/topologies/directed_edge/get_assembly_directed_edge_to_vertex.py +54 -0
  34. scadpy/core/assembly/topologies/edge/__init__.py +9 -0
  35. scadpy/core/assembly/topologies/edge/get_assembly_edge_lengths.py +46 -0
  36. scadpy/core/assembly/topologies/edge/get_assembly_edge_midpoints.py +51 -0
  37. scadpy/core/assembly/topologies/edge/get_assembly_edge_normals.py +67 -0
  38. scadpy/core/assembly/topologies/face_corner/__init__.py +13 -0
  39. scadpy/core/assembly/topologies/face_corner/get_assembly_face_corner_angles.py +72 -0
  40. scadpy/core/assembly/topologies/face_corner/get_assembly_face_corner_normals.py +103 -0
  41. scadpy/core/assembly/topologies/face_corner/get_assembly_face_corner_to_incoming_directed_edge.py +65 -0
  42. scadpy/core/assembly/topologies/face_corner/get_assembly_face_corner_to_outgoing_directed_edge.py +65 -0
  43. scadpy/core/assembly/topologies/face_corner/get_assembly_face_directed_edge_to_corner.py +79 -0
  44. scadpy/core/assembly/topologies/part/__init__.py +5 -0
  45. scadpy/core/assembly/topologies/part/get_assembly_part_colors.py +55 -0
  46. scadpy/core/assembly/topologies/vertex/__init__.py +7 -0
  47. scadpy/core/assembly/topologies/vertex/get_assembly_vertex_coordinates.py +70 -0
  48. scadpy/core/assembly/topologies/vertex/get_assembly_vertex_to_part.py +62 -0
  49. scadpy/core/assembly/transformations/__init__.py +19 -0
  50. scadpy/core/assembly/transformations/color_assembly.py +24 -0
  51. scadpy/core/assembly/transformations/mirror_vertex_coordinates.py +68 -0
  52. scadpy/core/assembly/transformations/pull_vertex_coordinates.py +64 -0
  53. scadpy/core/assembly/transformations/push_vertex_coordinates.py +64 -0
  54. scadpy/core/assembly/transformations/resize_vertex_coordinates.py +121 -0
  55. scadpy/core/assembly/transformations/rotate_vertex_coordinates.py +73 -0
  56. scadpy/core/assembly/transformations/scale_vertex_coordinates.py +76 -0
  57. scadpy/core/assembly/transformations/translate_vertex_coordinates.py +70 -0
  58. scadpy/core/assembly/types/__init__.py +7 -0
  59. scadpy/core/assembly/types/assembly.py +14 -0
  60. scadpy/core/assembly/types/topology_filter.py +6 -0
  61. scadpy/core/assembly/utils/__init__.py +9 -0
  62. scadpy/core/assembly/utils/lookup_pairs.py +56 -0
  63. scadpy/core/assembly/utils/resolve_topology_filter.py +84 -0
  64. scadpy/core/assembly/utils/transform_filtered_parts.py +55 -0
  65. scadpy/core/component/__init__.py +3 -0
  66. scadpy/core/component/exporters/__init__.py +7 -0
  67. scadpy/core/component/exporters/map_component_to_html_file.py +47 -0
  68. scadpy/core/component/exporters/map_component_to_screen.py +63 -0
  69. scadpy/core/component/features/__init__.py +5 -0
  70. scadpy/core/component/features/get_component_bounds.py +38 -0
  71. scadpy/core/component/utils/__init__.py +9 -0
  72. scadpy/core/component/utils/blend_component_colors.py +77 -0
  73. scadpy/core/component/utils/get_intersecting_component_index_groups.py +108 -0
  74. scadpy/core/part/__init__.py +3 -0
  75. scadpy/core/part/combinations/__init__.py +11 -0
  76. scadpy/core/part/combinations/concat_parts.py +48 -0
  77. scadpy/core/part/combinations/intersect_parts.py +147 -0
  78. scadpy/core/part/combinations/subtract_parts.py +94 -0
  79. scadpy/core/part/combinations/unify_parts.py +143 -0
  80. scadpy/core/part/types/__init__.py +5 -0
  81. scadpy/core/part/types/part.py +34 -0
  82. scadpy/core/part/utils/__init__.py +5 -0
  83. scadpy/core/part/utils/blend_part_colors.py +32 -0
  84. scadpy/d2/__init__.py +2 -0
  85. scadpy/d2/shape/__init__.py +9 -0
  86. scadpy/d2/shape/combinations/__init__.py +21 -0
  87. scadpy/d2/shape/combinations/are_shape_parts_intersecting.py +49 -0
  88. scadpy/d2/shape/combinations/concat_shape.py +48 -0
  89. scadpy/d2/shape/combinations/exclude_shape.py +71 -0
  90. scadpy/d2/shape/combinations/intersect_shape.py +64 -0
  91. scadpy/d2/shape/combinations/intersect_shape_parts.py +71 -0
  92. scadpy/d2/shape/combinations/subtract_shape.py +72 -0
  93. scadpy/d2/shape/combinations/subtract_shape_parts.py +66 -0
  94. scadpy/d2/shape/combinations/unify_shape.py +51 -0
  95. scadpy/d2/shape/combinations/unify_shape_parts.py +74 -0
  96. scadpy/d2/shape/exporters/__init__.py +17 -0
  97. scadpy/d2/shape/exporters/map_shape_to_dxf.py +43 -0
  98. scadpy/d2/shape/exporters/map_shape_to_dxf_file.py +38 -0
  99. scadpy/d2/shape/exporters/map_shape_to_html.py +117 -0
  100. scadpy/d2/shape/exporters/map_shape_to_html_file.py +58 -0
  101. scadpy/d2/shape/exporters/map_shape_to_screen.py +51 -0
  102. scadpy/d2/shape/exporters/map_shape_to_svg.py +40 -0
  103. scadpy/d2/shape/exporters/map_shape_to_svg_file.py +38 -0
  104. scadpy/d2/shape/features/__init__.py +9 -0
  105. scadpy/d2/shape/features/get_shape_bounds.py +40 -0
  106. scadpy/d2/shape/features/get_shape_part_bounds.py +37 -0
  107. scadpy/d2/shape/features/is_shape_empty.py +38 -0
  108. scadpy/d2/shape/importers/__init__.py +13 -0
  109. scadpy/d2/shape/importers/map_dxf_to_shape.py +55 -0
  110. scadpy/d2/shape/importers/map_geometries_to_shape.py +45 -0
  111. scadpy/d2/shape/importers/map_geometry_to_shape.py +43 -0
  112. scadpy/d2/shape/importers/map_parts_to_shape.py +62 -0
  113. scadpy/d2/shape/importers/map_svg_to_shape.py +55 -0
  114. scadpy/d2/shape/primitives/__init__.py +6 -0
  115. scadpy/d2/shape/primitives/circle.py +68 -0
  116. scadpy/d2/shape/primitives/polygon.py +86 -0
  117. scadpy/d2/shape/primitives/rectangle.py +85 -0
  118. scadpy/d2/shape/primitives/square.py +57 -0
  119. scadpy/d2/shape/topologies/__init__.py +53 -0
  120. scadpy/d2/shape/topologies/corner/__init__.py +15 -0
  121. scadpy/d2/shape/topologies/corner/are_shape_corners_convex.py +75 -0
  122. scadpy/d2/shape/topologies/corner/get_shape_corner_angles.py +58 -0
  123. scadpy/d2/shape/topologies/corner/get_shape_corner_normals.py +82 -0
  124. scadpy/d2/shape/topologies/corner/get_shape_corner_to_incoming_directed_edge.py +39 -0
  125. scadpy/d2/shape/topologies/corner/get_shape_corner_to_outgoing_directed_edge.py +39 -0
  126. scadpy/d2/shape/topologies/corner/get_shape_corner_to_vertex.py +65 -0
  127. scadpy/d2/shape/topologies/directed_edge/__init__.py +11 -0
  128. scadpy/d2/shape/topologies/directed_edge/get_shape_directed_edge_directions.py +44 -0
  129. scadpy/d2/shape/topologies/directed_edge/get_shape_directed_edge_to_corner.py +41 -0
  130. scadpy/d2/shape/topologies/directed_edge/get_shape_directed_edge_to_edge.py +51 -0
  131. scadpy/d2/shape/topologies/directed_edge/get_shape_directed_edge_to_vertex.py +63 -0
  132. scadpy/d2/shape/topologies/edge/__init__.py +11 -0
  133. scadpy/d2/shape/topologies/edge/get_shape_edge_lengths.py +43 -0
  134. scadpy/d2/shape/topologies/edge/get_shape_edge_midpoints.py +46 -0
  135. scadpy/d2/shape/topologies/edge/get_shape_edge_normals.py +40 -0
  136. scadpy/d2/shape/topologies/edge/get_shape_edge_to_vertex.py +71 -0
  137. scadpy/d2/shape/topologies/ring/__init__.py +7 -0
  138. scadpy/d2/shape/topologies/ring/get_shape_ring_to_part.py +46 -0
  139. scadpy/d2/shape/topologies/ring/get_shape_ring_types.py +46 -0
  140. scadpy/d2/shape/topologies/vertex/__init__.py +11 -0
  141. scadpy/d2/shape/topologies/vertex/get_shape_part_vertex_coordinates.py +62 -0
  142. scadpy/d2/shape/topologies/vertex/get_shape_vertex_coordinates.py +44 -0
  143. scadpy/d2/shape/topologies/vertex/get_shape_vertex_to_part.py +42 -0
  144. scadpy/d2/shape/topologies/vertex/get_shape_vertex_to_ring.py +63 -0
  145. scadpy/d2/shape/transformations/__init__.py +43 -0
  146. scadpy/d2/shape/transformations/chamfer_shape.py +259 -0
  147. scadpy/d2/shape/transformations/color_shape.py +46 -0
  148. scadpy/d2/shape/transformations/convexify_shape.py +79 -0
  149. scadpy/d2/shape/transformations/fill_shape.py +68 -0
  150. scadpy/d2/shape/transformations/fillet_shape.py +289 -0
  151. scadpy/d2/shape/transformations/grow_shape.py +82 -0
  152. scadpy/d2/shape/transformations/linear_cut_shape.py +116 -0
  153. scadpy/d2/shape/transformations/linear_extrude_shape.py +60 -0
  154. scadpy/d2/shape/transformations/linear_slice_shape.py +144 -0
  155. scadpy/d2/shape/transformations/mirror_shape.py +53 -0
  156. scadpy/d2/shape/transformations/pull_shape.py +67 -0
  157. scadpy/d2/shape/transformations/push_shape.py +67 -0
  158. scadpy/d2/shape/transformations/radial_extrude_shape.py +285 -0
  159. scadpy/d2/shape/transformations/radial_slice_shape.py +132 -0
  160. scadpy/d2/shape/transformations/recoordinate_shape.py +82 -0
  161. scadpy/d2/shape/transformations/resize_shape.py +91 -0
  162. scadpy/d2/shape/transformations/rotate_shape.py +63 -0
  163. scadpy/d2/shape/transformations/scale_shape.py +58 -0
  164. scadpy/d2/shape/transformations/shrink_shape.py +69 -0
  165. scadpy/d2/shape/transformations/translate_shape.py +54 -0
  166. scadpy/d2/shape/types/__init__.py +3 -0
  167. scadpy/d2/shape/types/shape.py +792 -0
  168. scadpy/d2/shape/types/utils/__init__.py +5 -0
  169. scadpy/d2/shape/types/utils/shapely_base_geometry_to_shapely_polygons.py +25 -0
  170. scadpy/d2/shape/utils/__init__.py +5 -0
  171. scadpy/d2/shape/utils/shapely_base_geometry_to_shapely_polygons.py +55 -0
  172. scadpy/d2/utils/__init__.py +3 -0
  173. scadpy/d2/utils/resolve_vector_2d.py +50 -0
  174. scadpy/d3/__init__.py +2 -0
  175. scadpy/d3/solid/__init__.py +8 -0
  176. scadpy/d3/solid/combinations/__init__.py +21 -0
  177. scadpy/d3/solid/combinations/are_solid_parts_intersecting.py +51 -0
  178. scadpy/d3/solid/combinations/concat_solid.py +48 -0
  179. scadpy/d3/solid/combinations/exclude_solid.py +71 -0
  180. scadpy/d3/solid/combinations/intersect_solid.py +64 -0
  181. scadpy/d3/solid/combinations/intersect_solid_parts.py +73 -0
  182. scadpy/d3/solid/combinations/subtract_solid.py +72 -0
  183. scadpy/d3/solid/combinations/subtract_solid_parts.py +68 -0
  184. scadpy/d3/solid/combinations/unify_solid.py +51 -0
  185. scadpy/d3/solid/combinations/unify_solid_parts.py +73 -0
  186. scadpy/d3/solid/exporters/__init__.py +11 -0
  187. scadpy/d3/solid/exporters/map_solid_to_html.py +318 -0
  188. scadpy/d3/solid/exporters/map_solid_to_html_file.py +58 -0
  189. scadpy/d3/solid/exporters/map_solid_to_screen.py +51 -0
  190. scadpy/d3/solid/exporters/map_solid_to_stl_file.py +48 -0
  191. scadpy/d3/solid/features/__init__.py +11 -0
  192. scadpy/d3/solid/features/get_solid_bounds.py +37 -0
  193. scadpy/d3/solid/features/get_solid_part_bounds.py +37 -0
  194. scadpy/d3/solid/features/get_solid_part_colors.py +39 -0
  195. scadpy/d3/solid/features/is_solid_empty.py +36 -0
  196. scadpy/d3/solid/importers/__init__.py +11 -0
  197. scadpy/d3/solid/importers/map_geometries_to_solid.py +42 -0
  198. scadpy/d3/solid/importers/map_geometry_to_solid.py +42 -0
  199. scadpy/d3/solid/importers/map_parts_to_solid.py +66 -0
  200. scadpy/d3/solid/importers/map_stl_to_solid.py +37 -0
  201. scadpy/d3/solid/primitives/__init__.py +7 -0
  202. scadpy/d3/solid/primitives/cone.py +70 -0
  203. scadpy/d3/solid/primitives/cuboid.py +75 -0
  204. scadpy/d3/solid/primitives/cylinder.py +73 -0
  205. scadpy/d3/solid/primitives/polyhedron.py +60 -0
  206. scadpy/d3/solid/primitives/sphere.py +58 -0
  207. scadpy/d3/solid/topologies/__init__.py +8 -0
  208. scadpy/d3/solid/topologies/triangle/__init__.py +5 -0
  209. scadpy/d3/solid/topologies/triangle/get_solid_triangle_to_vertex.py +49 -0
  210. scadpy/d3/solid/topologies/vertex/__init__.py +7 -0
  211. scadpy/d3/solid/topologies/vertex/get_solid_vertex_coordinates.py +39 -0
  212. scadpy/d3/solid/topologies/vertex/get_solid_vertex_to_part.py +37 -0
  213. scadpy/d3/solid/transformations/__init__.py +23 -0
  214. scadpy/d3/solid/transformations/color_solid.py +46 -0
  215. scadpy/d3/solid/transformations/convexify_solid.py +64 -0
  216. scadpy/d3/solid/transformations/mirror_solid.py +53 -0
  217. scadpy/d3/solid/transformations/pull_solid.py +67 -0
  218. scadpy/d3/solid/transformations/push_solid.py +67 -0
  219. scadpy/d3/solid/transformations/recoordinate_solid.py +68 -0
  220. scadpy/d3/solid/transformations/resize_solid.py +92 -0
  221. scadpy/d3/solid/transformations/rotate_solid.py +93 -0
  222. scadpy/d3/solid/transformations/scale_solid.py +58 -0
  223. scadpy/d3/solid/transformations/translate_solid.py +54 -0
  224. scadpy/d3/solid/types/__init__.py +3 -0
  225. scadpy/d3/solid/types/solid.py +448 -0
  226. scadpy/d3/utils/__init__.py +3 -0
  227. scadpy/d3/utils/resolve_vector_3d.py +50 -0
  228. scadpy/utils/__init__.py +6 -0
  229. scadpy/utils/resolve_vector.py +64 -0
  230. scadpy/utils/x.py +38 -0
  231. scadpy/utils/y.py +38 -0
  232. scadpy/utils/z.py +38 -0
  233. scadpy-0.1.0.dist-info/METADATA +282 -0
  234. scadpy-0.1.0.dist-info/RECORD +236 -0
  235. scadpy-0.1.0.dist-info/WHEEL +4 -0
  236. scadpy-0.1.0.dist-info/licenses/LICENSE.md +43 -0
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+ from shapely.geometry import Polygon
7
+ from typeguard import typechecked
8
+
9
+ if TYPE_CHECKING:
10
+ from scadpy import Shape, TopologyFilter
11
+
12
+
13
+ @typechecked
14
+ def fillet_shape(
15
+ shape: Shape,
16
+ size: float | np.ndarray,
17
+ corner_filter: TopologyFilter[Shape] | None = None,
18
+ segment_count: int = 32,
19
+ epsilon: float = 1e-8,
20
+ ) -> Shape:
21
+ """
22
+ Apply a fillet (circular arc) to every corner of a shape.
23
+
24
+ Convex corners are rounded; concave corners are filled with a circular arc.
25
+
26
+ Parameters
27
+ ----------
28
+ shape : Shape
29
+ The input shape to fillet.
30
+ size : float or ndarray
31
+ Fillet size: distance from the corner vertex to each tangent point along
32
+ the edges. Can be:
33
+
34
+ - ``float``: same size on both sides of every corner.
35
+ - ``(n_active,)``: per-active-corner size, same on both sides.
36
+ - ``(n_active, 2)``: per-active-corner, per-side size. Column 0 is
37
+ the incoming side, column 1 is the outgoing side.
38
+
39
+ ``n_active`` is the number of corners selected by ``corner_filter``
40
+ (or all corners if no filter). In all cases, each value is
41
+ automatically clamped to half the length of the corresponding edge
42
+ to avoid overlapping tangent points.
43
+ corner_filter : TopologyFilter[Shape] | None, optional
44
+ Boolean mask or callable ``(shape) -> NDArray[bool]`` of length ``n_corners``
45
+ selecting which corners to fillet. If None, all corners are filleted.
46
+ segment_count : int, optional
47
+ Number of arc segments per corner. Defaults to 32.
48
+ epsilon : float, optional
49
+ Small offset used to avoid coincident edges in boolean operations.
50
+ Defaults to ``1e-8``.
51
+
52
+ Returns
53
+ -------
54
+ Shape
55
+ A new shape with filleted corners.
56
+
57
+ Examples
58
+ --------
59
+ >>> from scadpy import square, polygon, fillet_shape
60
+
61
+ >>> sq = square(4)
62
+ >>> l_shape = polygon(
63
+ ... [(0, 0), (4, 0), (4, 2), (2, 2), (2, 4), (0, 4)]
64
+ ... )
65
+ >>> arrow = polygon(
66
+ ... [(0, 1), (3, 0), (5, 2), (3, 4),
67
+ ... (0, 3), (1, 2.5), (1, 1.5)]
68
+ ... )
69
+
70
+ >>> # all corners
71
+ >>> fillet_shape(sq, size=1.0) # doctest: +SKIP
72
+
73
+ .. render-example::
74
+ :name: fillet_shape_all
75
+ :example: fillet_shape(sq, size=1.0)
76
+ :ghost: sq
77
+
78
+ >>> # convex corners only (the 5 outer corners of the L-shape)
79
+ >>> fillet_shape( # doctest: +SKIP
80
+ ... l_shape, size=0.5,
81
+ ... corner_filter=lambda s: s.are_corners_convex,
82
+ ... )
83
+
84
+ .. render-example::
85
+ :name: fillet_shape_convex_only
86
+ :example: fillet_shape(l_shape, size=0.5, corner_filter=lambda s: s.are_corners_convex)
87
+ :ghost: l_shape
88
+
89
+ >>> import numpy as np
90
+
91
+ >>> # asymmetric: one side fills the full edge (length 2),
92
+ >>> # the other stays at 1.0
93
+ >>> fillet_shape( # doctest: +SKIP
94
+ ... l_shape, size=np.array([[2.0, 1.0]]),
95
+ ... corner_filter=lambda s: ~s.are_corners_convex,
96
+ ... )
97
+
98
+ .. render-example::
99
+ :name: fillet_shape_concave_asymmetric
100
+ :example: fillet_shape(l_shape, size=np.array([[2.0, 1.0]]), corner_filter=lambda s: ~s.are_corners_convex)
101
+ :ghost: l_shape
102
+
103
+ >>> # only sharp convex corners (angle > 100°):
104
+ >>> # the two 135° corners of the arrow tail
105
+ >>> fillet_shape( # doctest: +SKIP
106
+ ... arrow, size=0.4,
107
+ ... corner_filter=lambda s: (
108
+ ... s.are_corners_convex & (s.corner_angles > 100)
109
+ ... ),
110
+ ... )
111
+
112
+ .. render-example::
113
+ :name: fillet_shape_sharp_convex
114
+ :example: fillet_shape(arrow, size=0.4, corner_filter=lambda s: s.are_corners_convex & (s.corner_angles > 100))
115
+ :ghost: arrow
116
+
117
+ >>> # oversized: the 2 concave corners share an edge of length ~1;
118
+ >>> # size=10 is clamped proportionally so their contributions
119
+ >>> # sum to the edge length (0.5 + 0.5 each)
120
+ >>> fillet_shape( # doctest: +SKIP
121
+ ... arrow, size=10,
122
+ ... corner_filter=lambda s: ~s.are_corners_convex,
123
+ ... )
124
+
125
+ .. render-example::
126
+ :name: fillet_shape_clamp_proportional
127
+ :example: fillet_shape(arrow, size=10, corner_filter=lambda s: ~s.are_corners_convex)
128
+ :ghost: arrow
129
+
130
+ >>> # wrong size length raises ValueError
131
+ >>> fillet_shape(
132
+ ... sq, size=np.array([0.5, 0.5, 0.5])
133
+ ... ) # doctest: +ELLIPSIS
134
+ Traceback (most recent call last):
135
+ ...
136
+ ValueError: size array shape (3, 2) does not match...
137
+ """
138
+ from scadpy import resolve_topology_filter, Shape
139
+
140
+ corner_to_vertex = shape.corner_to_vertex
141
+ if len(corner_to_vertex) == 0:
142
+ return shape
143
+
144
+ vertex_coordinates = shape.vertex_coordinates
145
+ is_corner_convex = shape.are_corners_convex
146
+
147
+ active_mask = resolve_topology_filter(shape, len(corner_to_vertex), corner_filter)
148
+ if active_mask is not None and not np.any(active_mask):
149
+ return shape
150
+
151
+ # Filter all per-corner data to active corners only
152
+ active_indices = (
153
+ np.where(active_mask)[0]
154
+ if active_mask is not None
155
+ else np.arange(len(corner_to_vertex))
156
+ )
157
+ active_is_corner_convex = is_corner_convex[active_indices]
158
+
159
+ current_vertices = vertex_coordinates[corner_to_vertex[active_indices, 1]]
160
+
161
+ incoming_de = shape.corner_to_incoming_directed_edge[active_indices]
162
+ outgoing_de = shape.corner_to_outgoing_directed_edge[active_indices]
163
+ incoming_edge = shape.directed_edge_to_edge[incoming_de]
164
+ outgoing_edge = shape.directed_edge_to_edge[outgoing_de]
165
+
166
+ incoming_directions_normalized = shape.directed_edge_directions[incoming_de]
167
+ outgoing_directions_normalized = shape.directed_edge_directions[outgoing_de]
168
+ edge_lengths_incoming = shape.edge_lengths[incoming_edge]
169
+ edge_lengths_outgoing = shape.edge_lengths[outgoing_edge]
170
+ outward_normals_incoming = shape.edge_normals[incoming_edge]
171
+ outward_normals_outgoing = shape.edge_normals[outgoing_edge]
172
+
173
+ # Resolve size to (n_active, 2): column 0 = incoming side, column 1 = outgoing side
174
+ n_active = len(current_vertices)
175
+ sizes = np.asarray(size, dtype=np.float64)
176
+ if sizes.ndim == 0:
177
+ sizes = np.full((n_active, 2), sizes)
178
+ elif sizes.ndim == 1:
179
+ sizes = np.stack([sizes, sizes], axis=1)
180
+ if sizes.shape != (n_active, 2):
181
+ raise ValueError(
182
+ f"size array shape {sizes.shape} does not match "
183
+ f"expected ({n_active}, 2) for {n_active} active corners"
184
+ )
185
+ # Clamp sizes proportionally so adjacent corners don't overlap on a shared edge.
186
+ # For each active corner, find the adjacent corner on its outgoing/incoming edge.
187
+ # If both are active: scale both contributions so they sum to at most edge_length.
188
+ # If only one is active: it can use the full edge length.
189
+ active_index_of = np.full(len(shape.corner_to_vertex), -1, dtype=np.int64)
190
+ active_index_of[active_indices] = np.arange(n_active, dtype=np.int64)
191
+ de_to_corner = shape.directed_edge_to_corner
192
+ sizes_orig = sizes.copy()
193
+
194
+ adj_target_out = de_to_corner[outgoing_de, 1]
195
+ adj_idx_out = active_index_of[adj_target_out]
196
+ adj_size_out = np.where(adj_idx_out >= 0, sizes_orig[adj_idx_out.clip(0), 0], 0.0)
197
+ total_out = sizes_orig[:, 1] + adj_size_out
198
+ scale_out = np.where(
199
+ total_out > edge_lengths_outgoing, edge_lengths_outgoing / total_out, 1.0
200
+ )
201
+ sizes[:, 1] *= scale_out
202
+
203
+ adj_source_in = de_to_corner[incoming_de, 0]
204
+ adj_idx_in = active_index_of[adj_source_in]
205
+ adj_size_in = np.where(adj_idx_in >= 0, sizes_orig[adj_idx_in.clip(0), 1], 0.0)
206
+ total_in = sizes_orig[:, 0] + adj_size_in
207
+ scale_in = np.where(
208
+ total_in > edge_lengths_incoming, edge_lengths_incoming / total_in, 1.0
209
+ )
210
+ sizes[:, 0] *= scale_in
211
+
212
+ signs = np.where(active_is_corner_convex, 1.0, -1.0)
213
+
214
+ # Tangent points: slightly beyond size to avoid coincident inner points
215
+ tangent_points_incoming = current_vertices - incoming_directions_normalized * (
216
+ sizes[:, 0:1] + epsilon
217
+ )
218
+ tangent_points_outgoing = current_vertices + outgoing_directions_normalized * (
219
+ sizes[:, 1:2] + epsilon
220
+ )
221
+
222
+ # Outer offsets: perpendicular to edge to avoid coincident outer points
223
+ tangent_points_incoming_outer = (
224
+ tangent_points_incoming
225
+ + signs[:, np.newaxis] * outward_normals_incoming * epsilon
226
+ )
227
+ tangent_points_outgoing_outer = (
228
+ tangent_points_outgoing
229
+ + signs[:, np.newaxis] * outward_normals_outgoing * epsilon
230
+ )
231
+
232
+ # Extended corner vertex: push outward along the bisector
233
+ corner_normals = shape.corner_normals[active_indices]
234
+ current_vertices_extended = current_vertices + corner_normals * epsilon
235
+
236
+ # Elliptic arc centered on the corner vertex:
237
+ # P(t) = corner - a*cos(t)*incoming_dir + b*sin(t)*outgoing_dir, t ∈ [0, π/2]
238
+ # At t=0: corner - a*incoming_dir = tp_in
239
+ # At t=π/2: corner + b*outgoing_dir = tp_out
240
+ # The arc bulges away from the corner (toward the shape interior for convex corners).
241
+ t_values = np.linspace(0.0, np.pi / 2, segment_count) # (segment_count,)
242
+ cos_t = np.cos(t_values) # (segment_count,)
243
+ sin_t = np.sin(t_values) # (segment_count,)
244
+
245
+ cutters: list[Polygon] = []
246
+ fillers: list[Polygon] = []
247
+ for i in range(n_active):
248
+ a = sizes[i, 0] + epsilon
249
+ b = sizes[i, 1] + epsilon
250
+ corner_ext = current_vertices_extended[i]
251
+ inc = incoming_directions_normalized[i]
252
+ out = outgoing_directions_normalized[i]
253
+
254
+ # Elliptic arc points using epsilon-extended sizes: (segment_count, 2)
255
+ arc_points = (
256
+ current_vertices[i]
257
+ - a * cos_t[:, np.newaxis] * inc
258
+ + b * sin_t[:, np.newaxis] * out
259
+ )
260
+
261
+ # Double mirror = 180° rotation around midpoint of p0→p1
262
+ mid = (arc_points[0] + arc_points[-1]) / 2
263
+ arc_points = 2 * mid - arc_points
264
+
265
+ # Polygon: extended corner → outer tp_in → arc → outer tp_out
266
+ # For convex: corner_ext is outside the arc → polygon is the wedge to cut
267
+ # For concave: corner_ext is inside the arc → polygon is the notch to fill
268
+ polygon = Polygon(
269
+ [
270
+ corner_ext,
271
+ tangent_points_outgoing_outer[i],
272
+ *arc_points,
273
+ tangent_points_incoming_outer[i],
274
+ ]
275
+ )
276
+ if polygon.is_empty or not polygon.is_valid:
277
+ continue
278
+ if active_is_corner_convex[i]:
279
+ cutters.append(polygon)
280
+ else:
281
+ fillers.append(polygon)
282
+
283
+ result = shape
284
+ if cutters:
285
+ result = result - Shape.from_geometries(cutters)
286
+ if fillers:
287
+ result = result | Shape.from_geometries(fillers)
288
+
289
+ return result
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from shapely.geometry import Polygon
6
+ from typeguard import typechecked
7
+
8
+ if TYPE_CHECKING:
9
+ from scadpy import Shape, TopologyFilter
10
+
11
+
12
+ @typechecked
13
+ def grow_shape(
14
+ shape: Shape, distance: float, part_filter: TopologyFilter[Shape] | None = None
15
+ ) -> Shape:
16
+ """
17
+ Grow or shrink each selected part by offsetting its boundary by a given distance.
18
+
19
+ A positive distance expands the shape outward, a negative distance shrinks it
20
+ inward. The offset uses mitre joins to preserve sharp corners.
21
+
22
+ Parameters
23
+ ----------
24
+ shape : Shape
25
+ The input shape whose parts will be grown.
26
+ distance : float
27
+ The offset distance. Positive values expand, negative values shrink.
28
+ part_filter : TopologyFilter[Shape] | None, optional
29
+ A boolean mask selecting which parts to grow. If None, all parts are grown.
30
+
31
+ Returns
32
+ -------
33
+ Shape
34
+ A new shape with the selected parts grown and the unselected parts unchanged.
35
+
36
+ Examples
37
+ --------
38
+ >>> from scadpy import grow_shape, square
39
+ >>> import numpy as np
40
+
41
+ >>> shape = square(10)
42
+ >>> grow_shape(shape, 2) # doctest: +SKIP
43
+
44
+ .. render-example::
45
+ :name: grow_shape
46
+ :example: grow_shape(shape, 2)
47
+ :ghost: shape
48
+
49
+ >>> # shrink with negative distance
50
+ >>> grow_shape(shape, -2) # doctest: +SKIP
51
+
52
+ .. render-example::
53
+ :name: grow_shape_shrink
54
+ :example: grow_shape(shape, -2)
55
+ :ghost: shape
56
+
57
+ >>> # partial grow
58
+ >>> a = square(4)
59
+ >>> b = square(2).translate(10)
60
+ >>> grow_shape( # doctest: +SKIP
61
+ ... a + b, 1, part_filter=np.array([True, False])
62
+ ... )
63
+
64
+ .. render-example::
65
+ :name: grow_shape_partial
66
+ :example: grow_shape(a + b, 1, part_filter=np.array([True, False]))
67
+ :ghost: a + b
68
+ """
69
+ from scadpy import Part, Shape, transform_filtered_parts
70
+
71
+ return transform_filtered_parts(
72
+ assembly=shape,
73
+ parts=shape._parts,
74
+ part_filter=part_filter,
75
+ transform=lambda parts: [
76
+ Part[Polygon].from_geometry(
77
+ p.geometry.buffer(distance, join_style="mitre"), p.color
78
+ )
79
+ for p in parts
80
+ ],
81
+ concat_parts=Shape.from_parts,
82
+ )
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ from typeguard import typechecked
8
+
9
+ if TYPE_CHECKING:
10
+ from scadpy.d2.shape import Shape
11
+
12
+
13
+ def _make_half_plane(
14
+ size: float,
15
+ axis_x: float,
16
+ axis_y: float,
17
+ pivot_x: float,
18
+ pivot_y: float,
19
+ positive: bool,
20
+ ) -> Shape:
21
+ """Large polygon covering one half-plane relative to an axis line."""
22
+ from scadpy.d2.shape.primitives.polygon import polygon
23
+
24
+ radial_x, radial_y = (axis_y, -axis_x) if positive else (-axis_y, axis_x)
25
+ return polygon(
26
+ [
27
+ [pivot_x - size * axis_x, pivot_y - size * axis_y],
28
+ [pivot_x + size * axis_x, pivot_y + size * axis_y],
29
+ [
30
+ pivot_x + size * axis_x + size * radial_x,
31
+ pivot_y + size * axis_y + size * radial_y,
32
+ ],
33
+ [
34
+ pivot_x - size * axis_x + size * radial_x,
35
+ pivot_y - size * axis_y + size * radial_y,
36
+ ],
37
+ ]
38
+ )
39
+
40
+
41
+ @typechecked
42
+ def linear_cut_shape(
43
+ shape: Shape,
44
+ axis: float | Iterable[float],
45
+ pivot: float | Iterable[float] = 0,
46
+ ) -> Shape:
47
+ """Cut a shape along an axis line through a pivot point.
48
+
49
+ The axis line divides the plane into two half-planes and the shape
50
+ is split accordingly. Both halves are returned as a single
51
+ :class:`Shape` whose parts are the individual cut pieces.
52
+
53
+ The *positive* side is 90° clockwise from the axis direction. For
54
+ ``axis=[0, 1]`` (Y-axis) this means points with ``x >= 0``; for
55
+ ``axis=[1, 0]`` (X-axis) this means points with ``y <= 0``.
56
+
57
+ Parameters
58
+ ----------
59
+ shape : Shape
60
+ The shape to cut.
61
+ axis : float or Iterable[float]
62
+ Direction of the cut line (2D vector).
63
+ pivot : float or Iterable[float], optional
64
+ A point on the cut line. Default is the origin ``0``.
65
+
66
+ Returns
67
+ -------
68
+ Shape
69
+ A new shape whose parts are the two halves of the original shape
70
+ concatenated together.
71
+
72
+ Examples
73
+ --------
74
+ >>> from scadpy import linear_cut_shape, square, circle
75
+
76
+ >>> shape = square(6) - circle(2)
77
+
78
+ >>> # vertical cut along the Y-axis
79
+ >>> linear_cut_shape(shape, axis=[0, 1], pivot=[-1, 0]) # doctest: +SKIP
80
+
81
+ .. render-example::
82
+ :name: linear_cut_shape
83
+ :example: linear_cut_shape(shape, axis=[0, 1], pivot=[-1, 0])
84
+ :ghost: shape
85
+
86
+ >>> # diagonal cut: axis=[1, 1]
87
+ >>> linear_cut_shape(shape, axis=[1, 1]) # doctest: +SKIP
88
+
89
+ .. render-example::
90
+ :name: linear_cut_shape_diagonal
91
+ :example: linear_cut_shape(shape, axis=[1, 1])
92
+ :ghost: shape
93
+ """
94
+ from scadpy import resolve_vector_2d
95
+
96
+ axis = resolve_vector_2d(axis, 0)
97
+ axis = axis / np.linalg.norm(axis)
98
+ pivot = resolve_vector_2d(pivot, 0)
99
+
100
+ bounds = shape.bounds
101
+ size = float(np.max(np.abs(bounds))) * 10 + 100
102
+
103
+ axis_x, axis_y = float(axis[0]), float(axis[1])
104
+ pivot_x, pivot_y = float(pivot[0]), float(pivot[1])
105
+
106
+ positive_half_plane = _make_half_plane(
107
+ size, axis_x, axis_y, pivot_x, pivot_y, positive=True
108
+ )
109
+ negative_half_plane = _make_half_plane(
110
+ size, axis_x, axis_y, pivot_x, pivot_y, positive=False
111
+ )
112
+
113
+ positive_side = shape - negative_half_plane
114
+ negative_side = shape - positive_half_plane
115
+
116
+ return positive_side + negative_side
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from trimesh import Trimesh
6
+ from trimesh.creation import extrude_polygon
7
+ from typeguard import typechecked
8
+
9
+ if TYPE_CHECKING:
10
+ from scadpy.d2.shape import Shape
11
+ from scadpy.d3.solid import Solid
12
+
13
+
14
+ @typechecked
15
+ def linear_extrude_shape(shape: Shape, height: float) -> Solid:
16
+ """
17
+ Extrude a 2D shape along the Z axis into a 3D solid.
18
+
19
+ Each part of the shape is extruded vertically by the given height,
20
+ producing a solid with the same cross-section throughout.
21
+
22
+ Parameters
23
+ ----------
24
+ shape : Shape
25
+ The 2D shape to extrude.
26
+ height : float
27
+ The extrusion height along the Z axis.
28
+
29
+ Returns
30
+ -------
31
+ Solid
32
+ A 3D solid created by extruding the shape.
33
+
34
+ Examples
35
+ --------
36
+ >>> from scadpy import linear_extrude_shape, square, circle
37
+
38
+ >>> # simple box
39
+ >>> linear_extrude_shape(square(10), height=5) # doctest: +SKIP
40
+
41
+ .. render-example::
42
+ :name: linear_extrude_shape_square
43
+ :example: linear_extrude_shape(square(10), height=5)
44
+
45
+ >>> # tube from a hollow circle
46
+ >>> linear_extrude_shape( # doctest: +SKIP
47
+ ... circle(5) - circle(3), height=10
48
+ ... )
49
+
50
+ .. render-example::
51
+ :name: linear_extrude_shape_tube
52
+ :example: linear_extrude_shape(circle(5) - circle(3), height=10)
53
+ """
54
+ from scadpy import Part, Solid
55
+
56
+ solid_parts = [
57
+ Part[Trimesh].from_geometry(extrude_polygon(p.geometry, height), p.color)
58
+ for p in shape._parts
59
+ ]
60
+ return Solid.from_parts(solid_parts)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ from shapely.geometry.polygon import Polygon
8
+ from typeguard import typechecked
9
+
10
+ if TYPE_CHECKING:
11
+ from scadpy import Shape, TopologyFilter
12
+
13
+
14
+ @typechecked
15
+ def linear_slice_shape(
16
+ shape: Shape,
17
+ thickness: float,
18
+ direction: float | Iterable[float],
19
+ pivot: float | Iterable[float] = 0,
20
+ part_filter: TopologyFilter[Shape] | None = None,
21
+ ) -> Shape:
22
+ """
23
+ Slice a shape along a directed line, keeping only a strip of given thickness.
24
+
25
+ Constructs an oriented rectangle along the specified direction, centered on the
26
+ pivot point, and intersects it with the selected parts. The rectangle is wide
27
+ enough to cover the entire shape, so only the strip of the given thickness remains.
28
+
29
+ Parameters
30
+ ----------
31
+ shape : Shape
32
+ The input shape to slice.
33
+ thickness : float
34
+ The width of the slice strip.
35
+ direction : float | Iterable[float]
36
+ The direction vector along which the slice is oriented.
37
+ pivot : float | Iterable[float], optional
38
+ The center point of the slice strip. Defaults to the origin.
39
+ part_filter : TopologyFilter[Shape] | None, optional
40
+ A boolean mask selecting which parts to slice. If None, all parts are sliced.
41
+
42
+ Returns
43
+ -------
44
+ Shape
45
+ A new shape containing only the sliced strip of the selected parts,
46
+ plus the unselected parts unchanged.
47
+
48
+ Examples
49
+ --------
50
+ >>> from scadpy import linear_slice_shape, square, circle
51
+ >>> import numpy as np
52
+
53
+ >>> shape = square(10) - circle(3)
54
+
55
+ >>> # horizontal slice through center
56
+ >>> linear_slice_shape( # doctest: +SKIP
57
+ ... shape, thickness=3, direction=[1, 0]
58
+ ... )
59
+
60
+ .. render-example::
61
+ :name: linear_slice_shape_horizontal
62
+ :example: linear_slice_shape(shape, thickness=3, direction=[1, 0])
63
+ :ghost: shape
64
+
65
+ >>> # diagonal slice
66
+ >>> linear_slice_shape( # doctest: +SKIP
67
+ ... shape, thickness=2, direction=[1, 1]
68
+ ... )
69
+
70
+ .. render-example::
71
+ :name: linear_slice_shape_diagonal
72
+ :example: linear_slice_shape(shape, thickness=2, direction=[1, 1])
73
+ :ghost: shape
74
+
75
+ >>> # off-center slice with pivot
76
+ >>> linear_slice_shape( # doctest: +SKIP
77
+ ... shape, thickness=2, direction=[0, 1], pivot=[3, 0]
78
+ ... )
79
+
80
+ .. render-example::
81
+ :name: linear_slice_shape_pivot
82
+ :example: linear_slice_shape(shape, thickness=2, direction=[0, 1], pivot=[3, 0])
83
+ :ghost: shape
84
+
85
+ >>> # partial slice on a composite shape
86
+ >>> a = square(6)
87
+ >>> b = circle(3).translate(10)
88
+
89
+ >>> linear_slice_shape( # doctest: +SKIP
90
+ ... a + b, thickness=2, direction=[1, 0],
91
+ ... part_filter=np.array([True, False]),
92
+ ... )
93
+
94
+ .. render-example::
95
+ :name: linear_slice_shape_partial
96
+ :example: linear_slice_shape(a + b, thickness=2, direction=[1, 0], part_filter=np.array([True, False]))
97
+ :ghost: a + b
98
+ """
99
+ from scadpy import resolve_vector_2d, transform_filtered_parts, Shape
100
+
101
+ direction = resolve_vector_2d(direction, 0)
102
+ pivot = resolve_vector_2d(pivot, 0)
103
+
104
+ bounds = shape.bounds
105
+ size = max(bounds[2] - bounds[0], bounds[3] - bounds[1]) * 2
106
+
107
+ direction = direction / np.linalg.norm(direction)
108
+ direction_x, direction_y = direction[0], direction[1]
109
+
110
+ normal_x = -direction_y
111
+ normal_y = direction_x
112
+
113
+ half_thickness = thickness / 2
114
+ px, py = pivot[0], pivot[1]
115
+
116
+ half_size = size / 2
117
+ points = [
118
+ (
119
+ px - direction_x * half_size - normal_x * half_thickness,
120
+ py - direction_y * half_size - normal_y * half_thickness,
121
+ ),
122
+ (
123
+ px + direction_x * half_size - normal_x * half_thickness,
124
+ py + direction_y * half_size - normal_y * half_thickness,
125
+ ),
126
+ (
127
+ px + direction_x * half_size + normal_x * half_thickness,
128
+ py + direction_y * half_size + normal_y * half_thickness,
129
+ ),
130
+ (
131
+ px - direction_x * half_size + normal_x * half_thickness,
132
+ py - direction_y * half_size + normal_y * half_thickness,
133
+ ),
134
+ ]
135
+
136
+ slice_mask = Shape.from_geometries([Polygon(points)])
137
+
138
+ return transform_filtered_parts(
139
+ assembly=shape,
140
+ parts=shape._parts,
141
+ part_filter=part_filter,
142
+ transform=lambda parts: (Shape.from_parts(parts) & slice_mask)._parts,
143
+ concat_parts=Shape.from_parts,
144
+ )