diff --git a/backend/engine/cdsl_engine/parametric_thread.py b/backend/engine/cdsl_engine/parametric_thread.py new file mode 100644 index 00000000..75880e91 --- /dev/null +++ b/backend/engine/cdsl_engine/parametric_thread.py @@ -0,0 +1,166 @@ +"""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). + +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 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 + + +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: + raise ValueError("internal threads are not implemented yet (thread_cut)") + 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") + + 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))]) + 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) + + # 平移半个牙距,使 z = 0 端面落在牙谷中心:首尾端面无半牙、端面圆盘完整, + # 裁切后 [0, length_mm] 内牙顶平台数稳定为 length/pitch。 + fused = fused.moved(Location((0.0, 0.0, -pitch / 2.0))) + + # Trim both ends flush to z in [0, length_mm] with an oversized box. + trim_margin = 2.0 + half_span = crest_radius + trim_margin + clamp = Solid.make_box( + 2.0 * half_span, + 2.0 * half_span, + spec.length_mm, + Plane(origin=(-half_span, -half_span, 0.0)), + ) + 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_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