Skip to content

bevel_gear: replace the custom back-cone _wrap_edge with build123d UVFrame / Face.wrap once it is released #91

Open

Description

@gumyr

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:

  1. Draw the heel (outer-end) tooth outline in the plane of the unrolled back cone (SphericalInvoluteToothProfile).
  2. Wrap that outline back onto the back cone.
  3. 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)

  1. Raise the build123d minimum in pyproject.toml to the first release that contains UVFrame, e.g. "build123d >=0.14.0, <0.15.0". Use whatever version actually ships it.
  2. Apply the patch below to src/bd_warehouse/bevel_gear.py:
    • import UVFrame;
    • replace _WRAP_SAMPLES with a _WRAP_TOLERANCE;
    • add _back_cone_frame, which builds a back-cone face once per gear and a UVFrame whose flat origin and axes match the developed profile;
    • shrink _wrap_edge to a write_edge call;
    • keep _wrap_point.
  3. 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 with Face.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-coding True would 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_edge never 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 which Geom_Surface is used.

  • Tolerance. _WRAP_TOLERANCE is 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_TOLERANCE wrap 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_arc is still needed because BevelGear uses it to sort out the flank edges.

  • One fewer magic number. _WRAP_SAMPLES goes 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 (commit bb4eca19 or later) is published, and pyproject.toml requires it.
  • The patch above is applied; _WRAP_SAMPLES is gone and _wrap_point is kept.
  • tests/test_bevel_gear.py passes 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 their GeomType. 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 outside BevelGear.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions