N5 渐开线齿廓与齿条专用生成 involute_gear 的功能增加 #17

Merged
likang merged 2 commits from ganjihong into main 2026-09-08 14:25:27 +08:00
14 changed files with 774 additions and 13 deletions
Showing only changes of commit 673ae62e70 - Show all commits
@@ -23,7 +23,9 @@
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true},
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true},
{"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true},
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true}
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true},
{"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true},
{"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "priority": 100, "mandatory": true}
],
"phase_sections": {
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
@@ -62,6 +64,9 @@
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
"thread_add": ["op-thread"],
"thread_cut": ["op-thread"]
"thread_cut": ["op-thread"],
"bend_add": ["op-bend"],
"gear_add": ["op-gear"],
"rack_add": ["op-gear"]
}
}
@@ -0,0 +1 @@
`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm``width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 18090 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。
@@ -0,0 +1 @@
`gear_add``rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm``teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm``z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module``helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。
+53
View File
@@ -46,6 +46,9 @@ class ProfileCadRuntime:
"selected_edges_exist": self._preflight_selected_edges_exist,
"source_features_exist": self._preflight_source_features_exist,
"mirror_plane_exists": self._preflight_mirror_plane_exists,
"loft_profiles_exist": self._preflight_loft_profiles_exist,
"loft_profiles_closed": self._preflight_loft_profiles_closed,
"loft_profiles_single_region": self._preflight_loft_profiles_single_region,
}
schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json")
try:
@@ -990,6 +993,56 @@ class ProfileCadRuntime:
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "plane":
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale")
def _loft_profile_sketches(self, fragment: dict[str, Any], base: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Resolve ``profile_sketch_ids`` against the base document's sketches."""
ids = [str(value) for value in fragment.get("feature", {}).get("params", {}).get("profile_sketch_ids") or ()]
sketches = (base or {}).get("geometry", {}).get("sketches") if isinstance((base or {}).get("geometry"), dict) else None
by_id = {
str(sketch.get("id") or ""): sketch
for sketch in (sketches or ())
if isinstance(sketch, dict)
}
resolved: list[dict[str, Any]] = []
for sketch_id in ids:
sketch = by_id.get(sketch_id)
if not isinstance(sketch, dict):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch is not part of current head")
resolved.append(sketch)
if not resolved:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft has no profile sketches")
return resolved
def _preflight_loft_profiles_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
self._loft_profile_sketches(fragment, base)
def _preflight_loft_profiles_closed(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
# circle/polygon 草图类型闭合性由 schema 保证;analytic_contours 需要
# 每条参与轮廓显式 closed。
for sketch in self._loft_profile_sketches(fragment, base):
profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None
if profile is None:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile")
if profile.get("type") == "analytic_contours":
contours = profile.get("contours")
if not isinstance(contours, list) or not contours:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile has no contours")
for contour in contours:
if not isinstance(contour, dict) or not contour.get("closed"):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile contour is not closed")
def _preflight_loft_profiles_single_region(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
# 每个放样截面必须是单连通区域:analytic_contours 只允许恰好一条
# outer 闭合轮廓,不得携带 inner 环或 open 段。
for sketch in self._loft_profile_sketches(fragment, base):
profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None
if profile is None:
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile")
if profile.get("type") == "analytic_contours":
contours = [contour for contour in (profile.get("contours") or []) if isinstance(contour, dict)]
outer = [contour for contour in contours if contour.get("role") == "outer" and contour.get("closed")]
if len(outer) != 1 or len(outer) != len(contours):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile must be a single closed region")
@staticmethod
def _polygon_self_intersects(vertices: list[Any]) -> bool:
points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2]
@@ -28,6 +28,9 @@ SEMANTIC_PREFLIGHT_NAMES = frozenset({
"selected_edges_exist",
"source_features_exist",
"mirror_plane_exists",
"loft_profiles_exist",
"loft_profiles_closed",
"loft_profiles_single_region",
})
@@ -8,8 +8,9 @@ from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
from .parametric_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid
from .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
from .runtime_types import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
@@ -431,6 +432,47 @@ class Build123dGeometryAdapter:
)
return solid.moved(Location(plane))
@staticmethod
def gear_solid(spec: GearSpec) -> Any:
"""Build an involute gear placed on ``spec.axis``.
The parametric generator constructs the gear in a local +Z frame
spanning ``z in [0, width_mm]``. This gate rotates that frame so the
gear axis lands on ``spec.axis.direction`` with ``spec.axis.origin_mm``
at the centre of the ``z = 0`` end face (same placement contract as
``thread_solid``).
"""
solid = build_gear_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.X) <= 1e-9 and abs(direction.Y) <= 1e-9:
# 轴沿 ±Z:起始相位绕轴无意义,X 方向任意。
frame_x = Vector(1.0, 0.0, 0.0)
else:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def rack_solid(spec: RackSpec) -> Any:
"""Build a rack placed on ``spec.axis``.
The parametric generator constructs the rack locally with the length
along +X, thickness along +Y and the teeth pointing along +Z (the
root/backing plane spans +X/+Y at ``z = 0``). This gate maps local
+Z onto ``spec.axis.direction`` (the direction the teeth point) with
``spec.axis.origin_mm`` at the length-start / thickness-start corner;
the length direction (+X) is a deterministic orthogonal of the tooth
direction.
"""
solid = build_rack_solid(spec)
direction = _vector(spec.axis.direction)
if abs(direction.Z) <= 0.9:
frame_x = Vector(0.0, 0.0, 1.0).cross(direction).normalized()
else:
frame_x = Vector(1.0, 0.0, 0.0)
plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction)
return solid.moved(Location(plane))
@staticmethod
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
# 对指定边以给定半径做圆角。
+6 -6
View File
@@ -39,15 +39,15 @@ _BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer",
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer",
})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance. thread_add,
# thread_cut and bend_add are excluded: pattern translation does not yet move
# their parametric axis/frame, so a replayed instance would silently re-run at
# the original location.
# thread_cut, bend_add, gear_add and rack_add are excluded: pattern
# translation does not yet move their parametric axis/frame, so a replayed
# instance would silently re-run at the original location.
_REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add"})
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"})
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
@@ -532,7 +532,7 @@ class CapabilityAnalyzer:
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "bend_add",
"thread_add", "bend_add", "gear_add", "rack_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut",
+29 -1
View File
@@ -190,6 +190,32 @@
"required": ["thickness_mm", "width_mm", "chain"],
"additionalProperties": false
},
"gearAddParams": {
"type": "object",
"properties": {
"module_mm": {"$ref": "#/$defs/positive"},
"teeth_count": {"type": "integer", "minimum": 8, "maximum": 200},
"width_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"},
"helix_angle_rad": {"type": "number", "minimum": 0, "exclusiveMaximum": 0.7853981633974483},
"pressure_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 0.6108652381980153},
"herringbone": {"type": "boolean"}
},
"required": ["module_mm", "teeth_count", "width_mm", "axis"],
"additionalProperties": false
},
"rackAddParams": {
"type": "object",
"properties": {
"module_mm": {"$ref": "#/$defs/positive"},
"teeth_count": {"type": "integer", "minimum": 1, "maximum": 2000},
"thickness_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"},
"pressure_angle_rad": {"type": "number", "exclusiveMinimum": 0, "maximum": 0.6108652381980153}
},
"required": ["module_mm", "teeth_count", "thickness_mm", "axis"],
"additionalProperties": false
},
"threadAddParams": {
"type": "object",
"properties": {
@@ -437,7 +463,7 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -466,6 +492,8 @@
{"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "bend_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/bendAddParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "gear_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/gearAddParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "rack_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/rackAddParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "thread_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadAddParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "thread_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadCutParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}},
@@ -0,0 +1,157 @@
"""Parametric equal-thickness sheet-metal bend generator (``bend_add``).
A bent sheet is an equal-thickness ribbon around its mid-plane centreline:
straight wings joined by tangent circular-arc corners of radius
``r_m = r_i + t/2``. We walk the wing chain in the local XY bending plane
(first wing along +X), round each fold vertex with ``fillet_2d``, offset the
centreline by ``+t/2`` / ``-t/2``, close both offset curves with square end
caps and extrude by ``width_mm`` along +Z. Cross-section area is exactly
``thickness * centreline_length`` so the closed-form volume is
``t * w * L_mid`` with ``L_mid = sum(leg) - sum(2 r_m cot(alpha/2)) +
sum(r_m (pi - alpha))`` (alpha = interior angle in radians). Geometric-
equivalence only: no K-factor flattening, so the volume deliberately differs
from the flat blank. Local frame: +X = first wing, +Y = thickness normal,
+Z = fold (width) axis, first wing starts at the origin. The module reads
only ``runtime_types.BendSpec`` and returns an OCC ``Solid``.
"""
from __future__ import annotations
import math
from build123d import Edge, Face, Side, Solid, Vector, Wire
try: # build123d >= 0.9 exposes Kind; keep the import optional.
from build123d import Kind as _Kind
_KIND_ARC = _Kind.ARC
except ImportError: # pragma: no cover
_KIND_ARC = None
from .runtime_types import BendLeg, BendSpec
def _unit(angle_deg: float) -> tuple[float, float]:
angle = math.radians(angle_deg)
return math.cos(angle), math.sin(angle)
def _perp(value: Vector) -> Vector:
return Vector(-value.Y, value.X, 0.0)
def bend_folds(spec: BendSpec) -> tuple[list[float], list[tuple[float, float, float, int]]]:
"""Return ``(legs, folds)``; ``folds[i] = (angle, r_i, r_m, side)``."""
if not spec.chain:
raise ValueError("bend chain must contain at least one wing")
legs = [float(leg.leg_mm) for leg in spec.chain]
folds: list[tuple[float, float, float, int]] = []
for index in range(len(spec.chain) - 1):
leg: BendLeg = spec.chain[index]
if leg.bend_angle_deg is None:
raise ValueError(f"bend chain[{index}] needs bend_angle_deg towards the next wing")
angle = float(leg.bend_angle_deg)
if not 0 < angle < 180:
raise ValueError("bend interior angle must be between 0 and 180 degrees")
radius = float(leg.inner_radius_mm)
if radius < 0:
raise ValueError("bend inner radius must be non-negative")
folds.append((angle, radius, radius + spec.thickness_mm / 2.0, int(leg.side)))
return legs, folds
def fold_vertices(legs: list[float], folds: list[tuple[float, float, float, int]]) -> list[Vector]:
"""Fold-vertex polyline (both end points included) in the XY plane."""
angle = 0.0
points = [Vector(0.0, 0.0, 0.0)]
x = y = 0.0
for index in range(len(legs)):
dx, dy = _unit(angle)
x += dx * legs[index]
y += dy * legs[index]
points.append(Vector(x, y, 0.0))
if index < len(folds):
bend_angle_deg, _r_i, _r_m, side = folds[index]
angle += float(side) * (180.0 - bend_angle_deg)
return points
def mid_path_length_mm(spec: BendSpec) -> float:
"""Exact centreline length of the bent part (mm)."""
legs, folds = bend_folds(spec)
total = sum(legs)
for bend_angle_deg, _r_i, mid_radius, _side in folds:
delta = math.radians(180.0 - bend_angle_deg)
cut = mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0)
total -= 2.0 * cut
total += mid_radius * delta
return total
def validate_chain(spec: BendSpec) -> float:
"""Range-check a bend spec; return the centreline length.
Every wing must keep a positive straight portion after its neighbouring
bend corners consume their tangent lengths (otherwise corners overlap).
"""
if spec.thickness_mm <= 0 or spec.width_mm <= 0:
raise ValueError("bend requires positive thickness_mm and width_mm")
legs, folds = bend_folds(spec)
cuts: list[float] = []
for bend_angle_deg, _r_i, mid_radius, _side in folds:
cuts.append(mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0))
for index in range(len(legs)):
left = cuts[index - 1] if index > 0 else 0.0
right = cuts[index] if index < len(folds) else 0.0
if legs[index] - left - right <= 0:
raise ValueError(
f"bend wing {index} (leg {legs[index]:.4f} mm) is consumed by the adjacent "
f"bend radii (needs > {left + right:.4f} mm)"
)
return mid_path_length_mm(spec)
def _centerline_wire(legs, folds):
"""Tangent-continuous filleted centreline wire in the XY plane."""
points = fold_vertices(legs, folds)
wire = Wire([Edge.make_line(points[i], points[i + 1]) for i in range(len(points) - 1)])
for index, (bend_angle_deg, _r_i, mid_radius, _side) in enumerate(folds):
if mid_radius <= 0:
continue
target = points[index + 1]
selected = [
vertex for vertex in wire.vertices()
if (vertex.X - target.X) ** 2 + (vertex.Y - target.Y) ** 2 < 1e-9
]
if not selected:
raise ValueError(f"bend corner {index} vertex not found")
try:
wire = wire.fillet_2d(mid_radius, selected)
except Exception as error: # pragma: no cover - defensive
raise ValueError(
f"bend corner {index} fillet of radius {mid_radius:.4f} mm failed"
) from error
return wire, points
def build_bend_solid(spec: BendSpec) -> Solid:
"""Build the bent sheet in the local frame (+Z = width axis).
The first wing's mid-plane runs from the origin along +X; thickness spans
+/- t/2 across the XY mid-plane and the width spans z in [0, width_mm].
The caller (geometry adapter) places the solid at ``spec.frame``.
"""
thickness = float(spec.thickness_mm)
width = float(spec.width_mm)
legs, folds = bend_folds(spec)
validate_chain(spec)
half = thickness / 2.0
wire, points = _centerline_wire(legs, folds)
left = wire.offset_2d(half, kind=_KIND_ARC, side=Side.LEFT, closed=False)
right = wire.offset_2d(half, kind=_KIND_ARC, side=Side.RIGHT, closed=False)
start_tangent = (points[1] - points[0]).normalized()
end_tangent = (points[-1] - points[-2]).normalized()
cap_start = Edge.make_line(points[0] + _perp(start_tangent) * half, points[0] - _perp(start_tangent) * half)
cap_end = Edge.make_line(points[-1] + _perp(end_tangent) * half, points[-1] - _perp(end_tangent) * half)
closed = Wire([*left.edges(), cap_end, *right.edges(), cap_start])
return Solid.extrude(Face(closed), Vector(0.0, 0.0, width))
@@ -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))
@@ -18,6 +18,8 @@
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
"bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
"gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
"rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
"sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
"box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
"cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
+51 -1
View File
@@ -12,7 +12,7 @@ from .build123d_adapter import Build123dGeometryAdapter
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
from .runtime_types import (
AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
ThreadSpec, Vector3,
GearSpec, RackSpec, ThreadSpec, Vector3,
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
)
@@ -29,6 +29,7 @@ ALL_ATOMIC_IDS = frozenset({
"pattern_circular",
"thread_add", "thread_cut",
"bend_add",
"gear_add", "rack_add",
})
@@ -92,6 +93,8 @@ class GeometryAdapter(Protocol):
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
def bend_solid(self, spec: BendSpec) -> Any: ...
def gear_solid(self, spec: GearSpec) -> Any: ...
def rack_solid(self, spec: RackSpec) -> Any: ...
def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
@@ -636,6 +639,51 @@ def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dic
return _execute_bend(node, session)
def _execute_gear(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。
# 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。
spec = GearSpec.from_feature(node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。
solid = session.adapter.gear_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
# 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。
diagnostics: list[RuntimeDiagnostic] = []
if spec.teeth_count < 17:
diagnostics.append(RuntimeDiagnostic(
code="undercut_risk",
message=(
f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; "
"standard involute geometry is generated without profile shift"
),
feature_id=node.feature_id,
))
return session.result(node, diagnostics=diagnostics)
def _gear_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
# 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。
del sketch
return _execute_gear(node, session)
def _execute_rack(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。
# 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。
spec = RackSpec.from_feature(node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。
solid = session.adapter.rack_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _rack_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
# 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。
del sketch
return _execute_rack(node, session)
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
@@ -1439,6 +1487,8 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"thread_add": _thread_executor,
"thread_cut": _thread_executor,
"bend_add": _bend_executor,
"gear_add": _gear_executor,
"rack_add": _rack_executor,
"extrude_add_blind": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
+140 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from math import sqrt
from math import cos, pi, radians, sqrt
from typing import Any, Iterable
@@ -460,6 +460,145 @@ class BendSpec:
return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain))
@dataclass(frozen=True)
class GearSpec:
"""Runtime-neutral definition of an involute spur/helical/herringbone gear.
``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO
involute convention: pitch radius ``m*z/2``, base radius
``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm``
is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix
angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical
gear whose helix reverses at mid-width (the mid-plane seam is shared
phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end
face and ``axis.direction`` the outward gear axis.
"""
module_mm: float
teeth_count: int
width_mm: float
axis: AxisSpec
helix_angle_rad: float = 0.0
pressure_angle_rad: float = pi / 9.0 # 20 degrees
herringbone: bool = False
@classmethod
def from_feature(cls, params: dict[str, Any]) -> "GearSpec":
try:
module = float(params.get("module_mm") or 0.0)
width = float(params.get("width_mm") or 0.0)
teeth = int(params.get("teeth_count") or 0)
helix_raw = params.get("helix_angle_rad")
helix = float(helix_raw) if helix_raw is not None else 0.0
pressure_raw = params.get("pressure_angle_rad")
pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0
except (TypeError, ValueError) as error:
raise ValueError("gear dimensions must be numeric") from error
if module <= 0 or width <= 0:
raise ValueError("gear requires positive module_mm and width_mm")
if not 8 <= teeth <= 200:
raise ValueError("gear teeth_count must be between 8 and 200")
if not 0.0 <= helix < radians(45.0):
raise ValueError("gear helix_angle_rad must be within [0, 45) degrees")
if not 0.0 < pressure <= radians(35.0):
raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees")
herringbone = bool(params.get("herringbone", False))
if herringbone and helix <= 0.0:
raise ValueError("herringbone gear requires helix_angle_rad > 0")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("gear requires an axis definition")
return cls(
module_mm=module,
teeth_count=teeth,
width_mm=width,
axis=AxisSpec.from_mapping(raw_axis),
helix_angle_rad=helix,
pressure_angle_rad=pressure,
herringbone=herringbone,
)
@property
def pitch_radius_mm(self) -> float:
return self.module_mm * self.teeth_count / 2.0
@property
def base_radius_mm(self) -> float:
return self.pitch_radius_mm * cos(self.pressure_angle_rad)
@property
def tip_radius_mm(self) -> float:
return self.pitch_radius_mm + self.module_mm
@property
def root_radius_mm(self) -> float:
return self.pitch_radius_mm - 1.25 * self.module_mm
@dataclass(frozen=True)
class RackSpec:
"""Runtime-neutral definition of a straight-sided rack (``rack_add``).
The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``,
addendum ``m`` above the reference line and dedendum ``1.25*m`` below it,
flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact
overall length ``teeth_count * pi * m`` (both ends land in tooth valley
centres). ``thickness_mm`` is the width along the tooth crest direction.
``axis.origin_mm`` is the corner point where the length-start end face,
the thickness-start face and the tooth-valley plane intersect, and
``axis.direction`` is the direction the teeth point (local +Z).
"""
module_mm: float
teeth_count: int
thickness_mm: float
axis: AxisSpec
pressure_angle_rad: float = pi / 9.0 # 20 degrees
@classmethod
def from_feature(cls, params: dict[str, Any]) -> "RackSpec":
try:
module = float(params.get("module_mm") or 0.0)
thickness = float(params.get("thickness_mm") or 0.0)
teeth = int(params.get("teeth_count") or 0)
pressure_raw = params.get("pressure_angle_rad")
pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0
except (TypeError, ValueError) as error:
raise ValueError("rack dimensions must be numeric") from error
if module <= 0 or thickness <= 0:
raise ValueError("rack requires positive module_mm and thickness_mm")
if not 1 <= teeth <= 2000:
raise ValueError("rack teeth_count must be between 1 and 2000")
if not 0.0 < pressure <= radians(35.0):
raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("rack requires an axis definition")
return cls(
module_mm=module,
teeth_count=teeth,
thickness_mm=thickness,
axis=AxisSpec.from_mapping(raw_axis),
pressure_angle_rad=pressure,
)
@property
def pitch_mm(self) -> float:
return pi * self.module_mm
@property
def length_mm(self) -> float:
return self.pitch_mm * self.teeth_count
@property
def addendum_mm(self) -> float:
return self.module_mm
@property
def dedendum_mm(self) -> float:
return 1.25 * self.module_mm
@dataclass(frozen=True)
class RuntimeDiagnostic:
code: str
+1 -1
View File
@@ -73,7 +73,7 @@ class AuthorGuidanceTests(unittest.TestCase):
self.assertLessEqual(len(selection.content), 1_200)
self.assertIn("世界坐标", selection.content)
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-"))
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread"})
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread", "op-bend", "op-gear"})
def test_phase_repair_and_budget_selection_are_stable(self) -> None:
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)