merge: resolve main integration conflicts
This commit is contained in:
@@ -1 +1 @@
|
|||||||
只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。
|
以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
世界坐标默认是右手 mm。根 `XY` 与 `+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X`,`normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件。
|
世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "cdsl.author-guidance.manifest.v1",
|
"schema_version": "cdsl.author-guidance.manifest.v1",
|
||||||
"version": "2026-09-03.1",
|
"version": "2026-09-09.1",
|
||||||
"sections": [
|
"sections": [
|
||||||
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
|
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
|
||||||
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
|
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
|
||||||
@@ -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"],
|
||||||
@@ -40,13 +42,19 @@
|
|||||||
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
|
||||||
"operation_sections": {
|
"operation_sections": {
|
||||||
"extrude_add_blind": ["op-extrude-add"],
|
"extrude_add_blind": ["op-extrude-add"],
|
||||||
|
"extrude_add_blind_with_hole": ["op-extrude-add"],
|
||||||
"extrude_add_two_sided": ["op-extrude-add"],
|
"extrude_add_two_sided": ["op-extrude-add"],
|
||||||
|
"extrude_from_face": ["op-extrude-add"],
|
||||||
|
"extrude_surface": ["op-extrude-add"],
|
||||||
"extrude_cut_blind": ["op-extrude-cut"],
|
"extrude_cut_blind": ["op-extrude-cut"],
|
||||||
"extrude_cut_two_sided": ["op-extrude-cut"],
|
"extrude_cut_two_sided": ["op-extrude-cut"],
|
||||||
"extrude_cut_through": ["op-extrude-cut"],
|
"extrude_cut_through": ["op-extrude-cut"],
|
||||||
"loft_add": ["op-loft"],
|
"loft_add": ["op-loft"],
|
||||||
|
"loft_add_with_cap_face": ["op-loft"],
|
||||||
"revolve_add": ["op-revolve"],
|
"revolve_add": ["op-revolve"],
|
||||||
"revolve_cut": ["op-revolve"],
|
"revolve_cut": ["op-revolve"],
|
||||||
|
"revolve_surface": ["op-revolve"],
|
||||||
|
"sweep_add": ["op-loft"],
|
||||||
"hole_blind": ["op-hole"],
|
"hole_blind": ["op-hole"],
|
||||||
"hole_counterbore": ["op-hole"],
|
"hole_counterbore": ["op-hole"],
|
||||||
"hole_countersink": ["op-hole"],
|
"hole_countersink": ["op-hole"],
|
||||||
@@ -58,10 +66,17 @@
|
|||||||
"pattern_circular": ["op-pattern"],
|
"pattern_circular": ["op-pattern"],
|
||||||
"fillet": ["op-finish"],
|
"fillet": ["op-finish"],
|
||||||
"chamfer": ["op-finish"],
|
"chamfer": ["op-finish"],
|
||||||
|
"shell": ["op-finish"],
|
||||||
|
"boolean_bodies": ["op-finish"],
|
||||||
|
"transform_bodies": ["op-finish"],
|
||||||
|
"delete_bodies": ["op-finish"],
|
||||||
"sphere_add": ["op-sphere"],
|
"sphere_add": ["op-sphere"],
|
||||||
"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 < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。
|
||||||
@@ -1 +1 @@
|
|||||||
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。
|
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
|
||||||
|
|||||||
@@ -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 叠加。
|
||||||
@@ -1 +1 @@
|
|||||||
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
|
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ from OCP.TopTools import TopTools_ListOfShape
|
|||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||||
|
|
||||||
|
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 (
|
from .runtime_types import (
|
||||||
AxisSpec, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyDelta,
|
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
|
||||||
TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature,
|
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
||||||
|
canonical_plane_signature,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1354,6 +1357,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:
|
||||||
# 对指定边以给定半径做圆角。
|
# 对指定边以给定半径做圆角。
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _BODY_MUTATING_ATOMICS = frozenset({
|
|||||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||||
"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", "shell",
|
||||||
})
|
})
|
||||||
# ``ExecutionSession.body_members`` only contains independently selectable
|
# ``ExecutionSession.body_members`` only contains independently selectable
|
||||||
# body outputs. A normal additive/cut/dress-up feature replaces the active
|
# body outputs. A normal additive/cut/dress-up feature replaces the active
|
||||||
@@ -53,11 +53,11 @@ _BODY_MUTATING_ATOMICS = frozenset({
|
|||||||
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
|
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
|
||||||
# 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",
|
||||||
@@ -663,26 +663,26 @@ class CapabilityAnalyzer:
|
|||||||
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
for feature_id in feature_ids:
|
for source_id in feature_ids:
|
||||||
source = nodes_by_id.get(str(feature_id))
|
source = nodes_by_id.get(str(source_id))
|
||||||
if source is None:
|
if source is None:
|
||||||
blockers.append(self._blocker(
|
blockers.append(self._blocker(
|
||||||
node.feature_id, "boolean_body_unavailable",
|
node.feature_id, "boolean_body_unavailable",
|
||||||
"booleanBodies source feature does not exist", source_feature_id=feature_id,
|
"booleanBodies source feature does not exist", source_feature_id=source_id,
|
||||||
))
|
))
|
||||||
elif source.feature_id not in completed:
|
elif source.feature_id not in completed:
|
||||||
blockers.append(self._blocker(
|
blockers.append(self._blocker(
|
||||||
node.feature_id, "boolean_body_unavailable",
|
node.feature_id, "boolean_body_unavailable",
|
||||||
"booleanBodies source feature did not become executable", source_feature_id=feature_id,
|
"booleanBodies source feature did not become executable", source_feature_id=source_id,
|
||||||
))
|
))
|
||||||
elif source.feature_id not in body_members:
|
elif source.feature_id not in body_members:
|
||||||
blockers.append(self._blocker(
|
blockers.append(self._blocker(
|
||||||
node.feature_id, "boolean_body_unavailable",
|
node.feature_id, "boolean_body_unavailable",
|
||||||
"Selected source no longer has an independently selectable body output",
|
"Selected source no longer has an independently selectable body output",
|
||||||
source_feature_id=feature_id,
|
source_feature_id=source_id,
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
selected_members.add(str(feature_id))
|
selected_members.add(str(source_id))
|
||||||
for index, reference in enumerate(instance_refs):
|
for index, reference in enumerate(instance_refs):
|
||||||
if not isinstance(reference, dict):
|
if not isinstance(reference, dict):
|
||||||
blockers.append(self._blocker(
|
blockers.append(self._blocker(
|
||||||
@@ -1073,7 +1073,7 @@ class CapabilityAnalyzer:
|
|||||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||||
"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",
|
||||||
|
|||||||
@@ -190,7 +190,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||||
"center_mm": {"$ref": "#/$defs/point3"}
|
"center_mm": {"$ref": "#/$defs/point3"},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
},
|
},
|
||||||
"required": ["radius_mm", "center_mm"],
|
"required": ["radius_mm", "center_mm"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -201,7 +202,8 @@
|
|||||||
"length_mm": {"$ref": "#/$defs/positive"},
|
"length_mm": {"$ref": "#/$defs/positive"},
|
||||||
"width_mm": {"$ref": "#/$defs/positive"},
|
"width_mm": {"$ref": "#/$defs/positive"},
|
||||||
"height_mm": {"$ref": "#/$defs/positive"},
|
"height_mm": {"$ref": "#/$defs/positive"},
|
||||||
"center_mm": {"$ref": "#/$defs/point3"}
|
"center_mm": {"$ref": "#/$defs/point3"},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
},
|
},
|
||||||
"required": ["length_mm", "width_mm", "height_mm", "center_mm"],
|
"required": ["length_mm", "width_mm", "height_mm", "center_mm"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -211,7 +213,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||||
"height_mm": {"$ref": "#/$defs/positive"},
|
"height_mm": {"$ref": "#/$defs/positive"},
|
||||||
"axis": {"$ref": "#/$defs/axis"}
|
"axis": {"$ref": "#/$defs/axis"},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
},
|
},
|
||||||
"required": ["radius_mm", "height_mm"],
|
"required": ["radius_mm", "height_mm"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -243,11 +246,40 @@
|
|||||||
},
|
},
|
||||||
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
|
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
},
|
},
|
||||||
"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"},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
|
},
|
||||||
|
"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},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
|
},
|
||||||
|
"required": ["module_mm", "teeth_count", "thickness_mm", "axis"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"threadAddParams": {
|
"threadAddParams": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -260,7 +292,8 @@
|
|||||||
"lefthand": {"type": "boolean"},
|
"lefthand": {"type": "boolean"},
|
||||||
"relief_length_mm": {"type": "number", "minimum": 0},
|
"relief_length_mm": {"type": "number", "minimum": 0},
|
||||||
"crest_radius_mm": {"type": "number", "minimum": 0},
|
"crest_radius_mm": {"type": "number", "minimum": 0},
|
||||||
"root_radius_mm": {"type": "number", "minimum": 0}
|
"root_radius_mm": {"type": "number", "minimum": 0},
|
||||||
|
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||||
},
|
},
|
||||||
"required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"],
|
"required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -651,7 +684,7 @@
|
|||||||
"required": ["type", "source_entities", "selections"],
|
"required": ["type", "source_entities", "selections"],
|
||||||
"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_from_face", "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", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
"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_from_face", "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", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||||
"feature": {
|
"feature": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -686,6 +719,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))
|
||||||
@@ -24,6 +24,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"},"result_mode":{"enum":["fuse","new_body"]}},"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},"result_mode":{"enum":["fuse","new_body"]}},"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"]},
|
||||||
|
|||||||
@@ -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, TopologyDelta, TopologyDeltaRelation, Vector3,
|
GearSpec, RackSpec, ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3,
|
||||||
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
|
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
|
||||||
pattern_instance_member_id, transform_copy_member_id,
|
pattern_instance_member_id, transform_copy_member_id,
|
||||||
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
|
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
|
||||||
@@ -30,6 +30,7 @@ ALL_ATOMIC_IDS = frozenset({
|
|||||||
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
||||||
"thread_add", "thread_cut",
|
"thread_add", "thread_cut",
|
||||||
"bend_add",
|
"bend_add",
|
||||||
|
"gear_add", "rack_add",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +116,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: ...
|
||||||
@@ -1101,9 +1104,32 @@ def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) ->
|
|||||||
return session.result(node, context=axis)
|
return session.result(node, context=axis)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_added_solid(
|
||||||
|
session: ExecutionSession,
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
solid: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
|
||||||
|
|
||||||
|
When ``node.params['result_mode'] == "new_body"`` the primitive is kept as
|
||||||
|
an independent body member so that downstream ``boolean_bodies`` can
|
||||||
|
reference it without pulling in the accumulated fuse history. The current
|
||||||
|
body is replaced by a Compound that preserves both, matching the
|
||||||
|
``extrude_add_blind`` ``new_body`` semantics. Any other value (including
|
||||||
|
missing) falls back to the legacy fuse-into-body behavior.
|
||||||
|
"""
|
||||||
|
if node.params.get("result_mode") == "new_body":
|
||||||
|
combined = session.adapter.combine(session.body, solid)
|
||||||
|
members = {**session.body_members, node.feature_id: solid}
|
||||||
|
session.register_body(node.feature_id, combined, replay_node=node, body_members=members)
|
||||||
|
return
|
||||||
|
fused = session.adapter.fuse(session.body, solid)
|
||||||
|
session.register_body(node.feature_id, fused, replay_node=node)
|
||||||
|
|
||||||
|
|
||||||
def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||||
# 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。
|
# 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。
|
||||||
|
|
||||||
# 1. 解析参数:半径 radius_mm 与球心 center_mm。
|
# 1. 解析参数:半径 radius_mm 与球心 center_mm。
|
||||||
radius = float(node.params.get("radius_mm") or 0.0)
|
radius = float(node.params.get("radius_mm") or 0.0)
|
||||||
center = node.params.get("center_mm") or []
|
center = node.params.get("center_mm") or []
|
||||||
@@ -1113,7 +1139,7 @@ def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> Feature
|
|||||||
# 3. 由适配器创建球体实体。
|
# 3. 由适配器创建球体实体。
|
||||||
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
|
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
|
||||||
# 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
# 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
_register_added_solid(session, node, solid)
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -1139,7 +1165,7 @@ def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureRes
|
|||||||
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
|
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
|
||||||
solid = session.adapter.box(length, width, height, plane)
|
solid = session.adapter.box(length, width, height, plane)
|
||||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
_register_added_solid(session, node, solid)
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -1163,7 +1189,7 @@ def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> Featu
|
|||||||
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
||||||
solid = session.adapter.cylinder(radius, height, axis)
|
solid = session.adapter.cylinder(radius, height, axis)
|
||||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
_register_added_solid(session, node, solid)
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -1174,7 +1200,7 @@ def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> Feature
|
|||||||
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
|
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
|
||||||
solid = session.adapter.thread_solid(spec)
|
solid = session.adapter.thread_solid(spec)
|
||||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
_register_added_solid(session, node, solid)
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -1208,7 +1234,7 @@ def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureRe
|
|||||||
# 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。
|
# 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。
|
||||||
solid = session.adapter.bend_solid(spec)
|
solid = session.adapter.bend_solid(spec)
|
||||||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
_register_added_solid(session, node, solid)
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -1218,6 +1244,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)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
# 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)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
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")
|
||||||
@@ -2411,6 +2482,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -480,6 +480,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user