内螺纹 thread_cut 算子的增加

This commit is contained in:
2026-09-04 17:42:04 +08:00
parent 9abba1b12d
commit ef5d393c20
6 changed files with 214 additions and 18 deletions
+12 -5
View File
@@ -26,18 +26,22 @@ _PRIMARY_ATOMICS = frozenset({
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({ _ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "extrude_cut_blind", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
}) })
_BODY_MUTATING_ATOMICS = frozenset({ _BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "sphere_add", "thread_add", "revolve_add", "revolve_cut", "sphere_add", "thread_add", "thread_cut",
*_HOLE_ATOMICS, "fillet", "chamfer", *_HOLE_ATOMICS, "fillet", "chamfer",
}) })
# 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 is # Context-only features have no geometry definition to instance. thread_add
# excluded: pattern translation does not yet move its parametric axis, so a # and thread_cut are excluded: pattern translation does not yet move their
# replayed thread would silently re-fuse at the original location. # parametric axis, so a replayed thread would silently re-run at the original
# location.
_REPLAYABLE_ATOMICS = ( _REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add"}) _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"})
) | frozenset({"pattern_linear", "pattern_mirror"}) ) | frozenset({"pattern_linear", "pattern_mirror"})
_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",
@@ -433,6 +437,9 @@ class CapabilityAnalyzer:
body_producers = { body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "sphere_add", "thread_add", "revolve_add", "revolve_cut", "sphere_add", "thread_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut",
} }
document_blockers: list[RuntimeDiagnostic] = [] document_blockers: list[RuntimeDiagnostic] = []
if not any(node.atomic_id in body_producers for node in plan): if not any(node.atomic_id in body_producers for node in plan):
+22 -2
View File
@@ -126,7 +126,26 @@
"length_mm": {"$ref": "#/$defs/positive"}, "length_mm": {"$ref": "#/$defs/positive"},
"axis": {"$ref": "#/$defs/axis"}, "axis": {"$ref": "#/$defs/axis"},
"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180}, "angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180},
"lefthand": {"type": "boolean"} "lefthand": {"type": "boolean"},
"relief_length_mm": {"type": "number", "minimum": 0},
"crest_radius_mm": {"type": "number", "minimum": 0},
"root_radius_mm": {"type": "number", "minimum": 0}
},
"required": ["major_diameter_mm", "minor_diameter_mm", "pitch_mm", "length_mm", "axis"],
"additionalProperties": false
},
"threadCutParams": {
"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"},
"crest_radius_mm": {"type": "number", "minimum": 0},
"root_radius_mm": {"type": "number", "minimum": 0}
}, },
"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
@@ -332,7 +351,7 @@
"required": ["type", "contours"], "required": ["type", "contours"],
"additionalProperties": false "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", "thread_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", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": { "feature": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -356,6 +375,7 @@
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "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": "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": "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"}}}}, {"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_countersink"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCountersinkParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}},
+135 -7
View File
@@ -14,6 +14,16 @@ loop) plus the core cylinder, fuses them, and trims both ends flush at
radius so the boolean union has a clean volume overlap instead of a pair of radius so the boolean union has a clean volume overlap instead of a pair of
coincident faces (which OCC cannot fuse reliably). coincident faces (which OCC cannot fuse reliably).
``spec.internal=False`` builds an external thread (thread_add): a solid rod
whose crest envelope is ``major_diameter_mm``. ``spec.internal=True`` builds
an internal-thread cutting tool: the identical helical-rod topology but with
the crest radius over-sized by ``INTERNAL_CUT_OVERLAP_MM`` so that
``body.cut(tool)`` removes a clean helical groove from the host wall instead
of collapsing on coincident faces. External threads may add plain root-radius
end shanks (``relief_length_mm``, total length then becomes
``length_mm + 2 * relief_length_mm``) and crest/root fillet radii; the
internal cutting form accepts fillets but not relief.
The module stays independent of the CDSL runtime: it only reads The module stays independent of the CDSL runtime: it only reads
``runtime_types.ThreadSpec`` (a build123d-free data class) and returns an OCC ``runtime_types.ThreadSpec`` (a build123d-free data class) and returns an OCC
``Solid``. Construction happens in a local +Z frame anchored at ``z = 0``; ``Solid``. Construction happens in a local +Z frame anchored at ``z = 0``;
@@ -24,7 +34,7 @@ from __future__ import annotations
import math import math
from build123d import Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire from build123d import Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire
from .runtime_types import ThreadSpec from .runtime_types import ThreadSpec
@@ -34,6 +44,11 @@ from .runtime_types import ThreadSpec
_ROOT_OVERLAP_MM = 0.15 _ROOT_OVERLAP_MM = 0.15
#: Minimum surviving flat on the tooth crest before the flanks would overlap. #: Minimum surviving flat on the tooth crest before the flanks would overlap.
_MIN_CREST_HALF_WIDTH_MM = 0.02 _MIN_CREST_HALF_WIDTH_MM = 0.02
#: Internal-thread cutting tool over-size beyond the nominal major radius (mm).
#: ``body.cut(tool)`` removes a helical groove whose crest envelope has to
#: penetrate the host wall by a thin material layer; an exact-fit tool would
#: place coincident faces inside OCC's boolean and fail unpredictably.
INTERNAL_CUT_OVERLAP_MM = 0.02
def _validate_spec(spec: ThreadSpec) -> tuple[float, float, float, float, float]: def _validate_spec(spec: ThreadSpec) -> tuple[float, float, float, float, float]:
@@ -43,8 +58,8 @@ def _validate_spec(spec: ThreadSpec) -> tuple[float, float, float, float, float]
where ``flank_throw`` is the horizontal flank run per tooth side and where ``flank_throw`` is the horizontal flank run per tooth side and
``flank_half_tan`` is ``tan(flank_half_angle)``. ``flank_half_tan`` is ``tan(flank_half_angle)``.
""" """
if spec.internal: if spec.internal and spec.relief_length_mm > 0:
raise ValueError("internal threads are not implemented yet (thread_cut)") raise ValueError("relief_length_mm is only supported on external threads (thread_add)")
if spec.major_diameter_mm <= 0 or spec.minor_diameter_mm <= 0: if spec.major_diameter_mm <= 0 or spec.minor_diameter_mm <= 0:
raise ValueError("thread diameters must be positive") raise ValueError("thread diameters must be positive")
if spec.minor_diameter_mm >= spec.major_diameter_mm: if spec.minor_diameter_mm >= spec.major_diameter_mm:
@@ -56,7 +71,12 @@ def _validate_spec(spec: ThreadSpec) -> tuple[float, float, float, float, float]
if not 0 < spec.angle_deg < 180: if not 0 < spec.angle_deg < 180:
raise ValueError("thread angle_deg must be between 0 and 180") raise ValueError("thread angle_deg must be between 0 and 180")
crest_radius = spec.major_diameter_mm / 2.0 if spec.internal:
# 内螺纹刀具:牙顶必须比名义 major 大一个薄材料层,body.cut 才能
# 切入宿主孔壁完成布尔差,而不是在 coincident faces 上退化。
crest_radius = spec.major_diameter_mm / 2.0 + INTERNAL_CUT_OVERLAP_MM
else:
crest_radius = spec.major_diameter_mm / 2.0
root_radius = spec.minor_diameter_mm / 2.0 root_radius = spec.minor_diameter_mm / 2.0
depth_radius = crest_radius - root_radius depth_radius = crest_radius - root_radius
if depth_radius <= 0: if depth_radius <= 0:
@@ -109,6 +129,7 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid:
Vector(crest_radius, 0.0, -crest_half_width), Vector(crest_radius, 0.0, -crest_half_width),
] ]
wire = Wire([Edge.make_line(pts[index], pts[(index + 1) % len(pts)]) for index in range(len(pts))]) wire = Wire([Edge.make_line(pts[index], pts[(index + 1) % len(pts)]) for index in range(len(pts))])
wire = _apply_profile_fillets(wire, spec, crest_radius, sunk_root, pitch, crest_half_width)
ribbon = Solid.sweep(section=Face(wire), path=helix, make_solid=True, is_frenet=True) ribbon = Solid.sweep(section=Face(wire), path=helix, make_solid=True, is_frenet=True)
# Core cylinder at the nominal minor radius spanning the whole helix. # Core cylinder at the nominal minor radius spanning the whole helix.
@@ -120,18 +141,31 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid:
fused = core.fuse(ribbon) fused = core.fuse(ribbon)
if spec.relief_length_mm > 0:
# 端部收尾(relief):螺纹有效段保持 length_mm 不变,置于总长中部
# z ∈ [relief, relief + length],两端各附一个牙根半径的光杆段,总长 =
# length + 2 * relief。光杆与芯柱做实体重叠后由 trim 裁出干净的纯光杆端面。
return _build_external_with_relief(spec, fused, root_radius, crest_radius, pitch)
# 平移半个牙距,使 z = 0 端面落在牙谷中心:首尾端面无半牙、端面圆盘完整, # 平移半个牙距,使 z = 0 端面落在牙谷中心:首尾端面无半牙、端面圆盘完整,
# 裁切后 [0, length_mm] 内牙顶平台数稳定为 length/pitch。 # 裁切后 [0, length_mm] 内牙顶平台数稳定为 length/pitch。
fused = fused.moved(Location((0.0, 0.0, -pitch / 2.0))) fused = fused.moved(Location((0.0, 0.0, -pitch / 2.0)))
return _trim_thread_z(fused, 0.0, spec.length_mm, crest_radius)
# Trim both ends flush to z in [0, length_mm] with an oversized box.
def _trim_thread_z(fused: Solid | Compound, z0: float, z1: float, crest_radius: float) -> Solid:
"""Intersect ``fused`` with an oversized box clamped to ``z in [z0, z1]``.
A threaded solid may leave OCC float slivers at the trim planes; the
largest surviving solid is returned as the canonical body.
"""
trim_margin = 2.0 trim_margin = 2.0
half_span = crest_radius + trim_margin half_span = crest_radius + trim_margin
clamp = Solid.make_box( clamp = Solid.make_box(
2.0 * half_span, 2.0 * half_span,
2.0 * half_span, 2.0 * half_span,
spec.length_mm, z1 - z0,
Plane(origin=(-half_span, -half_span, 0.0)), Plane(origin=(-half_span, -half_span, z0)),
) )
intersected = fused.intersect(clamp) intersected = fused.intersect(clamp)
if isinstance(intersected, ShapeList): if isinstance(intersected, ShapeList):
@@ -153,6 +187,83 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid:
return trimmed return trimmed
def _build_external_with_relief(
spec: ThreadSpec,
aligned: Solid | Compound,
root_radius: float,
crest_radius: float,
pitch: float,
) -> Solid:
"""Build an externally threaded rod with plain root-radius end shanks.
The threaded portion keeps its full ``length_mm`` and sits in the middle of
the part: ``z in [relief, relief + length_mm]``. A plain shank of radius
``root_radius`` (the thread root/minor radius) extends over ``z in
[0, relief]`` and ``[relief + length_mm, total]``, so the total part length
is ``length_mm + 2 * relief_length_mm``.
``aligned`` is the fused core+ribbon *before* the valley-centring shift.
The build first trims a clean valley-centred thread over ``[0, length_mm]``
(identical phase and tooth count to the plain build), then shifts it up by
``relief`` so both end planes land inside tooth valleys. Each shank
cylinder overlaps the threaded core by ``_ROOT_OVERLAP_MM`` so both fuses
are volume booleans, and the final trim turns the two end planes into clean
plain discs at ``z = 0`` and ``z = total``.
"""
relief = spec.relief_length_mm
length = spec.length_mm
overlap = _ROOT_OVERLAP_MM
total = length + 2.0 * relief
thread = _trim_thread_z(aligned.moved(Location((0.0, 0.0, -pitch / 2.0))), 0.0, length, crest_radius)
thread = thread.moved(Location((0.0, 0.0, relief)))
bottom = Solid.make_cylinder(root_radius, relief + overlap, Plane(origin=(0.0, 0.0, 0.0)))
top = Solid.make_cylinder(
root_radius,
relief + overlap,
Plane(origin=(0.0, 0.0, relief + length - overlap)),
)
fused = thread.fuse(bottom).fuse(top)
return _trim_thread_z(fused, 0.0, total, crest_radius)
def _apply_profile_fillets(
wire: Wire,
spec: ThreadSpec,
crest_radius: float,
sunk_root: float,
pitch: float,
crest_half_width: float,
) -> Wire:
"""Round the tooth crest/root corners of the axial cross-section.
A crest/root radius that is too large for the flank lengths makes OCC's
``fillet_2d`` fail; the profile then falls back to the sharp-cornered
trapezoid instead of blocking the whole build (AGENTS: never destroy a
buildable model over a cosmetic detail).
"""
if spec.crest_radius_mm <= 0 and spec.root_radius_mm <= 0:
return wire
try:
if spec.crest_radius_mm > 0:
crest_vertices = [
vertex for vertex in wire.vertices()
if vertex.X >= crest_radius - 1e-6 and abs(vertex.Z) <= crest_half_width + 1e-6
]
if crest_vertices:
wire = wire.fillet_2d(spec.crest_radius_mm, crest_vertices)
if spec.root_radius_mm > 0:
root_vertices = [
vertex for vertex in wire.vertices()
if vertex.X <= sunk_root + 1e-6 and abs(vertex.Z) >= pitch / 2.0 - 1e-6
]
if root_vertices:
wire = wire.fillet_2d(spec.root_radius_mm, root_vertices)
except Exception:
# 清根圆角过大导致截面退化:保留尖角梯形,不阻断生成。
pass
return wire
def build_thread_solid(spec: ThreadSpec) -> Solid: def build_thread_solid(spec: ThreadSpec) -> Solid:
"""Build one external-threaded solid segment for ``spec``. """Build one external-threaded solid segment for ``spec``.
@@ -164,3 +275,20 @@ def build_thread_solid(spec: ThreadSpec) -> Solid:
if not result.solids(): if not result.solids():
raise ValueError("thread generation produced no solid") raise ValueError("thread generation produced no solid")
return result return result
def build_thread_cut_tool(spec: ThreadSpec) -> Solid:
"""Build the internal-thread cutting tool that ``thread_cut`` subtracts.
The tool is exactly the ``internal=True`` thread form: the identical
helical-rod topology as ``thread_add`` but with the crest envelope
over-sized by ``INTERNAL_CUT_OVERLAP_MM`` beyond the nominal major radius.
``host.cut(tool)`` then removes a clean full-depth helical groove from the
host bore wall instead of collapsing on a pair of coincident faces (which
OCC cannot cut reliably). This is the geometry-side entry point of the
``thread_cut`` atomic; the caller (geometry adapter) places the resulting
+Z-aligned tool at ``spec.axis`` exactly like an external thread segment.
"""
if not spec.internal:
raise ValueError("build_thread_cut_tool requires an internal-thread spec (internal=True)")
return build_thread_solid(spec)
@@ -15,7 +15,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"]},
"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"]},
"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"]}, "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"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"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"]},
"thread_cut": {"atomic_id":"thread_cut","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"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"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":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"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_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":[]}, "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": { "hole_wizard": {
+20 -2
View File
@@ -24,7 +24,7 @@ ALL_ATOMIC_IDS = frozenset({
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
"thread_add", "thread_add", "thread_cut",
}) })
@@ -525,11 +525,28 @@ def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> Feature
def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
# 螺纹不需要草图平面,丢弃该参数后执行。 # 螺纹特征(thread_add/thread_cut不需要草图平面,丢弃该参数后执行。
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
del sketch del sketch
if node.atomic_id == "thread_cut":
return _execute_thread_cut(node, session)
return _execute_thread(node, session) return _execute_thread(node, session)
def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
tool = session.adapter.thread_solid(spec)
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
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")
@@ -1042,6 +1059,7 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"reference_axis": _reference_axis_executor, "reference_axis": _reference_axis_executor,
"sphere_add": _sphere_executor, "sphere_add": _sphere_executor,
"thread_add": _thread_executor, "thread_add": _thread_executor,
"thread_cut": _thread_executor,
"extrude_add_blind": _primary_executor, "extrude_add_blind": _primary_executor,
"extrude_add_two_sided": _primary_executor, "extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor, "extrude_cut_blind": _primary_executor,
+23 -1
View File
@@ -295,6 +295,9 @@ class ThreadSpec:
angle_deg: float = 60.0 angle_deg: float = 60.0
internal: bool = False internal: bool = False
lefthand: bool = False lefthand: bool = False
relief_length_mm: float = 0.0
crest_radius_mm: float = 0.0
root_radius_mm: float = 0.0
@classmethod @classmethod
def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec":
@@ -321,6 +324,22 @@ class ThreadSpec:
raise ValueError("thread angle_deg must be numeric") from error raise ValueError("thread angle_deg must be numeric") from error
if not 0 < angle < 180: if not 0 < angle < 180:
raise ValueError("thread angle_deg must be between 0 and 180") raise ValueError("thread angle_deg must be between 0 and 180")
relief = float(params.get("relief_length_mm") or 0.0)
crest_r = float(params.get("crest_radius_mm") or 0.0)
root_r = float(params.get("root_radius_mm") or 0.0)
if relief < 0:
raise ValueError("thread relief_length_mm must be non-negative")
if crest_r < 0 or root_r < 0:
raise ValueError("thread crest/root radius must be non-negative")
# 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半,
# 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。
depth_radius = (major - minor) / 2.0
if crest_r + root_r > 0.5 * depth_radius:
raise ValueError("thread crest_radius + root_radius exceeds half the thread depth")
# thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传
# internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓
# 刀具做布尔差会在 coincident faces 上退化)。
internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False))
return cls( return cls(
major_diameter_mm=major, major_diameter_mm=major,
minor_diameter_mm=minor, minor_diameter_mm=minor,
@@ -328,8 +347,11 @@ class ThreadSpec:
length_mm=length, length_mm=length,
axis=AxisSpec.from_mapping(raw_axis), axis=AxisSpec.from_mapping(raw_axis),
angle_deg=angle, angle_deg=angle,
internal=bool(params.get("internal", False)), internal=internal,
lefthand=bool(params.get("lefthand", False)), lefthand=bool(params.get("lefthand", False)),
relief_length_mm=relief,
crest_radius_mm=crest_r,
root_radius_mm=root_r,
) )