提交关于N5功能,“involute_gear渐开线齿廓与齿条专用生成”的改动
This commit is contained in:
@@ -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 < 180,90 为直角折弯),可选 `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 叠加。
|
||||
@@ -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:
|
||||
# 对指定边以给定半径做圆角。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user