From bb9e8a8720084daf87c56c5efb5a6bb6a0898f57 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 3 Sep 2026 19:39:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86thread=E5=87=A0?= =?UTF-8?q?=E4=BD=95=E5=9F=BA=E7=A1=80=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../engine/cdsl_engine/build123d_adapter.py | 23 ++++++- backend/engine/cdsl_engine/capabilities.py | 13 ++-- backend/engine/cdsl_engine/cdsl_schema.json | 17 +++++- .../engine/cdsl_engine/profile_schema.json | 1 + backend/engine/cdsl_engine/runtime.py | 23 ++++++- backend/engine/cdsl_engine/runtime_types.py | 60 +++++++++++++++++++ 6 files changed, 130 insertions(+), 7 deletions(-) diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 4d042a49..632b4858 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -7,7 +7,8 @@ from typing import Any, Iterable from build123d import Axis, Compound, Edge, Face, Helix, Location, Plane, ShapeList, Solid, Vector, Wire, export_step -from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature +from .parametric_thread import build_thread_solid +from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -354,6 +355,26 @@ class Build123dGeometryAdapter: raise ValueError("hole has no positions") return result + @staticmethod + def thread_solid(spec: ThreadSpec) -> Any: + """Build a threaded solid segment anchored on ``spec.axis``. + + The parametric generator constructs the thread in a local +Z frame + spanning ``[0, length_mm]``. This gate rotates that frame so the + thread axis lands on ``spec.axis.direction`` with + ``spec.axis.origin_mm`` at the leading end face, keeping placement in + the adapter and geometry construction in ``parametric_thread.py``. + """ + solid = build_thread_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 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 00c655e0..24d1a37d 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -29,11 +29,16 @@ _ACTIVE_BODY_REQUIRED = frozenset({ }) _BODY_MUTATING_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "revolve_add", "revolve_cut", "sphere_add", *_HOLE_ATOMICS, "fillet", "chamfer", + "revolve_add", "revolve_cut", "sphere_add", "thread_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. -_REPLAYABLE_ATOMICS = _BODY_MUTATING_ATOMICS | frozenset({"pattern_linear", "pattern_mirror"}) +# Context-only features have no geometry definition to instance. thread_add is +# excluded: pattern translation does not yet move its parametric axis, so a +# replayed thread would silently re-fuse at the original location. +_REPLAYABLE_ATOMICS = ( + _BODY_MUTATING_ATOMICS - frozenset({"thread_add"}) +) | frozenset({"pattern_linear", "pattern_mirror"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body", @@ -427,7 +432,7 @@ class CapabilityAnalyzer: body_available = True body_producers = { "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "revolve_add", "revolve_cut", "sphere_add", + "revolve_add", "revolve_cut", "sphere_add", "thread_add", } document_blockers: list[RuntimeDiagnostic] = [] if not any(node.atomic_id in body_producers for node in plan): diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 56c9a634..9bcd536b 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -117,6 +117,20 @@ "required": ["radius_mm", "center_mm"], "additionalProperties": false }, + "threadAddParams": { + "type": "object", + "properties": { + "major_diameter_mm": {"$ref": "#/$defs/positive"}, + "minor_diameter_mm": {"$ref": "#/$defs/positive"}, + "pitch_mm": {"$ref": "#/$defs/positive"}, + "length_mm": {"$ref": "#/$defs/positive"}, + "axis": {"$ref": "#/$defs/axis"}, + "angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180}, + "lefthand": {"type": "boolean"} + }, + "required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"], + "additionalProperties": false + }, "holeBaseParams": { "type": "object", "properties": { @@ -318,7 +332,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "reference_plane", "reference_axis", "hole_wizard"]}, + "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "thread_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -341,6 +355,7 @@ {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "thread_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadAddParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_countersink"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCountersinkParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}}, diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index a7828113..406d8383 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -15,6 +15,7 @@ "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"]}, "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"]}, + "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_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},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_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"]}, "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"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":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]}, "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"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":["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":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[]}, "hole_wizard": { diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index d2221b8e..e2dc0e29 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -11,7 +11,8 @@ from typing import Any, Callable, Protocol from .build123d_adapter import Build123dGeometryAdapter from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract from .runtime_types import ( - AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, Vector3, + AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, + ThreadSpec, Vector3, RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) @@ -23,6 +24,7 @@ ALL_ATOMIC_IDS = frozenset({ "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", + "thread_add", }) @@ -83,6 +85,7 @@ class GeometryAdapter(Protocol): def fuse(self, body: Any | None, solid: Any) -> Any: ... def cut(self, body: Any, tool: Any) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def thread_solid(self, spec: ThreadSpec) -> 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: ... @@ -510,6 +513,23 @@ def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> Feature return session.result(node) +def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 + # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 + spec = ThreadSpec.from_feature(node.atomic_id, node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 + solid = session.adapter.thread_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + # 螺纹不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_thread(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") @@ -1021,6 +1041,7 @@ EXECUTORS: dict[str, ExecutorFunction] = { "reference_plane": _reference_plane_executor, "reference_axis": _reference_axis_executor, "sphere_add": _sphere_executor, + "thread_add": _thread_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 698112e9..48557a67 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -273,6 +273,66 @@ class HoleSpec: ) +@dataclass(frozen=True) +class ThreadSpec: + """Runtime-neutral definition of a parametric screw thread. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a threaded solid segment keeping source-contract + parsing separate from B-rep work. + + ``axis.origin_mm`` anchors the leading end face of the threaded segment and + ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full + flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). + ``internal`` selects the cutting form used by later thread_cut support. + """ + + major_diameter_mm: float + minor_diameter_mm: float + pitch_mm: float + length_mm: float + axis: AxisSpec + angle_deg: float = 60.0 + internal: bool = False + lefthand: bool = False + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": + try: + major = float(params.get("major_diameter_mm") or 0.0) + minor = float(params.get("minor_diameter_mm") or 0.0) + pitch = float(params.get("pitch_mm") or 0.0) + length = float(params.get("length_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("thread dimensions must be numeric") from error + if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: + raise ValueError("thread requires positive major/minor/pitch/length in millimetres") + if minor >= major: + raise ValueError("thread minor diameter must be smaller than the major diameter") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("thread requires an axis definition") + angle = 60.0 + raw_angle = params.get("angle_deg") + if raw_angle is not None: + try: + angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError("thread angle_deg must be numeric") from error + if not 0 < angle < 180: + raise ValueError("thread angle_deg must be between 0 and 180") + return cls( + major_diameter_mm=major, + minor_diameter_mm=minor, + pitch_mm=pitch, + length_mm=length, + axis=AxisSpec.from_mapping(raw_axis), + angle_deg=angle, + internal=bool(params.get("internal", False)), + lefthand=bool(params.get("lefthand", False)), + ) + + @dataclass(frozen=True) class RuntimeDiagnostic: code: str -- 2.52.0