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/point.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ A minimal abstraction for a 2D point, allowing the x,y values to be interpreted from a tuple.
3
+ Also has some utility functions for calculating various useful properties of points.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from math import atan2, cos, degrees, radians, sin, tan
8
+ from typing import Union, Tuple
9
+ from build123d import Axis
10
+
11
+
12
+ @dataclass
13
+ class Point:
14
+ """
15
+ A 2D point with x and y coordinates.
16
+ """
17
+
18
+ x: float
19
+ y: float
20
+
21
+ @property
22
+ def X(self):
23
+ """the x coordinate of the point
24
+ ----------
25
+ Returns:
26
+ - float: The x coordinate of the point"""
27
+ return self.x
28
+
29
+ @property
30
+ def Y(self):
31
+ """the y coordinate of the point
32
+ ----------
33
+ Returns:
34
+ - float: The y coordinate of the point"""
35
+ return self.y
36
+
37
+ def __init__(self, x: Union[float, list[float, float]] = None, y: float = None):
38
+ """initialize the point with x and y coordinates passed as a tuple or individual values
39
+ ----------
40
+ Arguments:
41
+ - x: Union[float, list[float, float]]
42
+ The x coordinate or a list containing [x, y] coordinates
43
+ - y: float
44
+ The y coordinate (ignored if x is a list)"""
45
+ if isinstance(x, list) and len(x) >= 2:
46
+ self.x, self.y = x
47
+ else:
48
+ self.x = x
49
+ self.y = y
50
+
51
+ def __iter__(self):
52
+ """iterate through the x and y coordinates of the point
53
+ ----------
54
+ Yields:
55
+ - float: The x coordinate, then the y coordinate"""
56
+ yield self.x
57
+ yield self.y
58
+
59
+ def __getitem__(self, index):
60
+ """return the x or y coordinate of the point
61
+ ----------
62
+ Arguments:
63
+ - index: int
64
+ 0 for x coordinate, 1 for y coordinate
65
+ Returns:
66
+ - float: The requested coordinate"""
67
+ if index == 0:
68
+ return self.x
69
+ elif index == 1:
70
+ return self.y
71
+ else:
72
+ raise IndexError("Index out of range")
73
+
74
+ def angle_to(self, point: "Point") -> float:
75
+ """from the point, identify the angle to a second point
76
+ ----------
77
+ Arguments:
78
+ - point: Point
79
+ The target point to calculate angle to
80
+ Returns:
81
+ - float: The angle in degrees (0-360) from this point to the target point"""
82
+ return degrees(atan2(point.y - self.y, point.x - self.x)) % 360
83
+
84
+ def distance_to(self, point: "Point") -> float:
85
+ """from the point, identify the distance to a second point
86
+ ----------
87
+ Arguments:
88
+ - point: Point
89
+ The target point to calculate distance to
90
+ Returns:
91
+ - float: The Euclidean distance between the two points"""
92
+ return ((self.x - point.x) ** 2 + (self.y - point.y) ** 2) ** 0.5
93
+
94
+ def related_point(self, angle: float, distance: float) -> "Point":
95
+ """from the point, identify a second point at a specified angle and distance
96
+ ----------
97
+ Arguments:
98
+ - angle: float
99
+ The angle in degrees from this point (0° = positive x direction)
100
+ - distance: float
101
+ The distance from this point to the new point
102
+ Returns:
103
+ - Point: A new point at the specified angle and distance"""
104
+ angle_rad = radians(angle)
105
+ return Point(
106
+ self.x + distance * cos(angle_rad),
107
+ self.y + distance * sin(angle_rad),
108
+ )
109
+
110
+ def related_point_by_axis(
111
+ self, angle: float, axis_distance: float, axis: Axis = Axis.X
112
+ ) -> "Point":
113
+ """from the point, identify a second point at a specified angle with a given distance along x or y axis
114
+ ----------
115
+ Arguments:
116
+ - angle: float
117
+ The angle in degrees from this point (0° = positive x direction)
118
+ - axis_distance: float
119
+ The distance to travel along the specified axis
120
+ - axis: Axis
121
+ Either Axis.X or Axis.Y - the axis along which to measure the distance
122
+ Returns:
123
+ - Point: A new point at the specified angle with the given axis distance"""
124
+ angle_rad = radians(angle)
125
+
126
+ if axis == Axis.X:
127
+ # If we want to move axis_distance along x-axis at the given angle
128
+ # x_distance = axis_distance, so we need to find the corresponding y_distance
129
+ # cos(angle) = x_distance / hypotenuse, so hypotenuse = x_distance / cos(angle)
130
+ if abs(cos(angle_rad)) < 1e-10:
131
+ raise ValueError(
132
+ f"Cannot move along x-axis at angle {angle} degrees (cos ≈ 0)"
133
+ )
134
+ hypotenuse = abs(axis_distance / cos(angle_rad))
135
+ return Point(
136
+ self.x + axis_distance,
137
+ self.y
138
+ + hypotenuse * sin(angle_rad) * (1 if cos(angle_rad) > 0 else -1),
139
+ )
140
+ elif axis == Axis.Y:
141
+ # If we want to move axis_distance along y-axis at the given angle
142
+ # y_distance = axis_distance, so we need to find the corresponding x_distance
143
+ # sin(angle) = y_distance / hypotenuse, so hypotenuse = y_distance / sin(angle)
144
+ if abs(sin(angle_rad)) < 1e-10:
145
+ raise ValueError(
146
+ f"Cannot move along y-axis at angle {angle} degrees (sin ≈ 0)"
147
+ )
148
+ hypotenuse = abs(axis_distance / sin(angle_rad))
149
+ return Point(
150
+ self.x
151
+ + hypotenuse * cos(angle_rad) * (1 if sin(angle_rad) > 0 else -1),
152
+ self.y + axis_distance,
153
+ )
154
+ else:
155
+ raise ValueError("axis must be Axis.X or Axis.Y")
156
+
157
+
158
+ def midpoint(point1: Point, point2: Point) -> Point:
159
+ """find the midpoint between two points
160
+ ----------
161
+ Arguments:
162
+ - point1: Point
163
+ The first point.
164
+ - point2: Point
165
+ The second point."""
166
+ midpoint = Point((point1.x + point2.x) / 2, (point1.y + point2.y) / 2)
167
+ return midpoint
168
+
169
+
170
+ def shifted_midpoint(point1: Point, point2: Point, shift: float) -> Point:
171
+ """find the midpoint between two points, with an allowance to shift the
172
+ midpoint towards point2
173
+ ----------
174
+ Arguments:
175
+ - point1: Point
176
+ The first point.
177
+ - point2: Point
178
+ The second point.
179
+ - shift: float
180
+ The distance to shift the midpoint towards point2"""
181
+ mid_x = (point1.x + point2.x) / 2
182
+ mid_y = (point1.y + point2.y) / 2
183
+
184
+ direction_x = point2.x - point1.x
185
+ direction_y = point2.y - point1.y
186
+
187
+ # Normalize the direction vector
188
+ length = (direction_x**2 + direction_y**2) ** 0.5
189
+ direction_x /= length
190
+ direction_y /= length
191
+
192
+ # Shift the midpoint towards point2 by the specified amount
193
+ shifted_mid_x = mid_x + direction_x * shift
194
+ shifted_mid_y = mid_y + direction_y * shift
195
+
196
+ return Point(shifted_mid_x, shifted_mid_y)
b3dkit/slide_box.py ADDED
@@ -0,0 +1,210 @@
1
+ from build123d import (
2
+ Align,
3
+ BuildPart,
4
+ Box,
5
+ add,
6
+ Color,
7
+ fillet,
8
+ offset,
9
+ Mode,
10
+ Plane,
11
+ Part,
12
+ Location,
13
+ BuildSketch,
14
+ extrude,
15
+ Axis,
16
+ Cylinder,
17
+ pack,
18
+ Compound,
19
+ section,
20
+ GridLocations,
21
+ Sketch,
22
+ )
23
+
24
+
25
+ from ocp_vscode import show, Camera
26
+ from b3dkit import Divot
27
+
28
+
29
+ def slider_template(
30
+ sketch: Sketch,
31
+ wall_thickness: float = 2,
32
+ tolerance=0.2,
33
+ top_offset: float = 0,
34
+ x_straighten_distance: float = 0,
35
+ divot_radius=0,
36
+ cut_template=True,
37
+ ) -> Part:
38
+ """
39
+ Create a slider part based on a sketch.
40
+ """
41
+ with BuildPart() as slider_part:
42
+ with BuildSketch() as top_sketch:
43
+ offset(sketch, amount=-abs(tolerance) - (abs(wall_thickness)))
44
+ extrude(top_sketch.sketch, amount=-wall_thickness - abs(tolerance), taper=-22.5)
45
+ cross_section = section(
46
+ obj=slider_part.part,
47
+ section_by=Plane.XZ.offset(
48
+ slider_part.part.bounding_box().max.Y
49
+ - x_straighten_distance
50
+ - wall_thickness
51
+ ),
52
+ )
53
+ add(
54
+ extrude(
55
+ cross_section, amount=x_straighten_distance * 2 + wall_thickness * 2
56
+ )
57
+ )
58
+ if divot_radius > 0:
59
+ with BuildPart(
60
+ Location(
61
+ (
62
+ 0,
63
+ sketch.bounding_box().min.Y + wall_thickness / 2,
64
+ -wall_thickness,
65
+ ),
66
+ (180, 0, 0),
67
+ ),
68
+ mode=Mode.ADD,
69
+ ):
70
+ with GridLocations(
71
+ sketch.bounding_box().size.X
72
+ - x_straighten_distance * 2
73
+ - wall_thickness * 2,
74
+ 0,
75
+ 2,
76
+ 1,
77
+ ):
78
+ Divot(
79
+ radius=divot_radius,
80
+ positive=(not cut_template),
81
+ extend_base=True,
82
+ )
83
+
84
+ return slider_part.part
85
+
86
+
87
+ def slide_lid(
88
+ part: Part,
89
+ wall_thickness: float = 2,
90
+ tolerance=0.15,
91
+ top_offset: float = 0,
92
+ thumb_radius: float = 5,
93
+ x_straighten_distance: float = 0,
94
+ divot_radius=0,
95
+ ) -> Part:
96
+
97
+ cross_section = section(
98
+ obj=part, section_by=Plane.XY.offset(part.bounding_box().max.Z - top_offset)
99
+ )
100
+ lid_template = slider_template(
101
+ cross_section,
102
+ wall_thickness,
103
+ tolerance=tolerance,
104
+ top_offset=top_offset,
105
+ x_straighten_distance=x_straighten_distance,
106
+ divot_radius=divot_radius,
107
+ cut_template=False,
108
+ )
109
+
110
+ extrusion_height = part.bounding_box().max.Z - wall_thickness
111
+ with BuildPart() as lid_part:
112
+ add(part)
113
+ add(
114
+ lid_template.move(Location((0, 0, extrusion_height + wall_thickness))),
115
+ mode=Mode.INTERSECT,
116
+ )
117
+
118
+ if thumb_radius > 0:
119
+ with BuildPart(
120
+ Location(
121
+ (
122
+ 0,
123
+ lid_part.part.bounding_box().min.Y
124
+ + thumb_radius
125
+ + wall_thickness,
126
+ part.bounding_box().max.Z + wall_thickness / 4,
127
+ )
128
+ ),
129
+ mode=Mode.SUBTRACT,
130
+ ):
131
+ Cylinder(
132
+ radius=thumb_radius,
133
+ arc_size=180,
134
+ height=wall_thickness,
135
+ rotation=(15, 0, 0),
136
+ )
137
+
138
+ lid_part.part.label = "lid"
139
+
140
+ return lid_part.part.move(Location((0, 0, 0), (0, 180, 0)))
141
+
142
+
143
+ # todo - handle top_offset for big fat slinding bits
144
+ # right now the logic works for the gobox because the top_offset (the height downward to get the fat bit)
145
+ # is equal to the thinness of the top plane because it's a nice even chamfer, but I can't assume that generically
146
+ def slide_box(
147
+ part: Part,
148
+ wall_thickness: float = 2,
149
+ top_offset: float = 0,
150
+ thumb_radius: float = 5,
151
+ x_straighten_distance: float = 0,
152
+ slide_tolerance=0.15,
153
+ divot_radius: float = 0,
154
+ ) -> Compound:
155
+
156
+ cross_section = section(
157
+ obj=part, section_by=Plane.XY.offset(part.bounding_box().max.Z - top_offset)
158
+ )
159
+ lid_cut_template = slider_template(
160
+ cross_section,
161
+ wall_thickness,
162
+ tolerance=0,
163
+ top_offset=top_offset,
164
+ x_straighten_distance=x_straighten_distance,
165
+ divot_radius=divot_radius,
166
+ )
167
+
168
+ extrusion_height = part.bounding_box().max.Z - wall_thickness
169
+ with BuildPart() as box_part:
170
+ add(part)
171
+ extrude(
172
+ offset(
173
+ box_part.faces().sort_by(Axis.Z)[-1],
174
+ amount=-abs(slide_tolerance) - abs(wall_thickness - top_offset),
175
+ ),
176
+ amount=-extrusion_height,
177
+ mode=Mode.SUBTRACT,
178
+ )
179
+ add(
180
+ lid_cut_template.move(Location((0, 0, extrusion_height + wall_thickness))),
181
+ mode=Mode.SUBTRACT,
182
+ )
183
+ box_part.part.label = "box"
184
+
185
+ lid = slide_lid(
186
+ part,
187
+ wall_thickness=wall_thickness,
188
+ tolerance=slide_tolerance,
189
+ top_offset=top_offset,
190
+ thumb_radius=thumb_radius,
191
+ x_straighten_distance=x_straighten_distance,
192
+ divot_radius=divot_radius,
193
+ )
194
+ lid.label = "lid"
195
+ lid.color = Color("red")
196
+
197
+ box_assembly = Compound(
198
+ label="slide box", children=pack([box_part.part, lid], padding=5, align_z=True)
199
+ )
200
+
201
+ return box_assembly
202
+
203
+
204
+ if __name__ == "__main__":
205
+ with BuildPart() as base_box:
206
+ Box(20, 44, 14, align=(Align.CENTER, Align.CENTER, Align.MIN))
207
+ fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
208
+
209
+ sb = slide_box(base_box.part, wall_thickness=2, thumb_radius=3.5, divot_radius=0.5)
210
+ show(sb, reset_camera=Camera.KEEP)
b3dkit/twist_snap.py ADDED
@@ -0,0 +1,302 @@
1
+ """
2
+
3
+ Twist & Snap Connector
4
+
5
+ name: twist_snap.py
6
+ by: x0pherl
7
+ date: May 19 2024
8
+
9
+ desc: A parameterized twist and snap fitting.
10
+
11
+ license:
12
+
13
+ license:
14
+
15
+ Copyright 2024 x0pherl
16
+
17
+ Use of this source code is governed by an MIT-style
18
+ license that can be found in the LICENSE file or at
19
+ https://opensource.org/licenses/MIT.
20
+
21
+ """
22
+
23
+ from dataclasses import dataclass
24
+ from enum import Enum, Flag, auto
25
+ from math import radians, cos, sin
26
+ from typing import Union
27
+
28
+ from build123d import (
29
+ Align,
30
+ Axis,
31
+ BasePartObject,
32
+ BuildPart,
33
+ BuildSketch,
34
+ Compound,
35
+ Cylinder,
36
+ GeomType,
37
+ Location,
38
+ Locations,
39
+ Mode,
40
+ PolarLocations,
41
+ Polygon,
42
+ RotationLike,
43
+ SortBy,
44
+ add,
45
+ fillet,
46
+ sweep,
47
+ tuplify,
48
+ )
49
+
50
+ from ocp_vscode import Camera, show
51
+
52
+
53
+ class TwistSnapConnector(BasePartObject):
54
+
55
+ def __init__(
56
+ self,
57
+ connector_radius: float = 4.5,
58
+ tolerance: float = 0.12,
59
+ arc_percentage: float = 10,
60
+ snapfit_count: int = 4,
61
+ snapfit_radius_extension: float = 2 * 2 / 3,
62
+ wall_width: float = 2,
63
+ wall_depth: float = 2,
64
+ snapfit_height: float = 2,
65
+ rotation: RotationLike = (0, 0, 0),
66
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
67
+ mode: Mode = Mode.ADD,
68
+ ):
69
+ """
70
+ Returns a connector that locks into a socket with a twist.
71
+ ----------
72
+ Arguments:
73
+ - connector_radius: the base radius of the connector mechanism
74
+ - tolerance: the spacing between the connector and the socket
75
+ - arc_percentage: the percentage of the arc that the snapfit will cover
76
+ - snapfit_count: how many snapfit mechanisms to add
77
+ - snapfit_radius_extension: how far beyond the connector the snapfit extends
78
+ - wall_width: the thickness of the wall mechanism
79
+ - wall_depth: the depth of the wall mechanism
80
+ - snapfit_height: the height of the snapfit mechanism
81
+ - rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0)
82
+ - align (Align | tuple[Align, Align, Align] | None, optional): align MIN, CENTER,
83
+ or MAX of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER)
84
+ - mode (Mode, optional): combine mode. Defaults to Mode.ADD
85
+
86
+ """
87
+ with BuildPart() as twistbase:
88
+ Cylinder(
89
+ radius=connector_radius,
90
+ height=wall_depth * 2,
91
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
92
+ )
93
+ path = (
94
+ twistbase.edges()
95
+ .filter_by(GeomType.CIRCLE)
96
+ .sort_by(Axis.Z, reverse=True)
97
+ .sort_by(SortBy.RADIUS)[-1]
98
+ ) # top edge of cylinder
99
+ path = path.trim(
100
+ arc_percentage / -200,
101
+ arc_percentage / 200,
102
+ )
103
+ with BuildPart(mode=Mode.PRIVATE) as snapfit:
104
+ path = path.rotate(Axis.Z, 90)
105
+ with BuildSketch(path ^ 0):
106
+ Polygon(
107
+ *[
108
+ (0, 0),
109
+ (snapfit_radius_extension, 0),
110
+ (
111
+ snapfit_radius_extension,
112
+ snapfit_height,
113
+ ),
114
+ (0, snapfit_height / 2),
115
+ ],
116
+ align=(Align.MAX, Align.MIN),
117
+ )
118
+ sweep(path=path)
119
+ with Locations(
120
+ snapfit.part.center() + (0, snapfit_radius_extension / 2, 0)
121
+ ):
122
+ Cylinder(
123
+ radius=snapfit_radius_extension / 2,
124
+ height=snapfit_height * 3,
125
+ mode=Mode.SUBTRACT,
126
+ )
127
+ fillet(
128
+ snapfit.faces().sort_by(Axis.Y)[-2:].edges().filter_by(Axis.Z),
129
+ min(
130
+ snapfit_radius_extension / 8,
131
+ snapfit.part.max_fillet(
132
+ snapfit.faces()
133
+ .sort_by(Axis.Y)[-2:]
134
+ .edges()
135
+ .filter_by(Axis.Z),
136
+ max_iterations=40,
137
+ ),
138
+ ),
139
+ )
140
+
141
+ with PolarLocations(0, snapfit_count):
142
+ add(snapfit.part)
143
+ super().__init__(
144
+ twistbase.part, rotation=rotation, align=tuplify(align, 3), mode=mode
145
+ )
146
+
147
+
148
+ class TwistSnapSocket(BasePartObject):
149
+
150
+ def __init__(
151
+ self,
152
+ connector_radius: float = 4.5,
153
+ tolerance: float = 0.12,
154
+ arc_percentage: float = 10,
155
+ snapfit_count: int = 4,
156
+ snapfit_radius_extension: float = 2 * 2 / 3,
157
+ wall_width: float = 2,
158
+ wall_depth: float = 2,
159
+ snapfit_height: float = 2,
160
+ rotation: RotationLike = (0, 0, 0),
161
+ align: Union[None, Align, tuple[Align, Align, Align]] = None,
162
+ mode: Mode = Mode.ADD,
163
+ ) -> Compound:
164
+ """
165
+ Returns a socket that locks into a connector with a twist.
166
+ ----------
167
+ Arguments:
168
+ - connector_radius: the base radius of the connector mechanism
169
+ - tolerance: the spacing between the connector and the socket
170
+ - arc_percentage: the percentage of the arc that the snapfit will cover
171
+ - snapfit_count: how many snapfit mechanisms to add
172
+ - snapfit_radius_extension: how far beyond the connector the snapfit extends
173
+ - wall_width: the thickness of the wall mechanism
174
+ - wall_depth: the depth of the wall mechanism
175
+ - snapfit_height: the height of the snapfit mechanism
176
+ - rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0)
177
+ - align (Align | tuple[Align, Align, Align] | None, optional): align MIN, CENTER,
178
+ or MAX of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER)
179
+ - mode (Mode, optional): combine mode. Defaults to Mode.ADD
180
+ """
181
+ outer_socket_radius = connector_radius + wall_width * 4 / 3
182
+ with BuildPart() as socket_fitting:
183
+ Cylinder(
184
+ radius=outer_socket_radius,
185
+ height=wall_depth,
186
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
187
+ )
188
+ with BuildPart(socket_fitting.faces().sort_by(Axis.Z)[-1]) as snap_socket:
189
+ Cylinder(
190
+ radius=outer_socket_radius,
191
+ height=wall_depth * 2,
192
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
193
+ )
194
+ Cylinder(
195
+ radius=connector_radius + tolerance,
196
+ height=wall_depth * 2,
197
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
198
+ mode=Mode.SUBTRACT,
199
+ )
200
+ trace_path = (
201
+ snap_socket.edges()
202
+ .filter_by(GeomType.CIRCLE)
203
+ .sort_by(Axis.Z, reverse=True)
204
+ .sort_by(SortBy.RADIUS, reverse=True)[-1]
205
+ ) # top edge of cylinder
206
+ path = trace_path.trim(
207
+ (arc_percentage / -200) * 1.1,
208
+ (arc_percentage / 200) * 1.1,
209
+ )
210
+ with BuildPart(mode=Mode.PRIVATE) as snapfit:
211
+ path = path.rotate(Axis.Z, 90)
212
+ with BuildSketch(path ^ 0):
213
+ Polygon(
214
+ *[
215
+ (0, 0),
216
+ (snapfit_radius_extension, 0),
217
+ (
218
+ snapfit_radius_extension,
219
+ snapfit_height * 2,
220
+ ),
221
+ (0, snapfit_height * 2),
222
+ ],
223
+ align=(Align.MAX, Align.MIN),
224
+ )
225
+ sweep(path=path)
226
+ fillet(
227
+ snapfit.faces().sort_by(Axis.Y)[-1].edges().filter_by(Axis.Z),
228
+ snapfit_radius_extension / 8,
229
+ )
230
+ with PolarLocations(0, snapfit_count):
231
+ add(snapfit.part, mode=Mode.SUBTRACT)
232
+
233
+ path = trace_path.trim(
234
+ (arc_percentage / -200) * 3.3,
235
+ (arc_percentage / 200) * 1.1,
236
+ )
237
+ with BuildPart(mode=Mode.PRIVATE) as snapfit:
238
+ path = path.rotate(Axis.Z, 90)
239
+ with BuildSketch(path ^ 0):
240
+ Polygon(
241
+ *[
242
+ (0, 0),
243
+ (
244
+ snapfit_radius_extension + tolerance,
245
+ 0,
246
+ ),
247
+ (
248
+ snapfit_radius_extension + tolerance,
249
+ snapfit_height + tolerance,
250
+ ),
251
+ (
252
+ 0,
253
+ snapfit_height / 2 + tolerance,
254
+ ),
255
+ ],
256
+ align=(Align.MAX, Align.MIN),
257
+ )
258
+ sweep(path=path)
259
+ fillet(
260
+ snapfit.faces().sort_by(Axis.Y)[-1].edges().filter_by(Axis.Z),
261
+ snapfit_radius_extension / 8,
262
+ )
263
+ with PolarLocations(0, snapfit_count):
264
+ add(snapfit.part, mode=Mode.SUBTRACT)
265
+ with PolarLocations(
266
+ connector_radius + snapfit_radius_extension + tolerance * 2,
267
+ snapfit_count,
268
+ start_angle=arc_percentage * -4,
269
+ ):
270
+ Cylinder(
271
+ radius=snapfit_radius_extension / 2 - tolerance,
272
+ height=snapfit_height * 2,
273
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
274
+ )
275
+ super().__init__(
276
+ socket_fitting.part, rotation=rotation, align=tuplify(align, 3), mode=mode
277
+ )
278
+
279
+
280
+ if __name__ == "__main__":
281
+ connector = (
282
+ TwistSnapConnector(
283
+ connector_radius=4.5,
284
+ tolerance=0.12,
285
+ snapfit_height=2,
286
+ snapfit_radius_extension=2 * (2 / 3) - 0.06,
287
+ wall_width=2,
288
+ wall_depth=2,
289
+ )
290
+ .rotate(Axis.X, 180)
291
+ .move(Location((0, 0, 15)))
292
+ )
293
+ socket = TwistSnapSocket(
294
+ connector_radius=4.5,
295
+ tolerance=0.12,
296
+ snapfit_height=2,
297
+ snapfit_radius_extension=2 * (2 / 3) - 0.06,
298
+ wall_width=2,
299
+ wall_depth=2,
300
+ )
301
+
302
+ show(connector, socket, reset_camera=Camera.KEEP)