Merge pull request '增加了thread几何基础操作' (#9) from ganjihong into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-09-04 10:18:51 +08:00
6 changed files with 130 additions and 7 deletions
@@ -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:
# 对指定边以给定半径做圆角。
+9 -4
View File
@@ -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):
+16 -1
View File
@@ -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"}}}},
@@ -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": {
+22 -1
View File
@@ -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,
@@ -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