b3dkit 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.
b3dkit/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from b3dkit.basic_shapes import *
2
+ from b3dkit.ball_socket import *
3
+ from b3dkit.click_fit import *
4
+ from b3dkit.dovetail import *
5
+ from b3dkit.hexwall import *
6
+ from b3dkit.high_top_slide_box import *
7
+ from b3dkit.point import *
8
+ from b3dkit.slide_box import *
9
+ from b3dkit.twist_snap import *
10
+ from b3dkit.antichamfer import *
b3dkit/antichamfer.py ADDED
@@ -0,0 +1,79 @@
1
+ from build123d import (
2
+ Align,
3
+ Axis,
4
+ BasePartObject,
5
+ Box,
6
+ BuildPart,
7
+ Builder,
8
+ Compound,
9
+ Face,
10
+ Iterable,
11
+ Location,
12
+ Mode,
13
+ Part,
14
+ add,
15
+ extrude,
16
+ fillet,
17
+ flatten_sequence,
18
+ validate_inputs,
19
+ )
20
+ from math import atan, degrees, tan, radians
21
+ from ocp_vscode import show, Camera
22
+
23
+
24
+ def anti_chamfer(
25
+ face: Face | Iterable[Face],
26
+ length: float,
27
+ length2: float | None = None,
28
+ ) -> Part:
29
+ faces_list = flatten_sequence(face)
30
+ if len(faces_list) == 0:
31
+ raise ValueError("No faces provided to anti_chamfer")
32
+ if not all([isinstance(obj, Face) for obj in faces_list]):
33
+ raise ValueError("anti_chamfer operation takes only Faces")
34
+
35
+ context: Builder | None = Builder._get_context("chamfer")
36
+ validate_inputs(context, "chamfer", faces_list)
37
+
38
+ if length2 is None:
39
+ length2 = length
40
+
41
+ if context is not None:
42
+ target = context._obj
43
+ else:
44
+ target = faces_list[0].topo_parent
45
+ if target is None:
46
+ raise ValueError("face does not seem to belong to a Part")
47
+ # Convert BasePartObject in Part so casting into Part during construction works
48
+ target = Part(target.wrapped) if isinstance(target, BasePartObject) else target
49
+
50
+ if length == 0 or length2 == 0:
51
+ return target
52
+
53
+ with BuildPart() as new_part:
54
+ add(target)
55
+ for f in faces_list:
56
+ extrude(
57
+ f.offset(-length),
58
+ amount=length,
59
+ taper=-degrees(atan(length2 / length)),
60
+ )
61
+ if context is not None:
62
+ context._add_to_context(
63
+ Part(Compound([new_part.part]).wrapped), mode=Mode.REPLACE
64
+ )
65
+ return Part(Compound([new_part.part]).wrapped)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ with BuildPart(Location((33, 11, 0))) as bkt:
70
+ Box(
71
+ 60,
72
+ 10,
73
+ 20,
74
+ rotation=(0, 0, 45),
75
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
76
+ )
77
+ fillet(bkt.edges().filter_by(Axis.Z), 3)
78
+ anti_chamfer(bkt.faces().filter_by(Axis.Z), 1, 1)
79
+ show(bkt, reset_camera=Camera.KEEP)
b3dkit/ball_socket.py ADDED
@@ -0,0 +1,177 @@
1
+ from math import sqrt
2
+ from typing import Union
3
+
4
+ from build123d import (
5
+ BasePartObject,
6
+ Box,
7
+ BuildPart,
8
+ BuildSketch,
9
+ Circle,
10
+ Cylinder,
11
+ Location,
12
+ Mode,
13
+ Part,
14
+ Plane,
15
+ PolarLocations,
16
+ RotationLike,
17
+ Sphere,
18
+ Align,
19
+ loft,
20
+ fillet,
21
+ Axis,
22
+ tuplify,
23
+ )
24
+ from ocp_vscode import show, Camera
25
+
26
+
27
+ class BallMount(BasePartObject):
28
+
29
+ def __init__(
30
+ self,
31
+ ball_radius: float,
32
+ rotation: RotationLike = (0, 0, 0),
33
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
34
+ mode: Mode = Mode.ADD,
35
+ ) -> Part:
36
+ """
37
+ Creates a ball mount component for a ball-and-socket joint system.
38
+
39
+ The ball mount consists of a spherical ball attached to a tapered shaft. The shaft
40
+ is designed to be inserted into another part or component, while the ball mates
41
+ with a corresponding ball socket to create a flexible joint that allows rotation
42
+ in multiple axes. The shaft has a sophisticated tapered design that transitions from
43
+ the full ball radius at the base to approximately 36% of the ball radius at the
44
+ insertion point, providing strength while allowing for easy integration into
45
+ mounting systems.
46
+
47
+ args:
48
+ - ball_radius: the radius of the spherical ball in millimeters. This determines
49
+ the overall size of the joint system and must match the ball_radius used
50
+ for the corresponding ball_socket.
51
+ - rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0)
52
+ - align (Align | tuple[Align, Align, Align] | None, optional): align MIN, CENTER,
53
+ or MAX of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER)
54
+ - mode (Mode, optional): combine mode. Defaults to Mode.ADD
55
+ """
56
+ with BuildPart() as ballmount:
57
+ with BuildPart(Location((0, 0, ball_radius * 2.5))):
58
+ Sphere(
59
+ radius=ball_radius, align=(Align.CENTER, Align.CENTER, Align.CENTER)
60
+ )
61
+ with BuildPart() as shaft:
62
+ with BuildSketch():
63
+ Circle(
64
+ radius=ball_radius,
65
+ align=(Align.CENTER, Align.CENTER),
66
+ )
67
+ with BuildSketch(Plane.XY.offset(ball_radius * 0.5)):
68
+ Circle(
69
+ radius=ball_radius / 2.75,
70
+ align=(Align.CENTER, Align.CENTER),
71
+ )
72
+ height_ratio = 0.25
73
+ with BuildSketch(Plane.XY.offset(ball_radius * (2 + height_ratio))):
74
+ Circle(
75
+ radius=ball_radius * (sqrt(1 - height_ratio**2)),
76
+ align=(Align.CENTER, Align.CENTER),
77
+ )
78
+ loft()
79
+
80
+ ballmount.part.label = "Ball Mount"
81
+ super().__init__(
82
+ part=ballmount.part, rotation=rotation, align=tuplify(align, 3), mode=mode
83
+ )
84
+
85
+
86
+ class BallSocket(BasePartObject):
87
+
88
+ def __init__(
89
+ self,
90
+ ball_radius: float,
91
+ wall_thickness: float = 2,
92
+ tolerance: float = 0.1,
93
+ rotation: RotationLike = (0, 0, 0),
94
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
95
+ mode: Mode = Mode.ADD,
96
+ ):
97
+ """
98
+ Creates a ball socket component for a ball-and-socket joint system.
99
+
100
+ The ball socket is designed to receive and hold a ball mount, creating a flexible
101
+ joint that allows rotation in multiple axes. The socket features a hemispherical
102
+ cavity to house the ball, a cylindrical outer shell for strength, and flexible
103
+ cuts that allow the socket to grip the ball while still permitting smooth rotation.
104
+ The socket includes a flange at the top with a smooth filleted edge for comfortable
105
+ operation and four radial flex cuts that allow the socket walls to compress slightly
106
+ for ball retention while maintaining smooth rotation.
107
+
108
+ args:
109
+ - ball_radius: the radius of the spherical ball that will be inserted into
110
+ this socket, in millimeters. Must match the ball_radius of the corresponding
111
+ ball_mount for proper fit.
112
+ - wall_thickness: the thickness of the socket walls in millimeters. Affects
113
+ both strength and flexibility. Thicker walls provide more strength but may
114
+ reduce flexibility.
115
+ - tolerance: additional clearance around the ball in millimeters. Positive
116
+ values create looser fits, negative values create tighter fits.
117
+ - rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0)
118
+ - align (Align | tuple[Align, Align, Align] | None, optional): align MIN, CENTER,
119
+ or MAX of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER)
120
+ - mode (Mode, optional): combine mode. Defaults to Mode.ADD
121
+
122
+ """
123
+ with BuildPart() as socket:
124
+ Cylinder(
125
+ radius=ball_radius + wall_thickness,
126
+ height=ball_radius + wall_thickness * 2.5,
127
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
128
+ )
129
+ with BuildPart(
130
+ Plane.XY.offset(wall_thickness), mode=Mode.SUBTRACT
131
+ ) as bowl_cut:
132
+ Sphere(
133
+ radius=ball_radius + tolerance,
134
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
135
+ )
136
+ # Fillet the top edge if inner wires exist (after sphere subtraction they may not)
137
+ top_face = socket.faces().sort_by(Axis.Z)[-1]
138
+ if top_face.inner_wires():
139
+ fillet(
140
+ top_face.inner_wires().edge(),
141
+ min(wall_thickness * 0.4, ball_radius * 0.1), # Limit fillet radius
142
+ )
143
+ with BuildPart(
144
+ Plane.XY.offset(ball_radius * 0.75 + wall_thickness), mode=Mode.SUBTRACT
145
+ ) as flexcuts:
146
+ with PolarLocations(0, 4):
147
+ Box(
148
+ ball_radius,
149
+ (ball_radius + wall_thickness) * 2,
150
+ ball_radius / 2 + wall_thickness,
151
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
152
+ )
153
+ Cylinder(
154
+ radius=ball_radius / 2,
155
+ height=(ball_radius + wall_thickness) * 2,
156
+ align=(Align.CENTER, Align.CENTER, Align.CENTER),
157
+ rotation=(90, 0, 0),
158
+ )
159
+ socket.part.label = "Ball Socket"
160
+ super().__init__(
161
+ part=socket.part, rotation=rotation, align=tuplify(align, 3), mode=mode
162
+ )
163
+
164
+
165
+ if __name__ == "__main__":
166
+ show(
167
+ BallMount(
168
+ 24.24871131,
169
+ ),
170
+ BallSocket(
171
+ 24.24871131,
172
+ # 15,
173
+ wall_thickness=2,
174
+ tolerance=-0.05,
175
+ ),
176
+ reset_camera=Camera.KEEP,
177
+ )
b3dkit/basic_shapes.py ADDED
@@ -0,0 +1,397 @@
1
+ """
2
+ Utility functions for creating and manipulating basic 3D shapes
3
+ and geometric calculations. These functions extend build123d's
4
+ capabilities with shapes and operations commonly used in 3D design.
5
+ """
6
+
7
+ from math import sqrt, radians, cos, sin, tan
8
+ from tempfile import template
9
+ from typing import Union
10
+
11
+ from build123d import (
12
+ Align,
13
+ Axis,
14
+ BasePartObject,
15
+ BaseSketchObject,
16
+ Box,
17
+ BuildLine,
18
+ BuildPart,
19
+ BuildSketch,
20
+ Builder,
21
+ Circle,
22
+ Compound,
23
+ Cylinder,
24
+ GridLocations,
25
+ JernArc,
26
+ Line,
27
+ Location,
28
+ Mode,
29
+ Part,
30
+ Plane,
31
+ PolarLocations,
32
+ RadiusArc,
33
+ RegularPolygon,
34
+ RotationLike,
35
+ Shape,
36
+ Sketch,
37
+ Sphere,
38
+ add,
39
+ extrude,
40
+ fillet,
41
+ loft,
42
+ make_face,
43
+ scale,
44
+ sweep,
45
+ tuplify,
46
+ validate_inputs,
47
+ )
48
+ from ocp_vscode import Camera, show
49
+
50
+
51
+ def radius_to_apothem(radius: float, side_count: int = 6) -> float:
52
+ """
53
+ calculates the apothem of a regular polygon given its circumradius
54
+ -------
55
+ arguments:
56
+ - radius: the circumradius of the polygon
57
+ """
58
+ return radius * cos(radians(360 / (2 * side_count)))
59
+
60
+
61
+ def apothem_to_radius(apothem: float, side_count: int = 6) -> float:
62
+ """
63
+ calculates the circumradius of a regular polygon given its apothem
64
+ -------
65
+ arguments:
66
+ - apothem: the apothem of the polygon
67
+ """
68
+ return apothem / cos(radians(360 / (2 * side_count)))
69
+
70
+
71
+ def opposite_length(angle: float, adjacent_length: float) -> float:
72
+ """calculate the opposite side length of a right triangle given the angle and adjacent side length
73
+ ----------
74
+ Arguments:
75
+ - angle: float
76
+ The angle in degrees.
77
+ - adjacent_length: float
78
+ The length of the adjacent side."""
79
+ angle_rad = radians(angle)
80
+ return adjacent_length * tan(angle_rad)
81
+
82
+
83
+ def adjacent_length(angle: float, opposite_length: float) -> float:
84
+ """calculate the adjacent side length of a right triangle given the angle and opposite side length
85
+ ----------
86
+ Arguments:
87
+ - angle: float
88
+ The angle in degrees.
89
+ - opposite_length: float
90
+ The length of the opposite side."""
91
+ angle_rad = radians(angle)
92
+ return opposite_length / tan(angle_rad)
93
+
94
+
95
+ def distance_to_circle_edge(radius, point, angle) -> float:
96
+ """
97
+ for a circle with the given radius, find the distance from the
98
+ given point to the edge of the circle in the direction determined
99
+ by the given angle
100
+ """
101
+ x1, y1 = point
102
+ theta = radians(angle)
103
+
104
+ a = 1
105
+ b = 2 * (x1 * cos(theta) + y1 * sin(theta))
106
+ c = x1**2 + y1**2 - radius**2
107
+
108
+ discriminant = b**2 - 4 * a * c
109
+
110
+ if discriminant < 0:
111
+ raise ValueError(f"Error: discriminant calculated as < 0 ({discriminant})")
112
+ t1 = (-b + sqrt(discriminant)) / (2 * a)
113
+ t2 = (-b - sqrt(discriminant)) / (2 * a)
114
+
115
+ t = max(t1, t2)
116
+
117
+ return t
118
+
119
+
120
+ def circular_intersection(radius: float, coordinate: float) -> float:
121
+ """
122
+ given a positive position along the axis of a circle, find the intersection
123
+ along the other axis of the perimeter of the circle
124
+ -------
125
+ arguments:
126
+ - radius: the radius of the circle
127
+ - coordinate: a coordinate along one axis of the circle (must be a
128
+ positive value less than the radius)
129
+ """
130
+ if 0 > coordinate > radius:
131
+ raise ValueError("The x-coordinate cannot be greater than the radius.")
132
+ return sqrt(radius**2 - coordinate**2)
133
+
134
+
135
+ class DiamondTorus(BasePartObject):
136
+ def __init__(
137
+ self,
138
+ major_radius: float,
139
+ minor_radius: float,
140
+ stretch: tuple = (1, 1),
141
+ rotation: RotationLike = (0, 0, 0),
142
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
143
+ mode: Mode = Mode.ADD,
144
+ ):
145
+ """
146
+ sweeps a regular diamond along a circle defined by major_radius
147
+ -------
148
+ arguments:
149
+ - major_radius: the radius of the circle to sweep the diamond along
150
+ - minor_radius: the radius of the diamond
151
+ - stretch: scales the diamond shape
152
+ - rotation: the rotation of the torus
153
+ - align: the alignment of the torus
154
+ - mode: the mode of the torus
155
+ """
156
+
157
+ context: BuildPart = BuildPart._get_context()
158
+ validate_inputs(context, self)
159
+
160
+ with BuildPart() as torus:
161
+ with BuildLine():
162
+ l1 = JernArc(
163
+ start=(major_radius, 0),
164
+ tangent=(0, 1),
165
+ radius=major_radius,
166
+ arc_size=360,
167
+ )
168
+ with BuildSketch(l1 ^ 0):
169
+ RegularPolygon(radius=minor_radius, side_count=4)
170
+ scale(by=(stretch[0], stretch[1], 1))
171
+ sweep()
172
+ super().__init__(
173
+ part=torus.part, rotation=rotation, align=tuplify(align, 3), mode=mode
174
+ )
175
+
176
+
177
+ class RoundedCylinder(BasePartObject):
178
+ def __init__(
179
+ self,
180
+ radius: float,
181
+ height: float,
182
+ rotation: RotationLike = (0, 0, 0),
183
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
184
+ mode: Mode = Mode.ADD,
185
+ ):
186
+ """
187
+ sweeps a regular diamond along a circle defined by major_radius
188
+ -------
189
+ arguments:
190
+ - radius: the radius of the cylinder
191
+ - height: the height of the cylinder
192
+ - rotation: the rotation of the cylinder
193
+ - align: the alignment of the cylinder
194
+ - mode: the mode of the cylinder
195
+ """
196
+
197
+ context: BuildPart = BuildPart._get_context()
198
+ validate_inputs(context, self)
199
+
200
+ if height <= radius * 2:
201
+ raise ValueError("height must be greater than radius * 2")
202
+ with BuildPart() as cylinder:
203
+ Cylinder(radius=radius, height=height, align=align)
204
+ fillet(
205
+ cylinder.faces().sort_by(Axis.Z)[-1].edges()
206
+ + cylinder.faces().sort_by(Axis.Z)[0].edges(),
207
+ radius=radius,
208
+ )
209
+ super().__init__(
210
+ part=cylinder.part, rotation=rotation, align=tuplify(align, 3), mode=mode
211
+ )
212
+
213
+
214
+ class PolygonalCylinder(BasePartObject):
215
+
216
+ def __init__(
217
+ self,
218
+ radius: float,
219
+ height: float,
220
+ side_count: int = 6,
221
+ stretch: tuple = (1, 1, 1),
222
+ rotation: RotationLike = (0, 0, 0),
223
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
224
+ mode: Mode = Mode.ADD,
225
+ ):
226
+ """
227
+ creates an extruded polygon that behaves like a cylinder
228
+ -------
229
+ arguments:
230
+ - radius: the radius of the cylinder
231
+ - height: the height of the cylinder
232
+ - side_count: the number of sides of the polygonal base (default is 6)
233
+ - stretch: scales the base polygon
234
+ - rotation: the rotation of the cylinder
235
+ - align: the alignment of the cylinder
236
+ - mode: the mode to use when
237
+ """
238
+ context: BuildPart = BuildPart._get_context()
239
+ validate_inputs(context, self)
240
+
241
+ with BuildPart() as tube:
242
+ with BuildSketch():
243
+ RegularPolygon(
244
+ radius=radius, side_count=side_count, align=tuplify(align, 2)
245
+ )
246
+ extrude(amount=height * stretch[2])
247
+ super().__init__(
248
+ part=tube.part, rotation=rotation, align=tuplify(align, 3), mode=mode
249
+ )
250
+
251
+
252
+ class DiamondCylinder(PolygonalCylinder):
253
+
254
+ def __init__(
255
+ self,
256
+ radius: float,
257
+ height: float,
258
+ stretch: tuple = (1, 1, 1),
259
+ rotation: RotationLike = (0, 0, 0),
260
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
261
+ mode: Mode = Mode.ADD,
262
+ ):
263
+ """
264
+ creates an extruded diamond that behaves like a cylinder
265
+ -------
266
+ arguments:
267
+ - radius: the radius of the cylinder
268
+ - height: the height of the cylinder
269
+ - rotation: the rotation of the cylinder
270
+ - align: the alignment of the cylinder (default
271
+ is (Align.CENTER, Align.CENTER, Align.CENTER) )
272
+ - mode: the mode to use when adding the part
273
+ """
274
+ super().__init__(
275
+ radius=radius,
276
+ height=height,
277
+ side_count=4,
278
+ stretch=stretch,
279
+ rotation=rotation,
280
+ align=align,
281
+ mode=mode,
282
+ )
283
+
284
+
285
+ class Teardrop(BaseSketchObject):
286
+
287
+ def __init__(
288
+ self,
289
+ radius: float,
290
+ peak_distance: float,
291
+ rotation: RotationLike = (0, 0),
292
+ align: Union[None, Align, tuple[Align, Align]] = None,
293
+ mode: Mode = Mode.ADD,
294
+ ):
295
+ """
296
+ Create a teardrop shape sketch;
297
+ this can be useful when creating holes along the Z axis
298
+ to compensate for overhang issues with FDM printers.
299
+ ----------
300
+ Arguments:
301
+ - radius: float
302
+ The radius of the teardrop.
303
+ - peak_distance: float
304
+ The distance from the center of the teardrop circle to the peak of the
305
+ teardrop shape.
306
+ - align: tuple
307
+ The alignment of the teardrop. Note that the Y alignment is to the center of the cylinder, ignoring the peak distance
308
+ - mode: Mode
309
+ The mode of the teardrop.
310
+ """
311
+ context: BuildSketch = BuildSketch._get_context()
312
+ validate_inputs(context, self)
313
+
314
+ x = radius * sqrt(1 - (radius**2 / peak_distance**2))
315
+ y = radius**2 / peak_distance
316
+ with BuildSketch() as teardrop:
317
+ if peak_distance == radius:
318
+ Circle(radius)
319
+ else:
320
+ with BuildLine() as outline:
321
+ Line((-x, -y), (0, -peak_distance))
322
+ Line((0, -peak_distance), (x, -y))
323
+ RadiusArc((x, -y), (-x, -y), radius, short_sagitta=False)
324
+ make_face()
325
+ super().__init__(
326
+ obj=teardrop.sketch,
327
+ rotation=rotation,
328
+ align=tuplify(align, 2),
329
+ mode=mode,
330
+ )
331
+
332
+
333
+ class TeardropCylinder(BasePartObject):
334
+
335
+ def __init__(
336
+ self,
337
+ radius: float,
338
+ height: float,
339
+ peak_distance: float,
340
+ rotation: RotationLike = (0, 0, 0),
341
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
342
+ mode: Mode = Mode.ADD,
343
+ ):
344
+ """
345
+ Create a cylinder with a teardrop shape;
346
+ this can be useful when creating holes along the Z axis
347
+ to compensate for overhang issues with FDM printers.
348
+ ----------
349
+ Arguments:
350
+ - radius: float
351
+ The radius of the cylinder.
352
+ - height: float
353
+ The height of the cylinder.
354
+ - peak_distance: float
355
+ The distance from the center of the cylinder to the peak of the
356
+ teardrop shape.
357
+ - rotation: tuple
358
+ The rotation of the cylinder.
359
+ - align: tuple
360
+ The alignment of the cylinder.
361
+ - mode: Mode
362
+ The mode of the cylinder.
363
+ """
364
+ context: BuildPart = BuildPart._get_context()
365
+ validate_inputs(context, self)
366
+
367
+ with BuildPart() as cylinder:
368
+ with BuildSketch():
369
+ Teardrop(radius, peak_distance, align=tuplify(align, 2))
370
+ extrude(amount=height)
371
+ super().__init__(
372
+ part=cylinder.part,
373
+ rotation=rotation,
374
+ align=tuplify(align, 3),
375
+ mode=mode,
376
+ )
377
+
378
+
379
+ if __name__ == "__main__":
380
+
381
+ show(
382
+ DiamondTorus(
383
+ 50,
384
+ 2,
385
+ ),
386
+ reset_camera=Camera.KEEP,
387
+ )
388
+ show(
389
+ DiamondCylinder(
390
+ radius=50,
391
+ height=200,
392
+ stretch=(1, 1, 1),
393
+ rotation=(0, 0, 0),
394
+ align=(Align.MIN, Align.MIN, Align.CENTER),
395
+ ),
396
+ reset_camera=Camera.KEEP,
397
+ )