diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 24d1a37d..ea2c2440 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -26,18 +26,22 @@ _PRIMARY_ATOMICS = frozenset({ _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) _ACTIVE_BODY_REQUIRED = frozenset({ "extrude_cut_blind", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", + # thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空 + # 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。 + "thread_cut", }) _BODY_MUTATING_ATOMICS = frozenset({ "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", }) # 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 -# excluded: pattern translation does not yet move its parametric axis, so a -# replayed thread would silently re-fuse at the original location. +# Context-only features have no geometry definition to instance. thread_add +# and thread_cut are excluded: pattern translation does not yet move their +# parametric axis, so a replayed thread would silently re-run at the original +# location. _REPLAYABLE_ATOMICS = ( - _BODY_MUTATING_ATOMICS - frozenset({"thread_add"}) + _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"}) ) | frozenset({"pattern_linear", "pattern_mirror"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", @@ -433,6 +437,9 @@ class CapabilityAnalyzer: body_producers = { "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "sphere_add", "thread_add", + # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 + # active_body 前置阻止,文档含该类特征即视为携带可执行几何。 + "thread_cut", } document_blockers: list[RuntimeDiagnostic] = [] if not any(node.atomic_id in body_producers for node in plan): diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 9bcd536b..312c37c4 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -126,7 +126,26 @@ "length_mm": {"$ref": "#/$defs/positive"}, "axis": {"$ref": "#/$defs/axis"}, "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"], "additionalProperties": false @@ -332,7 +351,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "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": { "type": "object", "properties": { @@ -356,6 +375,7 @@ {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "thread_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadAddParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "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_countersink"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCountersinkParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_counterbore"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeCounterboreParams"}}}}, diff --git a/backend/engine/cdsl_engine/parametric_thread.py b/backend/engine/cdsl_engine/parametric_thread.py new file mode 100644 index 00000000..c2e12f6d --- /dev/null +++ b/backend/engine/cdsl_engine/parametric_thread.py @@ -0,0 +1,294 @@ +"""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) diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 406d8383..99c8b2d2 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -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_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, + "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_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": { diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index e2dc0e29..fd249122 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -24,7 +24,7 @@ ALL_ATOMIC_IDS = frozenset({ "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", - "thread_add", + "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: - # 螺纹不需要草图平面,丢弃该参数后执行。 + # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 + # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 del sketch + if node.atomic_id == "thread_cut": + return _execute_thread_cut(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: if resolution.record is None: 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, "sphere_add": _sphere_executor, "thread_add": _thread_executor, + "thread_cut": _thread_executor, "extrude_add_blind": _primary_executor, "extrude_add_two_sided": _primary_executor, "extrude_cut_blind": _primary_executor, diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index 48557a67..a43eb976 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -295,6 +295,9 @@ class ThreadSpec: angle_deg: float = 60.0 internal: bool = False lefthand: bool = False + relief_length_mm: float = 0.0 + crest_radius_mm: float = 0.0 + root_radius_mm: float = 0.0 @classmethod 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 if not 0 < angle < 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( major_diameter_mm=major, minor_diameter_mm=minor, @@ -328,8 +347,11 @@ class ThreadSpec: length_mm=length, axis=AxisSpec.from_mapping(raw_axis), angle_deg=angle, - internal=bool(params.get("internal", False)), + internal=internal, lefthand=bool(params.get("lefthand", False)), + relief_length_mm=relief, + crest_radius_mm=crest_r, + root_radius_mm=root_r, )