内螺纹 thread_cut 算子的增加
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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"}}}},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user