b3dkit 0.5.0__tar.gz → 0.5.3__tar.gz

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 (69) hide show
  1. {b3dkit-0.5.0 → b3dkit-0.5.3}/CHANGELOG.md +42 -1
  2. {b3dkit-0.5.0 → b3dkit-0.5.3}/CONTRIBUTING.md +5 -0
  3. {b3dkit-0.5.0 → b3dkit-0.5.3}/PKG-INFO +1 -1
  4. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/slide_box.md +3 -1
  5. {b3dkit-0.5.0 → b3dkit-0.5.3}/pyproject.toml +3 -1
  6. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/antichamfer.py +10 -10
  7. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/basic_shapes.py +1 -1
  8. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/bolt_fittings.py +2 -2
  9. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/high_top_slide_box.py +28 -23
  10. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/slide_box.py +60 -30
  11. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/twist_snap.py +4 -1
  12. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_high_top_slide_box.py +1 -0
  13. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_public_api.py +1 -1
  14. b3dkit-0.5.3/tests/test_slide_box.py +185 -0
  15. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_twist_snap.py +1 -1
  16. b3dkit-0.5.0/tests/test_slide_box.py +0 -72
  17. {b3dkit-0.5.0 → b3dkit-0.5.3}/.coveragerc +0 -0
  18. {b3dkit-0.5.0 → b3dkit-0.5.3}/.github/workflows/release.yml +0 -0
  19. {b3dkit-0.5.0 → b3dkit-0.5.3}/.github/workflows/test.yml +0 -0
  20. {b3dkit-0.5.0 → b3dkit-0.5.3}/.gitignore +0 -0
  21. {b3dkit-0.5.0 → b3dkit-0.5.3}/.pre-commit-config.yaml +0 -0
  22. {b3dkit-0.5.0 → b3dkit-0.5.3}/.vscode/settings.json +0 -0
  23. {b3dkit-0.5.0 → b3dkit-0.5.3}/LICENSE +0 -0
  24. {b3dkit-0.5.0 → b3dkit-0.5.3}/README.md +0 -0
  25. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/CONTRIBUTING.md +0 -0
  26. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/antichamfer.md +0 -0
  27. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/ball_socket.md +0 -0
  28. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/basic_shapes.md +0 -0
  29. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/bolt_fittings.md +0 -0
  30. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/click_fit.md +0 -0
  31. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/dovetail.md +0 -0
  32. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/dovetail.png +0 -0
  33. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/hexwall.md +0 -0
  34. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/high_top_slide_box.md +0 -0
  35. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/index.md +0 -0
  36. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/point.md +0 -0
  37. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/antichamfer.md +0 -0
  38. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/ball_socket.md +0 -0
  39. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/basic_shapes.md +0 -0
  40. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/bolt_fittings.md +0 -0
  41. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/click_fit.md +0 -0
  42. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/dovetail.md +0 -0
  43. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/hexwall.md +0 -0
  44. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/high_top_slide_box.md +0 -0
  45. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/index.md +0 -0
  46. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/point.md +0 -0
  47. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/slide_box.md +0 -0
  48. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/reference/twist_snap.md +0 -0
  49. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/requirements.txt +0 -0
  50. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/twist_snap.md +0 -0
  51. {b3dkit-0.5.0 → b3dkit-0.5.3}/docs/twist_snap.png +0 -0
  52. {b3dkit-0.5.0 → b3dkit-0.5.3}/mkdocs.yml +0 -0
  53. {b3dkit-0.5.0 → b3dkit-0.5.3}/readthedocs.yaml +0 -0
  54. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/__init__.py +0 -0
  55. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/ball_socket.py +0 -0
  56. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/click_fit.py +0 -0
  57. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/dovetail.py +0 -0
  58. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/hexwall.py +0 -0
  59. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/point.py +0 -0
  60. {b3dkit-0.5.0 → b3dkit-0.5.3}/src/b3dkit/py.typed +0 -0
  61. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_antichamfer.py +0 -0
  62. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_ball_socket.py +0 -0
  63. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_basic_shapes.py +0 -0
  64. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_bolt_fittings.py +0 -0
  65. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_click_fit.py +0 -0
  66. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_docs.py +0 -0
  67. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_dovetail.py +0 -0
  68. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_hexwall.py +0 -0
  69. {b3dkit-0.5.0 → b3dkit-0.5.3}/tests/test_point.py +0 -0
