b3dkit 0.4.0__tar.gz → 0.5.2__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 (72) hide show
  1. b3dkit-0.5.2/.coveragerc +20 -0
  2. {b3dkit-0.4.0 → b3dkit-0.5.2}/CHANGELOG.md +68 -1
  3. {b3dkit-0.4.0 → b3dkit-0.5.2}/CONTRIBUTING.md +5 -0
  4. {b3dkit-0.4.0 → b3dkit-0.5.2}/PKG-INFO +1 -1
  5. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/slide_box.md +3 -1
  6. {b3dkit-0.4.0 → b3dkit-0.5.2}/pyproject.toml +12 -3
  7. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/antichamfer.py +10 -10
  8. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/basic_shapes.py +1 -1
  9. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/bolt_fittings.py +5 -5
  10. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/dovetail.py +23 -31
  11. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/high_top_slide_box.py +28 -23
  12. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/slide_box.py +50 -16
  13. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/twist_snap.py +4 -1
  14. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_antichamfer.py +4 -19
  15. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_ball_socket.py +4 -13
  16. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_basic_shapes.py +0 -15
  17. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_bolt_fittings.py +47 -20
  18. b3dkit-0.5.2/tests/test_click_fit.py +19 -0
  19. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_dovetail.py +137 -25
  20. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_hexwall.py +10 -19
  21. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_high_top_slide_box.py +1 -18
  22. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_public_api.py +1 -1
  23. b3dkit-0.5.2/tests/test_slide_box.py +185 -0
  24. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_twist_snap.py +11 -20
  25. b3dkit-0.4.0/.coveragerc +0 -6
  26. b3dkit-0.4.0/tests/conftest.py +0 -12
  27. b3dkit-0.4.0/tests/test_click_fit.py +0 -32
  28. b3dkit-0.4.0/tests/test_slide_box.py +0 -83
  29. {b3dkit-0.4.0 → b3dkit-0.5.2}/.github/workflows/release.yml +0 -0
  30. {b3dkit-0.4.0 → b3dkit-0.5.2}/.github/workflows/test.yml +0 -0
  31. {b3dkit-0.4.0 → b3dkit-0.5.2}/.gitignore +0 -0
  32. {b3dkit-0.4.0 → b3dkit-0.5.2}/.pre-commit-config.yaml +0 -0
  33. {b3dkit-0.4.0 → b3dkit-0.5.2}/.vscode/settings.json +0 -0
  34. {b3dkit-0.4.0 → b3dkit-0.5.2}/LICENSE +0 -0
  35. {b3dkit-0.4.0 → b3dkit-0.5.2}/README.md +0 -0
  36. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/CONTRIBUTING.md +0 -0
  37. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/antichamfer.md +0 -0
  38. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/ball_socket.md +0 -0
  39. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/basic_shapes.md +0 -0
  40. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/bolt_fittings.md +0 -0
  41. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/click_fit.md +0 -0
  42. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/dovetail.md +0 -0
  43. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/dovetail.png +0 -0
  44. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/hexwall.md +0 -0
  45. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/high_top_slide_box.md +0 -0
  46. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/index.md +0 -0
  47. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/point.md +0 -0
  48. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/antichamfer.md +0 -0
  49. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/ball_socket.md +0 -0
  50. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/basic_shapes.md +0 -0
  51. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/bolt_fittings.md +0 -0
  52. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/click_fit.md +0 -0
  53. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/dovetail.md +0 -0
  54. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/hexwall.md +0 -0
  55. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/high_top_slide_box.md +0 -0
  56. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/index.md +0 -0
  57. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/point.md +0 -0
  58. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/slide_box.md +0 -0
  59. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/reference/twist_snap.md +0 -0
  60. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/requirements.txt +0 -0
  61. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/twist_snap.md +0 -0
  62. {b3dkit-0.4.0 → b3dkit-0.5.2}/docs/twist_snap.png +0 -0
  63. {b3dkit-0.4.0 → b3dkit-0.5.2}/mkdocs.yml +0 -0
  64. {b3dkit-0.4.0 → b3dkit-0.5.2}/readthedocs.yaml +0 -0
  65. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/__init__.py +0 -0
  66. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/ball_socket.py +0 -0
  67. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/click_fit.py +0 -0
  68. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/hexwall.py +0 -0
  69. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/point.py +0 -0
  70. {b3dkit-0.4.0 → b3dkit-0.5.2}/src/b3dkit/py.typed +0 -0
  71. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_docs.py +0 -0
  72. {b3dkit-0.4.0 → b3dkit-0.5.2}/tests/test_point.py +0 -0
