Merge pull request 'N5 渐开线齿廓与齿条专用生成 involute_gear 的功能增加' (#17) from ganjihong into main

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-09-08 14:25:26 +08:00
14 changed files with 774 additions and 13 deletions
@@ -23,7 +23,9 @@
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true}, {"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-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-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": { "phase_sections": {
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
@@ -62,6 +64,9 @@
"box_add": ["op-primitives"], "box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"], "cylinder_add": ["op-primitives"],
"thread_add": ["op-thread"], "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, "selected_edges_exist": self._preflight_selected_edges_exist,
"source_features_exist": self._preflight_source_features_exist, "source_features_exist": self._preflight_source_features_exist,
"mirror_plane_exists": self._preflight_mirror_plane_exists, "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") schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json")
try: 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": 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") 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 @staticmethod
def _polygon_self_intersects(vertices: list[Any]) -> bool: 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] 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", "selected_edges_exist",
"source_features_exist", "source_features_exist",
"mirror_plane_exists", "mirror_plane_exists",
"loft_profiles_exist",
"loft_profiles_closed",
"loft_profiles_single_region",
}) })
@@ -22,8 +22,9 @@ from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from .parametric_bend import build_bend_solid 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 .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: def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
@@ -721,6 +722,47 @@ class Build123dGeometryAdapter:
) )
return solid.moved(Location(plane)) 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 @staticmethod
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any: 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_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies", "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. # 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, # Context-only features have no geometry definition to instance. thread_add,
# thread_cut and bend_add are excluded: pattern translation does not yet move # thread_cut, bend_add, gear_add and rack_add are excluded: pattern
# their parametric axis/frame, so a replayed instance would silently re-run at # translation does not yet move their parametric axis/frame, so a replayed
# the original location. # instance would silently re-run at the original location.
_REPLAYABLE_ATOMICS = ( _REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "shell"}) _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"})
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) ) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({ _SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
@@ -610,7 +610,7 @@ class CapabilityAnalyzer:
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_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 一致:无宿主时由 # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut", "thread_cut",
+29 -1
View File
@@ -242,6 +242,32 @@
"required": ["thickness_mm", "width_mm", "chain"], "required": ["thickness_mm", "width_mm", "chain"],
"additionalProperties": false "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": { "threadAddParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -522,7 +548,7 @@
"required": ["type", "contours"], "required": ["type", "contours"],
"additionalProperties": false "additionalProperties": false
}, },
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "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": { "feature": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -556,6 +582,8 @@
{"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}}, {"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": "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": "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_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": "thread_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadCutParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}}, {"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))
@@ -23,6 +23,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_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"]}, "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"]}, "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"]}, "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"]}, "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"]}, "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 .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
from .runtime_types import ( from .runtime_types import (
AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
ThreadSpec, Vector3, GearSpec, RackSpec, ThreadSpec, Vector3,
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
) )
@@ -29,6 +29,7 @@ ALL_ATOMIC_IDS = frozenset({
"pattern_circular", "boolean_bodies", "pattern_circular", "boolean_bodies",
"thread_add", "thread_cut", "thread_add", "thread_cut",
"bend_add", "bend_add",
"gear_add", "rack_add",
}) })
@@ -104,6 +105,8 @@ class GeometryAdapter(Protocol):
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ...
def bend_solid(self, spec: BendSpec) -> 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 hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
def body_center(self, body: Any) -> Vector3: ... def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ... def body_span(self, body: Any, direction: Vector3) -> float: ...
@@ -945,6 +948,51 @@ def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dic
return _execute_bend(node, session) 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: def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
if resolution.record is None: if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
@@ -1925,6 +1973,8 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"thread_add": _thread_executor, "thread_add": _thread_executor,
"thread_cut": _thread_executor, "thread_cut": _thread_executor,
"bend_add": _bend_executor, "bend_add": _bend_executor,
"gear_add": _gear_executor,
"rack_add": _rack_executor,
"extrude_add_blind": _primary_executor, "extrude_add_blind": _primary_executor,
"extrude_add_blind_with_hole": _primary_executor, "extrude_add_blind_with_hole": _primary_executor,
"extrude_add_two_sided": _primary_executor, "extrude_add_two_sided": _primary_executor,
+140 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import warnings import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from math import sqrt from math import cos, pi, radians, sqrt
from typing import Any, Iterable from typing import Any, Iterable
@@ -460,6 +460,145 @@ class BendSpec:
return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) 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) @dataclass(frozen=True)
class RuntimeDiagnostic: class RuntimeDiagnostic:
code: str code: str
+1 -1
View File
@@ -73,7 +73,7 @@ class AuthorGuidanceTests(unittest.TestCase):
self.assertLessEqual(len(selection.content), 1_200) self.assertLessEqual(len(selection.content), 1_200)
self.assertIn("世界坐标", selection.content) self.assertIn("世界坐标", selection.content)
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) 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: def test_phase_repair_and_budget_selection_are_stable(self) -> None:
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)