@@ -7,6 +7,44 @@ ship in minor releases.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.3] - 2026-10-08
11
+
12
+ ### Changed
13
+
14
+ - **`slide_box` cavity walls are `wall_thickness` thick.** The cavity follows
15
+ the part's outline at `top_offset` below the top, so lettering or other top
16
+ features no longer shape it, and `top_offset` and `tolerance` no longer thin
17
+ the walls.
18
+
19
+ ## [0.5.2] - 2026-10-03
20
+
21
+ ### Changed
22
+
23
+ - **`slide_box` sliding edges are filleted** by `wall_thickness * 0.025` on
24
+ both the slot and the lid.
25
+
26
+ ### Fixed
27
+
28
+ - **build123d 0.12 and 0.13 support.** `TeardropBoltCutSinkhole`,
29
+ `BoltCutSinkhole`, `SquareNutSinkhole`, `TwistSnapSocket` and the
30
+ `high_top_slide_box` parts built the wrong geometry on build123d 0.12+, which
31
+ places geometry inside located builders differently. Every public object now
32
+ builds identically on 0.11, 0.12 and 0.13; 0.11 geometry is unchanged.
33
+ - **`Teardrop`'s `rotation` is a float**, defaulting to `0`. The old `(0, 0)`
34
+ default raised `TypeError` on build123d 0.12+.
35
+
36
+ ## [0.5.1] - 2026-10-01
37
+
38
+ ### Changed
39
+
40
+ - **`slide_box` lids no longer intersect the box.** The lid was
41
+ `wall_thickness + tolerance` thick in a `wall_thickness` slot. `tolerance` is
42
+ now the total gap on every mating face, split evenly: the slot grows by half
43
+ and the lid shrinks by half, so the lid is `wall_thickness - tolerance / 2`
44
+ thick and its top stays flush. Box, lid and `slide_lid` geometry all change.
45
+ - **`slide_box` divots now line up.** The box detents and lid bumps were placed
46
+ from different tolerances and sat up to `tolerance` apart.
47
+
10
48
  ## [0.5.0] - 2026-09-09
11
49
 
12
50
  ### Changed
@@ -146,7 +184,10 @@ the Python 3.11 floor.
146
184
  See the git history. b3dkit began as `fb-library`; the rename accompanied a
147
185
  rewrite that reworked names and usage to feel closer to native build123d.
148
186
 
149
- [Unreleased]: https://github.com/x0pherl/b3dkit/compare/v0.5.0...HEAD
187
+ [Unreleased]: https://github.com/x0pherl/b3dkit/compare/v0.5.3...HEAD
188
+ [0.5.3]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.3
189
+ [0.5.2]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.2
190
+ [0.5.1]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.1
150
191
  [0.5.0]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.0
151
192
  [0.4.0]: https://github.com/x0pherl/b3dkit/releases/tag/v0.4.0
152
193
  [0.3.2]: https://github.com/x0pherl/b3dkit/releases/tag/v0.3.2
@@ -84,6 +84,11 @@ release, raise the floor in `pyproject.toml` in the same change. b3dkit 0.1.5
84
84
  shipped code that could not run against its own advertised minimum, which is
85
85
  what that CI job exists to prevent.
86
86
 
87
+ Inside a `BuildPart` that has a location, do not open another builder or
88
+ sketch, and give operations geometry from the builder (`builder.faces()`), not
89
+ from `builder.part`. build123d 0.11 and 0.12+ place geometry differently in
90
+ both cases.
91
+
87
92
  Do not add an upper bound. Caps propagate into every downstream resolution and
88
93
  convert a runtime failure into an install failure for everyone.
89
94
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: b3dkit
3
- Version: 0.5.0
3
+ Version: 0.5.3
4
4
  Summary: build123d libraries and utilities
5
5
  Project-URL: Homepage, https://github.com/x0pherl/b3dkit
6
6
  Project-URL: Issues, https://github.com/x0pherl/b3dkit/issues
@@ -45,7 +45,9 @@ The `x_straighten_distance` parameter controls how much of the sliding mechanism
45
45
  ## Design Considerations
46
46
 
47
47
  ### Tolerances
48
- The `tolerance` parameter controls the fit between the lid and base:
48
+ The `tolerance` parameter is the total gap between the lid and base on every mating face, split evenly between the two: the slot grows by half of it and the lid shrinks by half of it. The lid is `wall_thickness - tolerance / 2` thick, with its top flush with the box.
49
+
50
+ Typical values:
49
51
  - **0.1mm**: Very tight fit, may require force to operate
50
52
  - **0.15mm**: Standard fit (default) - smooth operation with minimal play
51
53
  - **0.2-0.25mm**: Loose fit for rough printing or materials that swell
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "b3dkit"
3
- version = "0.5.0"
3
+ version = "0.5.3"
4
4
  authors = [
5
5
  { name="x0pherl"},
6
6
  ]
@@ -72,6 +72,8 @@ filterwarnings = [
72
72
  "ignore::DeprecationWarning:pyparsing.*",
73
73
  "default::DeprecationWarning:build123d.*",
74
74
  "default::PendingDeprecationWarning:build123d.*",
75
+ # build123d 0.13 renamed add() to insert(); 0.11 has no insert().
76
+ "ignore:add\\(\\) is deprecated:DeprecationWarning",
75
77
  ]
