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 +10 -0
- b3dkit/antichamfer.py +79 -0
- b3dkit/ball_socket.py +177 -0
- b3dkit/basic_shapes.py +397 -0
- b3dkit/bolt_fittings.py +351 -0
- b3dkit/click_fit.py +78 -0
- b3dkit/dovetail.py +1338 -0
- b3dkit/hexwall.py +102 -0
- b3dkit/high_top_slide_box.py +504 -0
- b3dkit/point.py +196 -0
- b3dkit/slide_box.py +210 -0
- b3dkit/twist_snap.py +302 -0
- b3dkit-0.1.0.dist-info/METADATA +54 -0
- b3dkit-0.1.0.dist-info/RECORD +16 -0
- b3dkit-0.1.0.dist-info/WHEEL +4 -0
- b3dkit-0.1.0.dist-info/licenses/LICENSE +7 -0
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)
|