diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json index f82f8911..ad5f8c2d 100644 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ b/backend/agent/skills/cdsl-author-guidance/manifest.json @@ -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"] } } diff --git a/backend/agent/skills/cdsl-author-guidance/op-bend.md b/backend/agent/skills/cdsl-author-guidance/op-bend.md new file mode 100644 index 00000000..8e9918b6 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-bend.md @@ -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 替代。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-gear.md b/backend/agent/skills/cdsl-author-guidance/op-gear.md new file mode 100644 index 00000000..bc47d341 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-gear.md @@ -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 叠加。 diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 7eed94fd..65658289 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -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] diff --git a/backend/app/cad_agent/domain/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index f2cde698..3569e4fd 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -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", }) diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 28969a7b..1e131d4a 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -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: # 对指定边以给定半径做圆角。 diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 88c21a99..34c0c9a6 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -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", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index e676224a..19bb6ecb 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -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"}}}}, diff --git a/backend/engine/cdsl_engine/parametric_bend.py b/backend/engine/cdsl_engine/parametric_bend.py new file mode 100644 index 00000000..f143234a --- /dev/null +++ b/backend/engine/cdsl_engine/parametric_bend.py @@ -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)) diff --git a/backend/engine/cdsl_engine/parametric_gears.py b/backend/engine/cdsl_engine/parametric_gears.py new file mode 100644 index 00000000..487f8ba8 --- /dev/null +++ b/backend/engine/cdsl_engine/parametric_gears.py @@ -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)) diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 8e25ef9d..4cfdc58e 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -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"]}, diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 49769822..62245aaf 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -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, diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index 2d85f548..39a8689c 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -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 diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py index 54af4727..fe734dfd 100644 --- a/backend/tests/test_author_guidance.py +++ b/backend/tests/test_author_guidance.py @@ -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)