提交关于N5功能,“involute_gear渐开线齿廓与齿条专用生成”的改动
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""Parametric involute gear and rack generator (``gear_add`` / ``rack_add``).
|
||||
|
||||
Strategy
|
||||
--------
|
||||
A standard involute gear profile is sampled analytically in the end plane:
|
||||
pitch radius ``r = m*z/2``, base radius ``r_b = r*cos(alpha)``, tip radius
|
||||
``r + m`` and root radius ``r - 1.25*m``. Each tooth flank is the involute
|
||||
of the base circle parameterised by the roll angle ``t``
|
||||
|
||||
x(t) = r_b*(cos t + t*sin t), y(t) = r_b*(sin t - t*cos t)
|
||||
|
||||
rotated by ``delta = psi - inv(alpha)`` (``psi = pi/(2z)`` is the half tooth
|
||||
thickness angle on the pitch circle) so the flank passes through the pitch
|
||||
point at the correct tooth thickness. The closed section polygon per tooth
|
||||
period is: root arc (from the valley centre) -> left flank (root to tip) ->
|
||||
tip arc (through the tooth centre) -> right flank (tip to root) -> root arc
|
||||
(to the next valley centre). When ``r_f < r_b`` the involute starts on the
|
||||
base circle and a radial foot joins it down to the root circle.
|
||||
|
||||
* Spur gear (``helix_angle_rad == 0``): the end face is extruded linearly.
|
||||
* Helical gear: ``Solid.extrude_linear_with_rotation`` builds a true twisted
|
||||
prism - the section rotates by ``width*tan(beta)/r_pitch`` while extruding
|
||||
over ``width_mm`` - which is exactly the involute-helicoid tooth surface;
|
||||
every ``z = const`` cross-section is the same rotated profile so the volume
|
||||
is exactly ``section_area * width``.
|
||||
* Herringbone (double helical): the upper half is the twisted prism above;
|
||||
the lower half is its mirror image about the ``z = width/2`` plane, which
|
||||
reverses the helix while keeping the mid-plane section phase-continuous.
|
||||
The two halves meet on the shared mid-plane section; ``fuse`` plus
|
||||
``clean`` merge them into one solid (the seam is a same-shape section, and
|
||||
OCC handles it reliably, verified by exact volume ``2 * upper``).
|
||||
|
||||
The rack is the linear counterpart: pitch ``p = pi*m``, trapezoid teeth with
|
||||
flanks inclined by ``pressure_angle_rad``, addendum ``m`` and dedendum
|
||||
``1.25*m``. Both ends land in valley centres so the exact length is
|
||||
``teeth_count * pi * m`` and the cross-section area has the closed form
|
||||
``L*h_f + n*(b_root + b_tip)*h_a``.
|
||||
|
||||
Local frames: the gear axis is +Z with the ``z = 0`` end face centred on the
|
||||
origin; the rack runs along +X, teeth point along +Z with thickness along
|
||||
+Y. Placement onto ``spec.axis`` is the adapter's responsibility. The
|
||||
module only reads ``runtime_types.GearSpec`` / ``RackSpec`` and returns OCC
|
||||
``Solid`` objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from build123d import Edge, Face, Plane, Solid, Vector, Wire
|
||||
|
||||
from .runtime_types import GearSpec, RackSpec
|
||||
|
||||
#: Involute samples per tooth flank (chord error << 1e-3 mm at module 1).
|
||||
_SAMPLES_PER_FLANK = 16
|
||||
#: Maximum angular step (radians) when sampling tip/root arcs.
|
||||
_ARC_STEP_RAD = math.radians(2.0)
|
||||
#: Consecutive-point deduplication tolerance (mm).
|
||||
_MERGE_TOL = 1e-9
|
||||
|
||||
|
||||
def _rotate(point: tuple[float, float], angle: float) -> tuple[float, float]:
|
||||
return (
|
||||
point[0] * math.cos(angle) - point[1] * math.sin(angle),
|
||||
point[0] * math.sin(angle) + point[1] * math.cos(angle),
|
||||
)
|
||||
|
||||
|
||||
def involute_geometry(spec: GearSpec) -> dict[str, float]:
|
||||
"""Derive the analytic involute geometry of a gear spec.
|
||||
|
||||
Returns pitch/base/tip/root radii, the half tooth thickness angle on the
|
||||
pitch circle (``psi``), the base-circle start offset (``delta``), the
|
||||
flank roll-angle window ``[t_start, t_tip]`` and the polar half-angles of
|
||||
the root/tip points (``a_root`` / ``a_tip``, measured from the tooth
|
||||
centre line).
|
||||
"""
|
||||
r = spec.pitch_radius_mm
|
||||
rb = spec.base_radius_mm
|
||||
ra = spec.tip_radius_mm
|
||||
rf = spec.root_radius_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
z = spec.teeth_count
|
||||
if rb <= 0.0 or ra <= rb:
|
||||
raise ValueError("gear tip radius must exceed the base radius")
|
||||
psi = math.pi / (2.0 * z)
|
||||
delta = psi - (math.tan(alpha) - alpha)
|
||||
t_tip = math.sqrt((ra / rb) ** 2 - 1.0)
|
||||
beta_tip = t_tip - math.atan(t_tip)
|
||||
t_start = math.sqrt((rf / rb) ** 2 - 1.0) if rf >= rb else 0.0
|
||||
beta_start = t_start - math.atan(t_start)
|
||||
a_tip = delta + beta_tip
|
||||
a_root = delta + beta_start
|
||||
if not a_tip < math.pi / z:
|
||||
raise ValueError("gear tooth tip arcs overlap; reduce module or add teeth")
|
||||
if not a_root < math.pi / z:
|
||||
raise ValueError("gear tooth root arcs overlap; gear geometry is degenerate")
|
||||
return {
|
||||
"r": r, "rb": rb, "ra": ra, "rf": rf, "psi": psi, "delta": delta,
|
||||
"t_start": t_start, "t_tip": t_tip, "a_tip": a_tip, "a_root": a_root,
|
||||
}
|
||||
|
||||
|
||||
def right_flank_points(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
||||
"""Right tooth flank (root -> tip) with the tooth centred on angle 0.
|
||||
|
||||
Points lie exactly on the involute; when ``r_f < r_b`` the first point is
|
||||
the radial foot on the root circle (same polar angle as the base-circle
|
||||
start) so the section stays simply connected.
|
||||
"""
|
||||
geom = involute_geometry(spec)
|
||||
rb, delta = geom["rb"], geom["delta"]
|
||||
points: list[tuple[float, float]] = []
|
||||
if spec.root_radius_mm < rb:
|
||||
points.append((spec.root_radius_mm * math.cos(delta), spec.root_radius_mm * math.sin(delta)))
|
||||
for index in range(samples):
|
||||
t = geom["t_start"] + (geom["t_tip"] - geom["t_start"]) * index / (samples - 1)
|
||||
x0 = rb * (math.cos(t) + t * math.sin(t))
|
||||
y0 = rb * (math.sin(t) - t * math.cos(t))
|
||||
points.append(_rotate((x0, y0), delta))
|
||||
return points
|
||||
|
||||
|
||||
def _arc_points(radius: float, start_angle: float, end_angle: float) -> list[tuple[float, float]]:
|
||||
"""Sample a circular arc (inclusive of both ends) at ``_ARC_STEP_RAD``."""
|
||||
step = max(1, math.ceil(abs(end_angle - start_angle) / _ARC_STEP_RAD))
|
||||
return [
|
||||
_rotate((radius, 0.0), start_angle + (end_angle - start_angle) * k / step)
|
||||
for k in range(step + 1)
|
||||
]
|
||||
|
||||
|
||||
def spur_profile_polygon(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
||||
"""Closed end-plane section polygon of the full gear (counter-clockwise).
|
||||
|
||||
One period per tooth: valley centre -> root arc -> left flank (root to
|
||||
tip) -> tip arc (through the tooth centre) -> right flank (tip to root)
|
||||
-> root arc to the next valley centre. The polygon is a pure polyline so
|
||||
its shoelace area equals the OCC face area exactly.
|
||||
"""
|
||||
geom = involute_geometry(spec)
|
||||
ra, rf = geom["ra"], geom["rf"]
|
||||
a_tip, a_root = geom["a_tip"], geom["a_root"]
|
||||
period = 2.0 * math.pi / spec.teeth_count
|
||||
flank = right_flank_points(spec, samples)
|
||||
left_flank = [(x, -y) for (x, y) in flank]
|
||||
points: list[tuple[float, float]] = []
|
||||
for tooth in range(spec.teeth_count):
|
||||
gamma = tooth * period
|
||||
points.extend(_arc_points(rf, gamma - period / 2.0, gamma - a_root))
|
||||
points.extend(_rotate(point, gamma) for point in left_flank)
|
||||
points.extend(_arc_points(ra, gamma - a_tip, gamma + a_tip))
|
||||
points.extend(_rotate(point, gamma) for point in reversed(flank))
|
||||
points.extend(_arc_points(rf, gamma + a_root, gamma + period / 2.0))
|
||||
merged = _merge_consecutive(points)
|
||||
if polygon_area(merged) < 0.0:
|
||||
merged.reverse()
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_consecutive(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Drop consecutive (and closing) duplicate points within ``_MERGE_TOL``."""
|
||||
merged: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
if merged and math.dist(merged[-1], point) <= _MERGE_TOL:
|
||||
continue
|
||||
merged.append(point)
|
||||
if len(merged) > 1 and math.dist(merged[0], merged[-1]) <= _MERGE_TOL:
|
||||
merged.pop()
|
||||
return merged
|
||||
|
||||
|
||||
def polygon_area(points: list[tuple[float, float]]) -> float:
|
||||
"""Signed shoelace area (positive = counter-clockwise)."""
|
||||
total = 0.0
|
||||
count = len(points)
|
||||
for index in range(count):
|
||||
x0, y0 = points[index]
|
||||
x1, y1 = points[(index + 1) % count]
|
||||
total += x0 * y1 - x1 * y0
|
||||
return total / 2.0
|
||||
|
||||
|
||||
def _section_wire(spec: GearSpec, theta_offset: float = 0.0, z: float = 0.0) -> Wire:
|
||||
"""End-plane section wire rotated by ``theta_offset`` and lifted to ``z``."""
|
||||
points = spur_profile_polygon(spec)
|
||||
if theta_offset:
|
||||
points = [_rotate(point, theta_offset) for point in points]
|
||||
vertices = [Vector(x, y, z) for (x, y) in points]
|
||||
return Wire([
|
||||
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
||||
for index in range(len(vertices))
|
||||
])
|
||||
|
||||
|
||||
def helix_twist_angle_rad(spec: GearSpec) -> float:
|
||||
"""Total section rotation over the full width for a helical gear."""
|
||||
if spec.helix_angle_rad <= 0.0:
|
||||
return 0.0
|
||||
return math.tan(spec.helix_angle_rad) * spec.width_mm / spec.pitch_radius_mm
|
||||
|
||||
|
||||
def build_gear_solid(spec: GearSpec) -> Solid:
|
||||
"""Build the gear in the local frame (+Z = axis, ``z in [0, width_mm]``)."""
|
||||
involute_geometry(spec) # validation
|
||||
face = Face(_section_wire(spec))
|
||||
width = spec.width_mm
|
||||
if spec.helix_angle_rad <= 0.0:
|
||||
return Solid.extrude(face, Vector(0.0, 0.0, width))
|
||||
twist = helix_twist_angle_rad(spec)
|
||||
if not spec.herringbone:
|
||||
return Solid.extrude_linear_with_rotation(
|
||||
face, (0.0, 0.0, 0.0), (0.0, 0.0, width), math.degrees(twist),
|
||||
)
|
||||
# Herringbone: the upper half twists 0 -> +A/2; its mirror image about
|
||||
# the mid-width plane reverses the helix with a phase-continuous seam.
|
||||
half = width / 2.0
|
||||
upper = Solid.extrude_linear_with_rotation(
|
||||
face, (0.0, 0.0, 0.0), (0.0, 0.0, half), math.degrees(twist / 2.0),
|
||||
)
|
||||
mirrored = upper.mirror(Plane(origin=(0.0, 0.0, half), z_dir=(0.0, 0.0, 1.0)))
|
||||
merged = upper.fuse(mirrored).clean()
|
||||
if isinstance(merged, Solid):
|
||||
return merged
|
||||
solids = merged.solids()
|
||||
if len(solids) == 1:
|
||||
return solids[0]
|
||||
raise ValueError("herringbone gear fuse produced a non-single body")
|
||||
|
||||
|
||||
def rack_profile_polygon(spec: RackSpec) -> list[tuple[float, float]]:
|
||||
"""Closed rack cross-section polygon in the local ``(x, z)`` plane.
|
||||
|
||||
``x`` runs along the rack from 0 to ``teeth_count * pi * m`` (both ends
|
||||
in valley centres); ``z`` runs from the root/backing plane (0) to the
|
||||
crest plane (``2.25 * m``).
|
||||
"""
|
||||
m = spec.module_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
pitch = spec.pitch_mm
|
||||
addendum = spec.addendum_mm
|
||||
dedendum = spec.dedendum_mm
|
||||
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
||||
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
||||
if half_tip <= 0.0:
|
||||
raise ValueError("rack pressure angle consumes the whole tooth crest")
|
||||
length = spec.length_mm
|
||||
points: list[tuple[float, float]] = [(0.0, 0.0), (length, 0.0), (length, dedendum)]
|
||||
for tooth in reversed(range(spec.teeth_count)):
|
||||
centre = (tooth + 0.5) * pitch
|
||||
points.append((centre + half_root, dedendum))
|
||||
points.append((centre + half_tip, dedendum + addendum))
|
||||
points.append((centre - half_tip, dedendum + addendum))
|
||||
points.append((centre - half_root, dedendum))
|
||||
points.append((0.0, dedendum))
|
||||
merged = _merge_consecutive(points)
|
||||
if polygon_area(merged) < 0.0:
|
||||
merged.reverse()
|
||||
return merged
|
||||
|
||||
|
||||
def rack_section_area_mm2(spec: RackSpec) -> float:
|
||||
"""Closed-form rack cross-section area (shoelace-exact, all edges straight)."""
|
||||
pitch = spec.pitch_mm
|
||||
addendum = spec.addendum_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
||||
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
||||
return spec.length_mm * spec.dedendum_mm + spec.teeth_count * (half_root + half_tip) * addendum
|
||||
|
||||
|
||||
def build_rack_solid(spec: RackSpec) -> Solid:
|
||||
"""Build the rack in the local frame (+X length, +Y thickness, +Z teeth)."""
|
||||
points = rack_profile_polygon(spec)
|
||||
vertices = [Vector(x, 0.0, z) for (x, z) in points]
|
||||
wire = Wire([
|
||||
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
||||
for index in range(len(vertices))
|
||||
])
|
||||
return Solid.extrude(Face(wire), Vector(0.0, spec.thickness_mm, 0.0))
|
||||
Reference in New Issue
Block a user