76
78
 
77
79
  [tool.black]
@@ -11,7 +11,6 @@ from build123d import (
11
11
  Face,
12
12
  Iterable,
13
13
  Location,
14
- Mode,
15
14
  Part,
16
15
  add,
17
16
  extrude,
@@ -78,18 +77,19 @@ def anti_chamfer(
78
77
  if length == 0 or length2 == 0:
79
78
  return target
80
79
 
80
+ taper = -degrees(atan(length2 / length))
81
+ if context is not None:
82
+ # extrude straight into the caller's builder: a builder opened here would
83
+ # nest inside it, and build123d 0.12 changed where nested builders place
84
+ # their geometry
85
+ for f in faces_list:
86
+ extrude(f.offset(-length), amount=length, taper=taper)
87
+ return Part(Compound([context.part]).wrapped)
88
+
81
89
  with BuildPart() as new_part:
82
90
  add(target)
83
91
  for f in faces_list:
84
- extrude(
85
- f.offset(-length),
86
- amount=length,
87
- taper=-degrees(atan(length2 / length)),
88
- )
89
- if context is not None:
90
- context._add_to_context(
91
- Part(Compound([new_part.part]).wrapped), mode=Mode.REPLACE
92
- )
92
+ extrude(f.offset(-length), amount=length, taper=taper)
93
93
  return Part(Compound([new_part.part]).wrapped)
94
94
 
95
95
 
@@ -313,7 +313,7 @@ class Teardrop(BaseSketchObject):
313
313
  self,
314
314
  radius: float,
315
315
  peak_distance: float,
316
- rotation: RotationLike = (0, 0),
316
+ rotation: float = 0,
317
317
  align: None | Align | tuple[Align, Align] = None,
318
318
  mode: Mode = Mode.ADD,
319
319
  ):
@@ -92,7 +92,7 @@ class TeardropBoltCutSinkhole(BasePartObject):
92
92
  amount=extension_distance,
93
93
  )
94
94
  anti_chamfer(
95
- sinkhole.part.faces().sort_by(Axis.Z)[-1],
95
+ sinkhole.faces().sort_by(Axis.Z)[-1],
96
96
  chamfer_radius,
97
97
  )
98
98
  super().__init__(
@@ -205,7 +205,7 @@ class SquareNutSinkhole(BasePartObject):
205
205
  nut_length,
206
206
  align=(Align.CENTER, Align.MIN, Align.CENTER),
207
207
  )
208
- extrude(nut.part.faces().sort_by(Axis.Z)[-1], amount=nut_depth)
208
+ extrude(nut.faces().sort_by(Axis.Z)[-1], amount=nut_depth)
209
209
  if bolt_extension > 0:
210
210
  with BuildPart(Location((0, bolt_depth + nut_height, 0))) as nut:
211
211
  TeardropCylinder(
@@ -83,10 +83,11 @@ def _slide_top_rail_cut(
83
83
  -(part_depth - wall_thickness) / 2,
84
84
  )
85
85
 
86
- with BuildPart(
87
- Location((0, wall_thickness / 2, 0)),
88
- mode=Mode.SUBTRACT,
89
- ) as rail_cut:
86
+ # every child below is a nested builder or sketch, so this builder takes no
87
+ # location of its own: build123d 0.11 ignores a parent's location in nested
88
+ # builders and 0.12 composes it, so only an unlocated parent means the same
89
+ # thing to both
90
+ with BuildPart() as rail_cut:
90
91
  with BuildSketch(Location((0, wall_thickness / 2))):
91
92
  with BuildLine():
92
93
 
@@ -224,13 +225,15 @@ def _high_top_slide_box_top(
224
225
  )
225
226
  ):
226
227
  add(base_part)
227
- with BuildPart(mode=Mode.INTERSECT):
228
- Box(
229
- part_max_dimension,
230
- part_max_dimension,
231
- part_max_dimension,
232
- align=(Align.CENTER, Align.CENTER, Align.MIN),
233
- )
228
+ # outside the located builder above, so it sits at z=0 on every
229
+ # build123d version
230
+ Box(
231
+ part_max_dimension,
232
+ part_max_dimension,
233
+ part_max_dimension,
234
+ align=(Align.CENTER, Align.CENTER, Align.MIN),
235
+ mode=Mode.INTERSECT,
236
+ )
234
237
  with BuildPart(
235
238
  Location((0, wall_thickness / 2, rail_height)),
236
239
  mode=Mode.SUBTRACT,
@@ -389,6 +392,19 @@ def high_top_slide_box_base(
389
392
  part_depth = base_part.bounding_box().size.Y
390
393
  part_height = base_part.bounding_box().size.Z
391
394
 
395
+ # built before any builder opens: inside one it would be a nested builder,
396
+ # and build123d 0.12 changed where nested builders place their geometry
397
+ top_cut = _high_top_slide_box_top(
398
+ base_part,
399
+ top_height,
400
+ rail_height,
401
+ wall_thickness,
402
+ rail_angle,
403
+ divot_radius,
404
+ tolerance,
405
+ cut_template=True,
406
+ )
407
+
392
408
  with BuildPart() as boxbottom:
393
409
  add(base_part)
394
410
  with BuildPart(
@@ -423,18 +439,7 @@ def high_top_slide_box_base(
423
439
  ),
424
440
  mode=Mode.SUBTRACT,
425
441
  ):
426
- add(
427
- _high_top_slide_box_top(
428
- base_part,
429
- top_height,
430
- rail_height,
431
- wall_thickness,
432
- rail_angle,
433
- divot_radius,
434
- tolerance,
435
- cut_template=True,
436
- )
437
- )
442
+ add(top_cut)
438
443
  with BuildPart(
439
444
  Location(
440
445
  (
@@ -7,7 +7,7 @@ They are exempt from the compatibility promise in ``__all__`` until that is
7
7
  settled.
8
8
  """
9
9
 
10
- from math import radians, tan
10
+ from math import cos, radians, tan
11
11
 
12
12
  from build123d import (
13
13
  Align,
@@ -43,6 +43,13 @@ __all__ = [
43
43
  ]
44
44
 
45
45
 
46
+ def _tapered_inset(clearance: float) -> float:
47
+ """
48
+ The horizontal shift that moves a tapered face ``clearance`` along its normal.
49
+ """
50
+ return clearance / cos(radians(_SLIDER_TAPER_ANGLE))
51
+
52
+
46
53
  def _divot_spacing(
47
54
  sketch_width: float,
48
55
  wall_thickness: float,
@@ -59,14 +66,18 @@ def _divot_spacing(
59
66
  tapered face. A grazing intersection leaves needle-thin slivers behind the
60
67
  boolean, so pull the pair far enough inboard that a divot only ever meets the
61
68
  flat underside.
69
+
70
+ The spacing is measured against the lid's tapered face, the narrower of the
71
+ two, so the box and lid templates place their divots identically.
62
72
  """
63
73
  half_width = sketch_width / 2
74
+ lid_clearance = -abs(tolerance) / 2
64
75
  # depth below the top of the template at the widest point of the divot
65
- widest_depth = max(wall_thickness - divot_radius, 0)
76
+ widest_depth = max(wall_thickness + lid_clearance - divot_radius, 0)
66
77
  tapered_face = (
67
78
  half_width
68
79
  - wall_thickness
69
- - abs(tolerance)
80
+ + _tapered_inset(lid_clearance)
70
81
  + widest_depth * tan(radians(_SLIDER_TAPER_ANGLE))
71
82
  )
72
83
  return (
@@ -82,20 +93,27 @@ def _slider_template(
82
93
  sketch: Sketch,
83
94
  wall_thickness: float = 2,
84
95
  tolerance: float = 0.2,
85
- top_offset: float = 0,
86
96
  x_straighten_distance: float = 0,
87
97
  divot_radius: float = 0,
88
98
  cut_template: bool = True,
89
99
  ) -> Part:
90
100
  """
91
101
  Create a slider part based on a sketch.
102
+
103
+ ``tolerance`` is the total gap between lid and box; the cut template grows
104
+ by half of it and the lid template shrinks by half of it. The top of the
105
+ template stays at the top of the sketch either way.
92
106
  """
107
+ clearance = abs(tolerance) / 2 * (1 if cut_template else -1)
93
108
  with BuildPart() as slider_part:
94
109
  with BuildSketch() as top_sketch:
95
- offset(sketch, amount=-abs(tolerance) - (abs(wall_thickness)))
110
+ offset(
111
+ sketch,
112
+ amount=-abs(wall_thickness) + _tapered_inset(clearance),
113
+ )
96
114
  extrude(
97
115
  top_sketch.sketch,
98
- amount=-wall_thickness - abs(tolerance),
116
+ amount=-wall_thickness - clearance,
99
117
  taper=-_SLIDER_TAPER_ANGLE,
100
118
  )
101
119
  cross_section = section(
@@ -111,15 +129,20 @@ def _slider_template(
111
129
  cross_section, amount=x_straighten_distance * 2 + wall_thickness * 2
112
130
  )
113
131
  )
132
+ fillet(
133
+ slider_part.edges() - slider_part.faces().filter_by(Plane.XY).edges(),
134
+ wall_thickness * 0.025,
135
+ )
136
+ fillet(slider_part.faces().sort_by(Axis.Z)[0].edges(), wall_thickness * 0.025)
137
+
114
138
  if divot_radius > 0:
115
139
  # a divot wider than half the wall would overhang the open front of
116
- # the template; keep it far enough back to meet only the underside
117
- divot_y = max(
118
- sketch.bounding_box().min.Y + wall_thickness / 2,
119
- slider_part.part.bounding_box().min.Y + divot_radius + divot_radius / 5,
140
+ # the part; keep it far enough back to meet only the underside
141
+ divot_y = sketch.bounding_box().min.Y + max(
142
+ wall_thickness / 2, divot_radius + divot_radius / 5
120
143
  )
121
144
  with BuildPart(
122
- Location((0, divot_y, -wall_thickness), (180, 0, 0)),
145
+ Location((0, divot_y, -wall_thickness - clearance), (180, 0, 0)),
123
146
  mode=Mode.ADD,
124
147
  ):
125
148
  with GridLocations(
@@ -161,7 +184,7 @@ def slide_lid(
161
184
  args:
162
185
  - part: the part defining the box's outer dimensions
163
186
  - wall_thickness: the thickness of the box walls
164
- - tolerance: the clearance between the lid and the base; 0.1 is a tight
187
+ - tolerance: the total gap between the lid and the base; 0.1 is a tight
165
188
  fit, 0.15 the default, 0.2 and above loose
166
189
  - top_offset: how far below the top of the part the sliding section sits,
167
190
  for parts with features at the top worth preserving
@@ -179,7 +202,6 @@ def slide_lid(
179
202
  cross_section,
180
203
  wall_thickness,
181
204
  tolerance=tolerance,
182
- top_offset=top_offset,
183
205
  x_straighten_distance=x_straighten_distance,
184
206
  divot_radius=divot_radius,
185
207
  cut_template=False,
@@ -247,36 +269,35 @@ def slide_box(
247
269
  - thumb_radius: the radius of the thumb-grip cutout; 0 disables it
248
270
  - x_straighten_distance: how much of the slider stays straight at the
249
271
  edges, which stops wide lids binding at the corners
250
- - tolerance: the clearance between the lid and the base
272
+ - tolerance: the total gap between the lid and the base, split evenly
273
+ between the slot and the lid
251
274
  - divot_radius: the radius of the click-fit positioning divots; 0
252
275
  disables them
253
276
 
254
277
  returns a Compound labelled "slide box" with two children, "box" and "lid"
255
278
  """
256
279
 
257
- cross_section = section(
258
- obj=part, section_by=Plane.XY.offset(part.bounding_box().max.Z - top_offset)
259
- )
280
+ top = part.bounding_box().max.Z
281
+ cross_section = section(obj=part, section_by=Plane.XY.offset(top - top_offset))
260
282
  lid_cut_template = _slider_template(
261
283
  cross_section,
262
284
  wall_thickness,
263
- tolerance=0,
264
- top_offset=top_offset,
285
+ tolerance=tolerance,
265
286
  x_straighten_distance=x_straighten_distance,
266
287
  divot_radius=divot_radius,
267
288
  )
268
289
 
269
- extrusion_height = part.bounding_box().max.Z - wall_thickness
290
+ extrusion_height = top - wall_thickness
270
291
  with BuildPart() as box_part:
271
292
  add(part)
272
- extrude(
293
+ # the cavity follows the outline below any top features, raised to the
294
+ # top of the part
295
+ with BuildSketch(Plane.XY.offset(top)):
273
296
  offset(
274
- box_part.faces().sort_by(Axis.Z)[-1],
275
- amount=-abs(tolerance) - abs(wall_thickness - top_offset),
276
- ),
277
- amount=-extrusion_height,
278
- mode=Mode.SUBTRACT,
279
- )
297
+ cross_section.moved(Location((0, 0, -(top - top_offset)))),
298
+ amount=-abs(wall_thickness),
299
+ )
300
+ extrude(amount=-extrusion_height, mode=Mode.SUBTRACT)
280
301
  add(
281
302
  lid_cut_template.move(Location((0, 0, extrusion_height + wall_thickness))),
282
303
  mode=Mode.SUBTRACT,
@@ -303,11 +324,20 @@ def slide_box(
303
324
 
304
325
 
305
326
  if __name__ == "__main__":
306
- from ocp_vscode import Camera, show
327
+ from ocp_vscode import show
307
328
 
308
329
  with BuildPart() as base_box:
309
330
  Box(20, 44, 14, align=(Align.CENTER, Align.CENTER, Align.MIN))
310
331
  fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
311
332
 
312
- sb = slide_box(base_box.part, wall_thickness=2, thumb_radius=3.5, divot_radius=0.5)
313
- show(sb, reset_camera=Camera.KEEP)
333
+ sb = slide_box(
334
+ base_box.part,
335
+ wall_thickness=2,
336
+ thumb_radius=3.5,
337
+ divot_radius=0.5,
338
+ tolerance=0.3,
339
+ )
340
+
341
+ show(
342
+ sb,
343
+ )
@@ -204,8 +204,11 @@ class TwistSnapSocket(BasePartObject):
204
204
  align=(Align.CENTER, Align.CENTER, Align.MIN),
205
205
  mode=Mode.SUBTRACT,
206
206
  )
207
+ # snap_socket.part, not snap_socket.edges(): build123d 0.12 reports a
208
+ # located builder's edges in its local frame, and this path is used
209
+ # outside it
207
210
  trace_path = (
208
- snap_socket.edges()
211
+ snap_socket.part.edges()
209
212
  .filter_by(GeomType.CIRCLE)
210
213
  .sort_by(Axis.Z, reverse=True)
211
214
  .sort_by(SortBy.RADIUS, reverse=True)[-1]
@@ -90,6 +90,7 @@ class TestHighTopSlideBox:
90
90
  assert isinstance(lid, Part)
91
91
  assert_single_solid(lid, "lid")
92
92
  assert lid.label == "box top"
93
+ assert lid.volume == pytest.approx(1664.0654, rel=1e-4)
93
94
 
94
95
  def test_high_top_slide_box_lid_with_params(self, small_base_part):
95
96
  """Test high_top_slide_box_lid with various parameters."""
@@ -107,7 +107,7 @@ class TestPartObjectContract:
107
107
  against a shape it is given, so rotation and align cannot mean anything
108
108
  for it -- both were measurably inert when it still offered them.
109
109
  """
110
- params = list(inspect.signature(getattr(b3dkit, name)).parameters)
110
+ params = list(inspect.signature(getattr(b3dkit, name).__init__).parameters)
111
111
  if name in POSITIONED_AGAINST_INPUT:
112
112
  assert params[-1] == "mode", f"{name} should still end with mode"
113
113
  assert (
@@ -0,0 +1,185 @@
1
+ from math import cos, radians
2
+
3
+ import pytest
4
+ from build123d import (
5
+ Align,
6
+ Axis,
7
+ Box,
8
+ BuildPart,
9
+ Edge,
10
+ Location,
11
+ Plane,
12
+ fillet,
13
+ section,
14
+ )
15
+
16
+ from b3dkit.slide_box import _slider_template, slide_box
17
+
18
+
19
+ class TestSlideBox:
20
+ def test_slide_box(self):
21
+ with BuildPart() as base_box:
22
+ Box(20, 44, 14, align=(Align.CENTER, Align.CENTER, Align.MIN))
23
+ fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
24
+
25
+ sb = slide_box(
26
+ base_box.part, wall_thickness=2, thumb_radius=3.5, divot_radius=0.5
27
+ )
28
+ box, lid = sb.children
29
+ assert (box.label, lid.label) == ("box", "lid")
30
+ assert len(box.solids()) == 1
31
+ assert len(lid.solids()) == 1
32
+ assert box.volume == pytest.approx(4439.0809, rel=1e-4)
33
+ assert lid.volume == pytest.approx(1344.8752, rel=1e-4)
34
+ # the lid slides inside the box, so it must be narrower
35
+ assert lid.bounding_box().size.X < box.bounding_box().size.X
36
+
37
+
38
+ class TestSliderDivots:
39
+ """
40
+ The divots sit against the tapered sliding faces of the template. Placing one
41
+ so that it merely grazes a tapered face leaves needle-thin slivers behind the
42
+ boolean, which show up as a nick in the rail the lid slides through.
43
+
44
+ A pair of divots must therefore contribute exactly two solids, and each must
45
+ be a real divot rather than a sliver -- the two failure modes are a doubled
46
+ solid count and a solid orders of magnitude too small.
47
+ """
48
+
49
+ @pytest.fixture(scope="class")
50
+ @classmethod
51
+ def sketch(cls):
52
+ with BuildPart() as blank:
53
+ Box(60, 100, 20, align=(Align.CENTER, Align.CENTER, Align.MIN))
54
+ fillet(blank.part.edges().filter_by(Axis.Z), radius=1.5)
55
+ return section(obj=blank.part, section_by=Plane.XY.offset(20))
56
+
57
+ @staticmethod
58
+ def _divot_solids(sketch, wall, radius, tolerance, cut_template=True):
59
+ """the geometry the divots add to an otherwise identical template"""
60
+ with_divots = _slider_template(
61
+ sketch,
62
+ wall,
63
+ tolerance=tolerance,
64
+ divot_radius=radius,
65
+ cut_template=cut_template,
66
+ )
67
+ without_divots = _slider_template(
68
+ sketch, wall, tolerance=tolerance, divot_radius=0, cut_template=cut_template
69
+ )
70
+ return (with_divots - without_divots).solids()
71
+
72
+ @pytest.mark.parametrize("cut_template", [True, False])
73
+ @pytest.mark.parametrize("wall", [2.0, 3.0, 4.0])
74
+ @pytest.mark.parametrize("radius", [0.5, 0.75, 0.9, 1.0, 1.25])
75
+ def test_divots_contribute_exactly_two_solids(
76
+ self, sketch, wall, radius, cut_template
77
+ ):
78
+ # the sweep matters -- the tangency only bites on some wall/radius
79
+ # combinations, so any single pairing passes on its own
80
+ solids = self._divot_solids(
81
+ sketch, wall, radius, tolerance=0.2, cut_template=cut_template
82
+ )
83
+
84
+ assert len(solids) == 2
85
+ assert min(s.volume for s in solids) > radius**3 * 1e-2
86
+
87
+ def test_wide_divot_clears_the_open_front(self, sketch):
88
+ # a divot wider than half the wall used to overhang the front face of
89
+ # the template, slivering against it the same way the taper did
90
+ solids = self._divot_solids(sketch, wall=1.5, radius=1.25, tolerance=0.3)
91
+
92
+ assert len(solids) == 2
93
+ assert min(s.volume for s in solids) > 1.25**3 * 1e-2
94
+
95
+
96
+ class TestSlideBoxFit:
97
+ """
98
+ The tolerance is the total gap between lid and box on every mating face:
99
+ the slot grows by half of it and the lid shrinks by half of it. The lid
100
+ top stays flush with the box top.
101
+ """
102
+
103
+ WALL = 2
104
+ TOP = 14
105
+
106
+ @pytest.fixture(scope="class")
107
+ @classmethod
108
+ def base(cls):
109
+ with BuildPart() as base_box:
110
+ Box(20, 44, cls.TOP, align=(Align.CENTER, Align.CENTER, Align.MIN))
111
+ fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
112
+ return base_box.part
113
+
114
+ def _fit(self, base, tolerance, divot_radius=0):
115
+ """the box and the lid flipped back into place on it"""
116
+ box, lid = slide_box(
117
+ base,
118
+ wall_thickness=self.WALL,
119
+ thumb_radius=0,
120
+ tolerance=tolerance,
121
+ divot_radius=divot_radius,
122
+ ).children
123
+ return box, lid.moved(Location((0, 0, 0), (0, 180, 0)))
124
+
125
+ @staticmethod
126
+ def _along_x(part, z):
127
+ """the edges where a line along +x at y=0 passes through the part"""
128
+ return (Edge.make_line((0, 0, z), (20, 0, z)) & part).edges()
129
+
130
+ @pytest.mark.parametrize("tolerance", [0.1, 0.15, 0.3])
131
+ def test_lid_thickness(self, base, tolerance):
132
+ _, lid = self._fit(base, tolerance)
133
+
134
+ assert lid.bounding_box().max.Z == pytest.approx(self.TOP, abs=1e-4)
135
+ assert lid.bounding_box().size.Z == pytest.approx(
136
+ self.WALL - tolerance / 2, abs=1e-4
137
+ )
138
+
139
+ @pytest.mark.parametrize("tolerance", [0.1, 0.15, 0.3])
140
+ def test_slot_depth(self, base, tolerance):
141
+ box, _ = self._fit(base, tolerance)
142
+ floor = self.TOP - self.WALL - tolerance / 2
143
+
144
+ def first_material(z):
145
+ return min(e.bounding_box().min.X for e in self._along_x(box, z))
146
+
147
+ # the slot floor is a ledge: just above it the opening reaches out to
148
+ # the rail, just below it only to the inner wall of the box
149
+ cavity_wall = 10 - self.WALL
150
+ assert first_material(floor - 0.01) == pytest.approx(cavity_wall, abs=1e-4)
151
+ assert first_material(floor + 0.01) > cavity_wall + 0.5
152
+
153
+ @pytest.mark.parametrize("tolerance", [0.1, 0.15, 0.3])
154
+ def test_tapered_face_gap(self, base, tolerance):
155
+ box, lid = self._fit(base, tolerance)
156
+ z = self.TOP - 0.5
157
+
158
+ slot_face = min(e.bounding_box().min.X for e in self._along_x(box, z))
159
+ lid_face = max(e.bounding_box().max.X for e in self._along_x(lid, z))
160
+ # measured horizontally, a normal gap of `tolerance` on a face leaning
161
+ # 22.5 degrees
162
+ assert slot_face - lid_face == pytest.approx(
163
+ tolerance / cos(radians(22.5)), abs=1e-4
164
+ )
165
+
166
+ @pytest.mark.parametrize("divot_radius", [0, 0.5])
167
+ @pytest.mark.parametrize("tolerance", [0.1, 0.15, 0.3])
168
+ def test_no_interference(self, base, tolerance, divot_radius):
169
+ box, lid = self._fit(base, tolerance, divot_radius)
170
+
171
+ overlap = box & lid
172
+ assert sum(s.volume for s in overlap.solids()) == pytest.approx(0, abs=1e-6)
173
+
174
+ @pytest.mark.parametrize("tolerance", [0.1, 0.15, 0.3])
175
+ def test_divots_align(self, base, tolerance):
176
+ plain_box, plain_lid = self._fit(base, tolerance)
177
+ box, lid = self._fit(base, tolerance, divot_radius=0.5)
178
+
179
+ detents = sorted(
180
+ (s.center().X, s.center().Y) for s in (plain_box - box).solids()
181
+ )
182
+ bumps = sorted((s.center().X, s.center().Y) for s in (lid - plain_lid).solids())
183
+ assert len(detents) == len(bumps) == 2
184
+ for detent, bump in zip(detents, bumps, strict=True):
185
+ assert detent == pytest.approx(bump, abs=1e-4)
@@ -31,7 +31,7 @@ class TestTwistSnap:
31
31
  """
32
32
  import inspect
33
33
 
34
- params = inspect.signature(TwistSnapConnector).parameters
34
+ params = inspect.signature(TwistSnapConnector.__init__).parameters
35
35
  assert "tolerance" not in params
36
36
  assert "wall_width" not in params
37
37
 
@@ -1,72 +0,0 @@
1
- import pytest
2
- from build123d import Align, Axis, Box, BuildPart, Plane, fillet, section
3
-
4
- from b3dkit.slide_box import _slider_template, slide_box
5
-
6
-
7
- class TestSlideBox:
8
- def test_slide_box(self):
9
- with BuildPart() as base_box:
10
- Box(20, 44, 14, align=(Align.CENTER, Align.CENTER, Align.MIN))
11
- fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
12
-
13
- sb = slide_box(
14
- base_box.part, wall_thickness=2, thumb_radius=3.5, divot_radius=0.5
15
- )
16
- box, lid = sb.children
17
- assert (box.label, lid.label) == ("box", "lid")
18
- assert len(box.solids()) == 1
19
- assert len(lid.solids()) == 1
20
- assert box.volume == pytest.approx(4632.0405, rel=1e-4)
21
- assert lid.volume == pytest.approx(1498.3503, rel=1e-4)
22
- # the lid slides inside the box, so it must be narrower
23
- assert lid.bounding_box().size.X < box.bounding_box().size.X
24
-
25
-
26
- class TestSliderDivots:
27
- """
28
- The divots sit against the tapered sliding faces of the template. Placing one
29
- so that it merely grazes a tapered face leaves needle-thin slivers behind the
30
- boolean, which show up as a nick in the rail the lid slides through.
31
-
32
- A pair of divots must therefore contribute exactly two solids, and each must
33
- be a real divot rather than a sliver -- the two failure modes are a doubled
34
- solid count and a solid orders of magnitude too small.
35
- """
36
-
37
- @pytest.fixture(scope="class")
38
- @classmethod
39
- def sketch(cls):
40
- with BuildPart() as blank:
41
- Box(60, 100, 20, align=(Align.CENTER, Align.CENTER, Align.MIN))
42
- fillet(blank.part.edges().filter_by(Axis.Z), radius=1.5)
43
- return section(obj=blank.part, section_by=Plane.XY.offset(20))
44
-
45
- @staticmethod
46
- def _divot_solids(sketch, wall, radius, tolerance):
47
- """the geometry the divots add to an otherwise identical template"""
48
- with_divots = _slider_template(
49
- sketch, wall, tolerance=tolerance, divot_radius=radius, cut_template=True
50
- )
51
- without_divots = _slider_template(
52
- sketch, wall, tolerance=tolerance, divot_radius=0, cut_template=True
53
- )
54
- return (with_divots - without_divots).solids()
55
-
56
- @pytest.mark.parametrize("wall", [2.0, 3.0, 4.0])
57
- @pytest.mark.parametrize("radius", [0.5, 0.75, 0.9, 1.0, 1.25])
58
- def test_divots_contribute_exactly_two_solids(self, sketch, wall, radius):
59
- # the sweep matters -- the tangency only bites on some wall/radius
60
- # combinations, so any single pairing passes on its own
61
- solids = self._divot_solids(sketch, wall, radius, tolerance=0.2)
62
-
63
- assert len(solids) == 2
64
- assert min(s.volume for s in solids) > radius**3 * 1e-2
65
-
66
- def test_wide_divot_clears_the_open_front(self, sketch):
67
- # a divot wider than half the wall used to overhang the front face of
68
- # the template, slivering against it the same way the taper did
69
- solids = self._divot_solids(sketch, wall=1.5, radius=1.25, tolerance=0.3)
70
-
71
- assert len(solids) == 2
72
- assert min(s.volume for s in solids) > 1.25**3 * 1e-2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes