bevel_gear: replace the custom back-cone _wrap_edge with build123d UVFrame / Face.wrap once it is released #91
Description
bevel_gear: replace the custom back-cone _wrap_edge with build123d UVFrame / Face.wrap once it is released
Background
BevelGear builds each tooth as follows:
- Draw the heel (outer-end) tooth outline in the plane of the unrolled back cone (
SphericalInvoluteToothProfile). - Wrap that outline back onto the back cone.
- Join each heel edge to a copy scaled toward the cone apex, giving the tooth faces.
Unrolling a cone is distance-preserving, so in principle step 2 is exact.
Step 2 currently uses two private helpers in bevel_gear.py:
| Helper | What it does | Keep? |
|---|---|---|
_wrap_point(point, delta, cone_distance) |
Closed-form inverse of cone development for one point (3 lines) | Yes. It is also used for single points such as the tip/root arc ends and the tooth-space centre. |
_wrap_edge(edge, delta, cone_distance) |
Converts edges by type: rebuilds apex-centred arcs as circles and radial lines as generators, and refits anything else through 31 wrapped sample points | Replace |
_wrap_edge duplicates what build123d's new UVFrame already does correctly.
UVFrame.at uses _cone_map on a conical face, which is the exact unrolling about the cone apex: the same isometry _wrap_point encodes. With the new version:
- lines and apex-centred arcs come back as exact lines and circles;
- other curves are interpolated to a requested 3D tolerance, rather than through a fixed number of samples;
- the resulting edges lie on the cone surface.
Why it can't be done now
bd_warehouse declares build123d >=0.11.2.dev0, <0.14.0 in pyproject.toml. Neither released version has UVFrame. Their Face.wrap projects points onto the surface instead, and on the same back cone it is both slower and less accurate:
| Wrapping a developed tooth edge onto the back cone | released v0.13.0 Face.wrap |
dev Face.wrap (UVFrame) |
current _wrap_edge |
|---|---|---|---|
| Involute-like spline: max distance from the exact unrolled image | 7.84 µm | 0.027 µm | 0.27 µm |
| Apex-centred arc: max distance / result type | 25.8 µm / B-spline | 0 / circle | 0 / circle |
| Time per edge | ~80–180 ms | ~1 ms | ~0.3 ms |
On v0.13.0 the migrated module fails at import: ImportError: cannot import name 'UVFrame' from 'build123d'. The current module passes all 70 bevel-gear tests on v0.13.0.
Proposed change (after the build123d release)
- Raise the build123d minimum in
pyproject.tomlto the first release that containsUVFrame, e.g."build123d >=0.14.0, <0.15.0". Use whatever version actually ships it. - Apply the patch below to
src/bd_warehouse/bevel_gear.py:- import
UVFrame; - replace
_WRAP_SAMPLESwith a_WRAP_TOLERANCE; - add
_back_cone_frame, which builds a back-cone face once per gear and aUVFramewhose flat origin and axes match the developed profile; - shrink
_wrap_edgeto awrite_edgecall; - keep
_wrap_point.
- import
- Optionally, update the module docstring: "wrapping … is exact (up to spline refitting)" becomes "exact; non-analytic edges are interpolated to
_WRAP_TOLERANCE".
--- a/src/bd_warehouse/bevel_gear.py
+++ b/src/bd_warehouse/bevel_gear.py
@@ -59,6 +59,7 @@
Shell,
Solid,
Spline,
+ UVFrame,
Vector,
Wire,
fillet,
@@ -76,8 +77,8 @@
# Points are spaced uniformly in the great-circle unwinding angle, which concentrates
# them near the base cone where the involute curvature is highest.
_INVOLUTE_SAMPLES = 21
-# Number of points used to refit a developed spline after wrapping it onto the cone.
-_WRAP_SAMPLES = 31
+# Largest 3D error allowed when build123d's UVFrame interpolates an edge image.
+_WRAP_TOLERANCE = 1e-5
# Radial root-flank segments shorter than this fraction of the module are omitted.
_MIN_EDGE_LENGTH = 1e-3
@@ -291,33 +292,54 @@
)
-def _wrap_edge(edge: Edge, delta: float, cone_distance: float) -> Edge:
- """Wrap a developed profile edge onto the back cone.
-
- Arcs centred on the back-cone apex become exact circles and radial lines
- become exact back-cone generators; other edges (involute splines, chords,
- root fillets) are refitted through wrapped samples.
- """
- start, end = edge @ 0, edge @ 1
+def _back_cone_frame(
+ delta: float, cone_distance: float, reach: float
+) -> tuple[UVFrame, Location, bool]:
+ """A flat frame on the back cone (apex frame) that inverts cone development.
- def wrap(point: Vector) -> Vector:
- return _wrap_point(point, delta, cone_distance)
+ build123d's cone map is the exact unrolling about the cone apex, so
+ ``frame.write_edge`` puts a developed profile edge on the back cone exactly:
+ arcs about the apex stay circles, radial lines stay generators, and other
+ curves are interpolated to ``_WRAP_TOLERANCE``.
- if (
- edge.geom_type == GeomType.CIRCLE
- and edge.arc_center.length < 1e-7 * edge.radius
- ):
- return Edge.make_three_point_arc(wrap(start), wrap(edge @ 0.5), wrap(end))
- radial = abs(start.X * end.Y - start.Y * end.X) < 1e-12 * max(
- 1.0, start.length * end.length
- )
- if edge.geom_type == GeomType.LINE and radial:
- return Edge.make_line(wrap(start), wrap(end))
- return Edge.make_spline(
- [wrap(edge @ (i / (_WRAP_SAMPLES - 1))) for i in range(_WRAP_SAMPLES)]
+ The face only supplies the surface: a back-cone band ``reach`` either side of
+ the pitch circle with its revolve seam on -X, opposite the tooth on +X.
+
+ Returns:
+ the frame, the location moving the profile origin (back-cone apex) to the
+ frame origin (heel pitch point), and whether developed shapes must be
+ mirrored so that developed +Y maps to +azimuth.
+ """
+ pitch_radius = cone_distance * tan(delta)
+ seam = Vector(-1, 0, 0)
+ band = Edge.make_line(
+ _wrap_point(seam * (pitch_radius - reach), delta, cone_distance),
+ _wrap_point(seam * (pitch_radius + reach), delta, cone_distance),
+ )
+ back_cone = Face.revolve(band, 360, Axis.Z)
+ pitch_point = _wrap_point(Vector(pitch_radius, 0), delta, cone_distance)
+ frame = back_cone.uv_frame(
+ Location(
+ Plane(
+ pitch_point,
+ x_dir=(cos(delta), 0, -sin(delta)), # developed +X: radially out
+ z_dir=(sin(delta), 0, cos(delta)),
+ )
+ ),
+ tolerance=_WRAP_TOLERANCE,
)
+ to_frame = Location((-pitch_radius, 0, 0))
+ probe = frame.write_edge(to_frame * Edge.make_line((pitch_radius, 0), (pitch_radius, 1)))
+ return frame, to_frame, (probe @ 1).Y < 0
+def _wrap_edge(edge: Edge, frame: tuple[UVFrame, Location, bool]) -> Edge:
+ """Wrap a developed profile edge onto the back cone."""
+ uv_frame, to_frame, mirror_y = frame
+ flat = edge.mirror(Plane.XZ) if mirror_y else edge
+ return uv_frame.write_edge(to_frame * flat)
+
+
def _is_centred_arc(edge: Edge, radius: float) -> bool:
"""Is ``edge`` an arc of ``radius`` centred on the developed-profile origin?
@@ -560,9 +582,13 @@
# ---- one pitch of heel (back-cone) edges --------------------------------
def wrap_point(point: Vector) -> Vector:
return _wrap_point(point, delta, cone_distance)
+
+ back_cone = _back_cone_frame(
+ delta, cone_distance, reach=0.75 * profile.pitch_radius
+ )
def wrap(edge: Edge) -> Edge:
- return _wrap_edge(edge, delta, cone_distance)
+ return _wrap_edge(edge, back_cone)
lower_flank = sorted(
(Notes on the patch
-
Hand detection (
mirror_y). On a cone made withFace.revolve, the frame maps flat +Y to −azimuth. The developed profile assumes flat +Y is +azimuth. A one-edge probe detects the hand, and the profile is mirrored across XZ before writing. Mirroring the input only is the correct fix. Mirroring the output as well re-mirrors it onto the opposite flank, which showed up as a false 4.5 mm error during evaluation. Probing keeps the code correct if build123d ever changes its cone parameterisation; hard-codingTruewould not. -
Seam placement. The helper face is a 360° revolve whose seam is put on −X, half a turn from the tooth on +X. That keeps a single tooth's edges well away from the seam, so
write_edgenever has to split one. -
Band width (
reach = 0.75 * pitch_radius). This only has to cover the profile's radial extent (addendum + dedendum, at most about 2.25 m). Writing happens in the surface's parameter space, so edges just outside the face boundary still land on the surface. The band only sets whichGeom_Surfaceis used. -
Tolerance.
_WRAP_TOLERANCEis the largest 3D error allowed when an edge is interpolated (involute splines and root fillets). Lines and arcs are exact at any tolerance. Measured on a 16-tooth module-2 miter gear:_WRAP_TOLERANCEwrap 8 profile edges max distance from exact unrolled image full gear build tests 1e-4 11 ms 0.10 µm 74 ms 70/70 1e-5 (proposed) 38 ms 0.007 µm 105 ms 70/70 1e-6 72 ms 0.0005 µm 137 ms 70/70 current _wrap_edge(31-point refit)3 ms 0.27 µm 77 ms 70/70 1e-5 is proposed: it is about 40× more accurate than the current refit at a small cost. 1e-4 is still more accurate than today, and slightly faster.
-
What stays the same.
_wrap_point, every other helper, the public API, face counts and surface types (circles stay circles, lines stay lines)._is_centred_arcis still needed becauseBevelGearuses it to sort out the flank edges. -
One fewer magic number.
_WRAP_SAMPLESgoes away. There is no longer a fixed sample count to tune for highly curved fillets or small tooth counts.
Verification (dev build123d f5cca68a, /tmp copies)
Test suite
| Module | build123d | tests/test_bevel_gear.py |
|---|---|---|
current (_wrap_edge) |
dev f5cca68a |
70 passed |
migrated, _WRAP_TOLERANCE = 1e-4 / 1e-5 / 1e-6 |
dev f5cca68a |
70 passed (each) |
current (_wrap_edge) |
released v0.13.0 | 70 passed |
| migrated | released v0.13.0 | ImportError: UVFrame (expected; this is why it's blocked) |
Edge-level comparison (16-tooth miter, module 2, 20°)
current _wrap_edge |
migrated (1e-6) | |
|---|---|---|
| Max distance from the exact unrolled image, over all profile edges | 0.270 µm | 0.0005 µm |
| Edge types after wrapping | circle, line, B-spline | identical |
Gear-level comparison (valid single solid in every case)
Precise volumes use adaptive integration (BRepGProp.VolumeProperties_s(..., 1e-7, True)). The default Shape.volume is only good to about 5e-5 on these B-spline faces.
| Gear | Faces (current / migrated) | Precise volume, current (mm³) | Migrated (mm³) | Relative difference |
|---|---|---|---|---|
| 10/10 miter | 64 / 64 | 1187.9240 | 1187.9247 | 6.3e-7 |
| 16/16 miter | 100 / 100 | 4363.6129 | 4363.6151 | 4.9e-7 |
16/16, root_fillet=0.4 |
132 / 132 | 4368.7195 | 4368.7216 | 4.9e-7 |
16/16, bore_diameter=8 |
101 / 101 | 4037.5503 | 4037.5524 | 5.3e-7 |
| 20 / 40 | 124 / 124 | 12097.7098 | 12097.7152 | 4.5e-7 |
| 40 / 20 | 164 / 164 | 38087.8938 | 38087.8936 | 4.6e-9 |
| 50/50 miter | 204 / 204 | 103257.5893 | 103257.5891 | 2.1e-9 |
| 60 / 20 | 244 / 244 | 89457.2793 | 89457.2793 | 9.6e-11 |
| 18 / 27 at 60° shaft, module 1.5 | 112 / 112 | 3655.9869 | 3655.9888 | 5.0e-7 |
The small differences are the current 31-point refit error, which the migration removes. They are largest where the involute is most curved (low tooth counts), and next to nothing where the root lies outside the base cone (z_v > ~42).
Acceptance criteria
- A build123d release containing
UVFrame(commitbb4eca19or later) is published, andpyproject.tomlrequires it. - The patch above is applied;
_WRAP_SAMPLESis gone and_wrap_pointis kept. -
tests/test_bevel_gear.pypasses unchanged (70 tests). - Add a test checking that wrapped profile edges match the closed-form unrolled image to within
_WRAP_TOLERANCE, and that arcs and lines keep theirGeomType. The comparison in this issue is a ready-made version. - Optional: replace the
(UVFrame, Location, bool)tuple with a small named helper class if it ends up used outsideBevelGear.
Activity