"""Parametric screw-thread generator built on build123d geometry. Strategy -------- A screw thread is a helical prism: one trapezoidal tooth profile swept along a helix using the OCC Frenet frame. ``is_frenet=True`` keeps the profile orientation constant along a straight helix (the curvature vector always points at the axis), which is exactly the configuration a machined thread has: the flank is a true helical surface with a constant axial pitch. The generator builds a single seamless "tooth ribbon" (one pitch wide per loop) plus the core cylinder, fuses them, and trims both ends flush at ``z in [0, length_mm]``. The tooth root is sunk slightly below the core radius so the boolean union has a clean volume overlap instead of a pair of 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 ``runtime_types.ThreadSpec`` (a build123d-free data class) and returns an OCC ``Solid``. Construction happens in a local +Z frame anchored at ``z = 0``; frame placement/rotation to ``spec.axis`` is the adapter's responsibility. """ from __future__ import annotations import math from build123d import Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire from .runtime_types import ThreadSpec #: How far below the nominal core radius the tooth root extends (mm, clamped). #: The extra overlap guarantees the root cylinder union is a clean volume #: boolean rather than a coincident-face attachment. _ROOT_OVERLAP_MM = 0.15 #: Minimum surviving flat on the tooth crest before the flanks would overlap. _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]: """Range-check a spec and return geometry parameters. Returns ``(crest_radius, root_radius, sink, flank_throw, flank_half_tan)`` where ``flank_throw`` is the horizontal flank run per tooth side and ``flank_half_tan`` is ``tan(flank_half_angle)``. """ if spec.internal and spec.relief_length_mm > 0: 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: raise ValueError("thread diameters must be positive") if spec.minor_diameter_mm >= spec.major_diameter_mm: raise ValueError("thread minor diameter must be smaller than the major diameter") if spec.pitch_mm <= 0: raise ValueError("thread pitch must be positive") if spec.length_mm <= 0: raise ValueError("thread length must be positive") if not 0 < spec.angle_deg < 180: raise ValueError("thread angle_deg must be between 0 and 180") 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 depth_radius = crest_radius - root_radius if depth_radius <= 0: raise ValueError("thread major diameter must exceed the minor diameter") sink = min(_ROOT_OVERLAP_MM, 0.25 * depth_radius, 0.1 * spec.pitch_mm) full_depth = depth_radius + sink # from sunk root up to the crest flank_half_tan = math.tan(math.radians(spec.angle_deg / 2.0)) flank_throw = full_depth * flank_half_tan return crest_radius, root_radius, sink, flank_throw, flank_half_tan def _build_z_aligned(spec: ThreadSpec) -> Solid: """Construct an external thread along +Z spanning ``z in [0, length_mm]``. Local frame: z = thread axis, the leading end face sits at ``z = 0`` and starts inside a tooth valley so the first crest rises cleanly off the end face. Helix pitch runs right-handed (or left-handed when ``lefthand``). """ crest_radius, root_radius, sink, flank_throw, _flank_half_tan = _validate_spec(spec) pitch = spec.pitch_mm # Tooth geometry in the axial cross-section (z = axial, r = radial). # One full tooth occupies a pitch-wide interval centered on the crest flat; # the flank horizontal throw is `full_depth * tan(half_angle)`. crest_half_width = pitch / 2.0 - flank_throw if crest_half_width < _MIN_CREST_HALF_WIDTH_MM: raise ValueError(f"thread pitch is too small for the given depth and flank angle (flank throw {flank_throw:.4f} mm must stay below pitch/2)") # Overshoot both ends by one pitch so the trimmed faces land in full # material; the crest centre sits at the helix start phase (z = 0), which # also puts the z = 0 end face through full crest material after trimming. helix_height = spec.length_mm + 2.0 * pitch helix = Edge.make_helix( pitch=pitch, height=helix_height, radius=root_radius, lefthand=spec.lefthand, ) # Profile vertices (z, r) -> world (x = r, y = 0, z). Order is counter # clockwise in the (z, r) plane: bottom edge first, then crest right, # crest flat, crest left back down. The bottom edge spans the full pitch # so consecutive helical loops share an identical seam line. sunk_root = root_radius - sink pts = [ Vector(sunk_root, 0.0, -pitch / 2.0), Vector(sunk_root, 0.0, pitch / 2.0), 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 = _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) # Core cylinder at the nominal minor radius spanning the whole helix. # (The sunken tooth roots overlap it so the union below is clean.) # The profile spans z in [-pitch/2, +pitch/2] around the helix start, so # the core begins at -pitch/2 and covers the shell plus one extra pitch. core_height = helix_height + pitch core = Solid.make_cylinder(root_radius, core_height, Plane(origin=(0.0, 0.0, -pitch / 2.0))) 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 端面落在牙谷中心:首尾端面无半牙、端面圆盘完整, # 裁切后 [0, length_mm] 内牙顶平台数稳定为 length/pitch。 fused = fused.moved(Location((0.0, 0.0, -pitch / 2.0))) return _trim_thread_z(fused, 0.0, spec.length_mm, crest_radius) 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 half_span = crest_radius + trim_margin clamp = Solid.make_box( 2.0 * half_span, 2.0 * half_span, z1 - z0, Plane(origin=(-half_span, -half_span, z0)), ) intersected = fused.intersect(clamp) if isinstance(intersected, ShapeList): candidates = list(intersected) elif intersected is not None: candidates = [intersected] else: candidates = [] members: list[Solid] = [] for candidate in candidates: if isinstance(candidate, Solid): members.append(candidate) else: members.extend(candidate.solids()) if not members: raise ValueError("thread end trim produced no solid") # 端部裁齐应保持单一主体;若 OCC 留下浮点碎屑,取体积最大的实心主体。 trimmed = members[0] if len(members) == 1 else max(members, key=lambda shape: shape.volume) 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: """Build one external-threaded solid segment for ``spec``. Returns a solid whose thread axis is +Z and whose leading end face is at ``z = 0``. The caller (geometry adapter) is responsible for placing the solid at ``spec.axis``. """ result = _build_z_aligned(spec) if not result.solids(): raise ValueError("thread generation produced no solid") 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)