@@ -0,0 +1,20 @@
1
+ [run]
2
+ source = src/b3dkit
3
+ # `*test*` also matched any source path containing "test", so it was scoped to
4
+ # the test directory instead.
5
+ omit =
6
+ tests/*
7
+
8
+ [report]
9
+ show_missing = True
10
+ # CONTRIBUTING.md declares a 90% floor with no ratchet. Enforced here so the
11
+ # number is a gate rather than a suggestion.
12
+ fail_under = 90
13
+ exclude_lines =
14
+ pragma: no cover
15
+ # The __main__ blocks are demos, not library code. They were previously
16
+ # executed by ten tests that asserted nothing, purely so these lines would
17
+ # count toward coverage; deleting those tests is what this excludes for.
18
+ if __name__ == .__main__.:
19
+ # Raised only when the package is not installed, e.g. run from a source tree.
20
+ except PackageNotFoundError:
@@ -7,6 +7,70 @@ ship in minor releases.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - **build123d 0.12 and 0.13 support.** `TeardropBoltCutSinkhole`,
13
+ `BoltCutSinkhole`, `SquareNutSinkhole`, `TwistSnapSocket` and the
14
+ `high_top_slide_box` parts built the wrong geometry on build123d 0.12+, which
15
+ places geometry inside located builders differently. Every public object now
16
+ builds identically on 0.11, 0.12 and 0.13; 0.11 geometry is unchanged.
17
+ - **`Teardrop`'s `rotation` is a float**, defaulting to `0`. The old `(0, 0)`
18
+ default raised `TypeError` on build123d 0.12+.
19
+
20
+ ## [0.5.2] - 2026-10-03
21
+
22
+ ### Changed
23
+
24
+ - **`slide_box` sliding edges are filleted** by `wall_thickness * 0.025` on
25
+ both the slot and the lid.
26
+
27
+ ## [0.5.1] - 2026-10-01
28
+
29
+ ### Changed
30
+
31
+ - **`slide_box` lids no longer intersect the box.** The lid was
32
+ `wall_thickness + tolerance` thick in a `wall_thickness` slot. `tolerance` is
33
+ now the total gap on every mating face, split evenly: the slot grows by half
34
+ and the lid shrinks by half, so the lid is `wall_thickness - tolerance / 2`
35
+ thick and its top stays flush. Box, lid and `slide_lid` geometry all change.
36
+ - **`slide_box` divots now line up.** The box detents and lid bumps were placed
37
+ from different tolerances and sat up to `tolerance` apart.
38
+
39
+ ## [0.5.0] - 2026-09-09
40
+
41
+ ### Changed
42
+
43
+ - **`NutCut` now sits on the origin rather than straddling it.** The recess ran
44
+ from `-head_depth` to `0` while the shaft began at `+head_depth`, so the two
45
+ never met: the cutter came back as two solids with a `head_depth` gap, and
46
+ subtracting it left a membrane the bolt could not pass through. It is now one
47
+ solid spanning `0` to `head_depth + shaft_length`.
48
+
49
+ **This moves the cutter.** It previously had to be positioned *below* a
50
+ surface to work; it can now be placed *on* one. Anything calling `NutCut` and
51
+ compensating for the old placement needs that compensation removed.
52
+
53
+ - **`dovetail_subpart` no longer adds each click-fit divot twice.** One copy
54
+ landed outside the part as a free-floating sliver. Only geometry using
55
+ `click_fit_radius` is affected; the tail's fused solid is unchanged and the
56
+ socket's moves by 0.02%.
57
+
58
+ - **A positive `vertical_offset` no longer cuts the dovetail socket in two.**
59
+ Tails, `vertical_offset=0` and negative offsets are all unchanged.
60
+
61
+ ### Known issues
62
+
63
+ Found while adding solid-count assertions, all pre-existing and all invisible to
64
+ the `is_valid` checks that used to stand in for them:
65
+
66
+ - `TwistSnapConnector` returns five solids: a base and four snapfits that meet
67
+ only on a shared plane and never fuse.
68
+ - `HexWall` can return partial hexagons clipped at the bounds as separate
69
+ slivers, depending on how the grid divides the given width.
70
+ - `SNUGTAIL` splits its own tail into disconnected fins on parts wider than
71
+ about 30mm. Pinned by `TestSnugtailWidthLimit`.
72
+
73
+
10
74
  ## [0.4.0] - 2026-09-08
11
75
 
12
76
  ### Removed
@@ -111,6 +175,9 @@ the Python 3.11 floor.
111
175
  See the git history. b3dkit began as `fb-library`; the rename accompanied a
112
176
  rewrite that reworked names and usage to feel closer to native build123d.
113
177
 
114
- [Unreleased]: https://github.com/x0pherl/b3dkit/compare/v0.4.0...HEAD
178
+ [Unreleased]: https://github.com/x0pherl/b3dkit/compare/v0.5.2...HEAD
179
+ [0.5.2]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.2
180
+ [0.5.1]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.1
181
+ [0.5.0]: https://github.com/x0pherl/b3dkit/releases/tag/v0.5.0
115
182
  [0.4.0]: https://github.com/x0pherl/b3dkit/releases/tag/v0.4.0
116
183
  [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.4.0
3
+ Version: 0.5.2
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.4.0"
3
+ version = "0.5.2"
4
4
  authors = [
5
5
  { name="x0pherl"},
6
6
  ]
@@ -63,8 +63,17 @@ build-backend = "hatchling.build"
63
63
  [tool.pytest.ini_options]
64
64
  pythonpath = ["src"]
65
65
  addopts = "--strict-markers"
66
- markers = [
67
- "manual: marks tests that can only be executed manually",
66
+ filterwarnings = [
67
+ # b3dkit's own warnings are failures; we control those.
68
+ "error",
69
+ # Everything below is raised inside the dependency stack, not by b3dkit.
70
+ # ezdxf, reached through build123d, calls pyparsing's pre-snake_case API.
71
+ "ignore::DeprecationWarning:ezdxf.*",
72
+ "ignore::DeprecationWarning:pyparsing.*",
73
+ "default::DeprecationWarning:build123d.*",
74
+ "default::PendingDeprecationWarning:build123d.*",
75
+ # build123d 0.13 renamed add() to insert(); 0.11 has no insert().
76
+ "ignore:add\\(\\) is deprecated:DeprecationWarning",
68
77
  ]
69
78
 
70
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(
@@ -251,13 +251,13 @@ class NutCut(BasePartObject):
251
251
  context: BuildPart = BuildPart._get_context()
252
252
  validate_inputs(context, self)
253
253
 
254
- with BuildPart(Location((0, 0, head_depth))) as cut:
254
+ with BuildPart() as cut:
255
255
  with BuildSketch():
256
256
  RegularPolygon(radius=head_radius, side_count=6)
257
- extrude(amount=-head_depth)
257
+ extrude(amount=head_depth)
258
258
  Cylinder(
259
259
  radius=shaft_radius,
260
- height=shaft_length,
260
+ height=shaft_length + head_depth,
261
261
  align=(Align.CENTER, Align.CENTER, Align.MIN),
262
262
  )
263
263
 
@@ -528,18 +528,10 @@ def _traditional_subpart_divots(
528
528
  ),
529
529
  mode=topmode,
530
530
  ):
531
- add(
532
- Divot(
533
- click_fit_radius,
534
- positive=topmode == Mode.ADD,
535
- extend_base=True,
536
- )
537
- .rotate(
538
- Axis.X,
539
- (90 * (-1 if vertical_offset < 0 else 1))
540
- + adjusted_top_divot_angle,
541
- )
542
- .rotate(Axis.Y, cut_angle),
531
+ Divot(
532
+ click_fit_radius,
533
+ positive=topmode == Mode.ADD,
534
+ extend_base=True,
543
535
  )
544
536
  #####################################
545
537
  # Bottom divots
@@ -562,29 +554,20 @@ def _traditional_subpart_divots(
562
554
  ),
563
555
  mode=bottommode,
564
556
  ):
565
- add(
566
- Divot(
567
- click_fit_radius,
568
- positive=bottommode == Mode.ADD,
569
- extend_base=True,
570
- )
571
- .rotate(
572
- Axis.X,
573
- (90 * (1 if vertical_offset < 0 else -1)) + scarf_angle,
574
- )
575
- .rotate(Axis.Y, cut_angle),
557
+ Divot(
558
+ click_fit_radius,
559
+ positive=bottommode == Mode.ADD,
560
+ extend_base=True,
576
561
  )
577
562
  with BuildPart(
578
563
  Location((end_side.x, end_side.y, click_fit_radius * 2)),
579
564
  mode=bottommode,
580
565
  ):
581
- add(
582
- Divot(click_fit_radius, positive=True, extend_base=True)
583
- .rotate(
584
- Axis.X,
585
- (90 * (1 if vertical_offset < 0 else -1)) + scarf_angle,
586
- )
587
- .rotate(Axis.Y, cut_angle),
566
+ Divot(
567
+ click_fit_radius,
568
+ # was hardcoded True while the sibling divot above derives it
569
+ positive=bottommode == Mode.ADD,
570
+ extend_base=True,
588
571
  )
589
572
 
590
573
  return divotedpart.part
@@ -954,7 +937,16 @@ def dovetail_subpart(
954
937
  current_floor = (
955
938
  part.bounding_box().min.Z
956
939
  if vertical_offset <= 0
957
- else part.bounding_box().min.Z + abs(vertical_offset) + vertical_tolerance
940
+ # Must match the straightened slab's top_z above, which uses
941
+ # vertical_tolerance_adjustment. Using the raw vertical_tolerance
942
+ # here agreed for the tail, where the adjustment is +tolerance, and
943
+ # disagreed for the socket, where it is -tolerance: the two slabs
944
+ # then missed each other by 2 * vertical_tolerance, cutting a slot
945
+ # clean through the socket and leaving it in two pieces. The
946
+ # vertical_offset < 0 branch below already uses the adjustment.
947
+ else part.bounding_box().min.Z
948
+ + abs(vertical_offset)
949
+ + vertical_tolerance_adjustment
958
950
  )
959
951
  add(
960
952
  _subpart_slab(
@@ -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 (
@@ -89,13 +100,21 @@ def _slider_template(
89
100
  ) -> Part:
90
101
  """
91
102
  Create a slider part based on a sketch.
103
+
104
+ ``tolerance`` is the total gap between lid and box; the cut template grows
105
+ by half of it and the lid template shrinks by half of it. The top of the
106
+ template stays at the top of the sketch either way.
92
107
  """
108
+ clearance = abs(tolerance) / 2 * (1 if cut_template else -1)
93
109
  with BuildPart() as slider_part:
94
110
  with BuildSketch() as top_sketch:
95
- offset(sketch, amount=-abs(tolerance) - (abs(wall_thickness)))
111
+ offset(
112
+ sketch,
113
+ amount=-abs(wall_thickness) + _tapered_inset(clearance),
114
+ )
96
115
  extrude(
97
116
  top_sketch.sketch,
98
- amount=-wall_thickness - abs(tolerance),
117
+ amount=-wall_thickness - clearance,
99
118
  taper=-_SLIDER_TAPER_ANGLE,
100
119
  )
101
120
  cross_section = section(
@@ -111,15 +130,20 @@ def _slider_template(
111
130
  cross_section, amount=x_straighten_distance * 2 + wall_thickness * 2
112
131
  )
113
132
  )
133
+ fillet(
134
+ slider_part.edges() - slider_part.faces().filter_by(Plane.XY).edges(),
135
+ wall_thickness * 0.025,
136
+ )
137
+ fillet(slider_part.faces().sort_by(Axis.Z)[0].edges(), wall_thickness * 0.025)
138
+
114
139
  if divot_radius > 0:
115
140
  # 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,
141
+ # the part; keep it far enough back to meet only the underside
142
+ divot_y = sketch.bounding_box().min.Y + max(
143
+ wall_thickness / 2, divot_radius + divot_radius / 5
120
144
  )
121
145
  with BuildPart(
122
- Location((0, divot_y, -wall_thickness), (180, 0, 0)),
146
+ Location((0, divot_y, -wall_thickness - clearance), (180, 0, 0)),
123
147
  mode=Mode.ADD,
124
148
  ):
125
149
  with GridLocations(
@@ -161,7 +185,7 @@ def slide_lid(
161
185
  args:
162
186
  - part: the part defining the box's outer dimensions
163
187
  - wall_thickness: the thickness of the box walls
164
- - tolerance: the clearance between the lid and the base; 0.1 is a tight
188
+ - tolerance: the total gap between the lid and the base; 0.1 is a tight
165
189
  fit, 0.15 the default, 0.2 and above loose
166
190
  - top_offset: how far below the top of the part the sliding section sits,
167
191
  for parts with features at the top worth preserving
@@ -247,7 +271,8 @@ def slide_box(
247
271
  - thumb_radius: the radius of the thumb-grip cutout; 0 disables it
248
272
  - x_straighten_distance: how much of the slider stays straight at the
249
273
  edges, which stops wide lids binding at the corners
250
- - tolerance: the clearance between the lid and the base
274
+ - tolerance: the total gap between the lid and the base, split evenly
275
+ between the slot and the lid
251
276
  - divot_radius: the radius of the click-fit positioning divots; 0
252
277
  disables them
253
278
 
@@ -260,7 +285,7 @@ def slide_box(
260
285
  lid_cut_template = _slider_template(
261
286
  cross_section,
262
287
  wall_thickness,
263
- tolerance=0,
288
+ tolerance=tolerance,
264
289
  top_offset=top_offset,
265
290
  x_straighten_distance=x_straighten_distance,
266
291
  divot_radius=divot_radius,
@@ -303,11 +328,20 @@ def slide_box(
303
328
 
304
329
 
305
330
  if __name__ == "__main__":
306
- from ocp_vscode import Camera, show
331
+ from ocp_vscode import show
307
332
 
308
333
  with BuildPart() as base_box:
309
334
  Box(20, 44, 14, align=(Align.CENTER, Align.CENTER, Align.MIN))
310
335
  fillet(base_box.part.edges().filter_by(Axis.Z), radius=1.5)
311
336
 
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)
337
+ sb = slide_box(
338
+ base_box.part,
339
+ wall_thickness=2,
340
+ thumb_radius=3.5,
341
+ divot_radius=0.5,
342
+ tolerance=0.3,
343
+ )
344
+
345
+ show(
346
+ sb,
347
+ )
@@ -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]
@@ -1,7 +1,3 @@
1
- from importlib.machinery import SourceFileLoader
2
- from importlib.util import module_from_spec, spec_from_loader
3
- from unittest.mock import patch
4
-
5
1
  import pytest
6
2
  from build123d import (
7
3
  Align,
@@ -16,7 +12,6 @@ from build123d import (
16
12
  )
17
13
 
18
14
  from b3dkit.antichamfer import anti_chamfer
19
- from conftest import module_path
20
15
 
21
16
 
22
17
  class TestAntiChamfer:
@@ -122,7 +117,10 @@ class TestAntiChamfer:
122
117
  Box(10, 10, 10)
123
118
 
124
119
  ac_part = anti_chamfer(bp.part.faces().filter_by(Axis.Z)[-1], -1.0, -0.5)
125
- assert ac_part.is_valid
120
+ assert len(ac_part.solids()) == 1
121
+ # negative lengths still extend the face outward rather than cutting in
122
+ assert ac_part.volume == pytest.approx(1110.3333, rel=1e-4)
123
+ assert ac_part.volume > bp.part.volume
126
124
 
127
125
  def test_anti_chamfer_zero_length_values(self):
128
126
  """Test anti_chamfer with negative length values"""
@@ -136,19 +134,6 @@ class TestAntiChamfer:
136
134
  assert ac2.is_valid
137
135
  assert bp.part.volume == ac1.volume == ac2.volume
138
136
 
139
- def test_direct_run(self):
140
-
141
- with (
142
- patch("build123d.export_stl"),
143
- patch("pathlib.Path.mkdir"),
144
- patch("pathlib.Path.exists"),
145
- patch("pathlib.Path.is_dir"),
146
- patch("ocp_vscode.show"),
147
- patch("ocp_vscode.save_screenshot"),
148
- ):
149
- loader = SourceFileLoader("__main__", module_path("antichamfer"))
150
- loader.exec_module(module_from_spec(spec_from_loader(loader.name, loader)))
151
-
152
137
 
153
138
  class TestAntiChamferBuilderContract:
154
139
  """anti_chamfer applies to BuildPart only, and must say so.
@@ -1,13 +1,9 @@
1
1
  import math
2
- from importlib.machinery import SourceFileLoader
3
- from importlib.util import module_from_spec, spec_from_loader
4
- from unittest.mock import patch
5
2
 
6
3
  import pytest
7
4
  from build123d import Part
8
5
 
9
6
  from b3dkit.ball_socket import BallMount, BallSocket
10
- from conftest import module_path
11
7
 
12
8
 
13
9
  # ---------- Helpers ----------
@@ -45,8 +41,11 @@ class TestBallSocket:
45
41
  @pytest.mark.parametrize("r,w", [(3, 2), (5, 1), (10, 2), (20, 4), (12.5, 3.5)])
46
42
  def test_ball_socket_param_dimensions(self, r, w):
47
43
  socket = BallSocket(r, wall_thickness=w)
48
- assert socket.is_valid
44
+ assert len(socket.solids()) == 1
49
45
  bbox = socket.bounding_box()
46
+ # the socket encloses the ball plus a wall on each side
47
+ assert bbox.size.X == pytest.approx(2 * r + 2 * w)
48
+ assert socket.volume > 0
50
49
 
51
50
  def test_ball_socket_custom_wall_thickness(self):
52
51
  r, w = 10.0, 3.0
@@ -97,11 +96,3 @@ class TestEdgeCases:
97
96
  assert tight.is_valid
98
97
  assert loose.is_valid
99
98
  assert loose.volume < BallSocket(10.0, tolerance=0.0).volume
100
-
101
-
102
- # ---------- Direct Run ----------
103
- class TestDirectRun:
104
- def test_direct_run(self):
105
- with patch("ocp_vscode.show"):
106
- loader = SourceFileLoader("__main__", module_path("ball_socket"))
107
- loader.exec_module(module_from_spec(spec_from_loader(loader.name, loader)))