boltgen: real helical thread via spline-helix sweep; compound bolt assembly
- _external_thread: spline-helix sweep with makeSolid=True, n capped at 24 - hexagon_head_bolt: shaft+thread+head via Compound.makeCompound (union ~8s/bolt) - removed dead duplicate _bolt_thread function - generate_bolt: unwrap Workplane result correctly - thread is visual overlay (sweep invalid in CadQuery 2.7, STEP volume correct)
This commit is contained in:
1 parent
715ed60f20
commit
93d18a15a5
2 files changed
+91
-38
No files matched your search
+15
-1
@@ -27,10 +27,12 @@ from .standard import (
|
||||
BoltSpec,
|
||||
NutSpec,
|
||||
WasherSpec,
|
||||
StudSpec,
|
||||
SelfTackerSpec,
|
||||
model_filename,
|
||||
product_params,
|
||||
)
|
||||
from .model import generate_bolt, generate_nut, generate_washer
|
||||
from .model import generate_bolt, generate_nut, generate_washer, generate_stud, generate_self_tacker
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -146,6 +148,18 @@ class PortableCatalog:
|
||||
generate_washer(spec, path)
|
||||
return rec
|
||||
|
||||
def add_stud(self, spec: StudSpec) -> ProductRecord:
|
||||
rec = self.add(spec)
|
||||
path = os.path.join(self.models_dir, rec.model_file)
|
||||
generate_stud(spec, path)
|
||||
return rec
|
||||
|
||||
def add_self_tacker(self, spec: SelfTackerSpec) -> ProductRecord:
|
||||
rec = self.add(spec)
|
||||
path = os.path.join(self.models_dir, rec.model_file)
|
||||
generate_self_tacker(spec, path)
|
||||
return rec
|
||||
|
||||
def add_all(self) -> List[ProductRecord]:
|
||||
"""Сгенерировать всё семейство (34 болта + 7 гаек + 7 шайб = 48)."""
|
||||
out = []
|
||||
|
||||
+76
-37
@@ -6,6 +6,7 @@ Self-contained — без КОМПАСА и других CAD-систем. Ге
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
|
||||
# cadquery — опциональная зависимость. Геометрические генераторы импортируют его
|
||||
@@ -13,8 +14,10 @@ import os
|
||||
# (например, standalone-тесты спецификаций на хостах без cadquery).
|
||||
try:
|
||||
import cadquery as cq # noqa: F401
|
||||
from cadquery import Vector, Edge, Wire, Plane
|
||||
except ImportError: # pragma: no cover - зависит от окружения
|
||||
cq = None
|
||||
Vector = Edge = Wire = Plane = None
|
||||
|
||||
from .standard import BoltSpec, NutSpec, WasherSpec, StudSpec, SelfTackerSpec, BoltSetSpec
|
||||
|
||||
@@ -22,25 +25,68 @@ from .standard import BoltSpec, NutSpec, WasherSpec, StudSpec, SelfTackerSpec, B
|
||||
# =====Болты ГОСТ 7805-70 (шестигранная головка) ======
|
||||
|
||||
|
||||
def _helix_path(r_c: float, length: float, pitch: float, n: int) -> Edge:
|
||||
"""Винтовая линия (спираль) radius r_c со шагом pitch и длиной length.
|
||||
|
||||
Строится как сплайн через точки геликоидной спирали (n секментов). Используется
|
||||
как путь для sweep-профаля резьбы.
|
||||
"""
|
||||
pts = []
|
||||
turns = length / pitch
|
||||
for i in range(n + 1):
|
||||
a = 2 * math.pi * (i / n) * turns
|
||||
pts.append(Vector(r_c * math.cos(a), r_c * math.sin(a), length * (i / n)))
|
||||
return Edge.makeSpline(pts)
|
||||
|
||||
|
||||
def _external_thread(spec_d: float, pitch: float, length: int) -> cq.CQ:
|
||||
"""Винтовая нарезка на стержне болта (упрощённая геометрическая спираль).
|
||||
"""Винтовая нарезка на стержне болта — реальный CAD-thread через sweep профиля.
|
||||
|
||||
Завитки нарезаются на свободной части стержня (длина - h_thread). Геометрически
|
||||
это наружная поверхность: основной диаметр d minus глубина резьбы + закрученный
|
||||
«набросок» профиля. Не параметрический CAD-thread, но визуально читаемость резьбы
|
||||
в STEP/просмотре.
|
||||
Профиль метрической резьбы (трапеция, угол фланга 30°, глубина ~0.65·pitch)
|
||||
«накручивается» по геликоидной спирали через sweep (makeSolid=True). Стержень
|
||||
диаметром spec_d (по вершине резьбы) остаётся целым; резьба добавляется как
|
||||
объём витков сверху (z в [length-threaded_len, length]).
|
||||
|
||||
Возвращает стержень диаметром spec_d (по вершине резьбы) длиной length. Резьба
|
||||
занимает длину ~2d+6 сверху (z в [length-threaded_len, length]).
|
||||
Sweep-профиль даёт Solid, который CadQuery 2.7.0 помечает invalid (известная
|
||||
проблема makeSolid в OCC 7.8.x), но STEP-экспорт и чтение объёма через
|
||||
BRepGProp корректны. Резьба добавляется к стержню через Compound.
|
||||
"""
|
||||
threaded_len = min(2 * spec_d + 6, length)
|
||||
# Глубина резьбы: до 0.625*pitch (профиль метрической резьбы)
|
||||
thr_d = spec_d - 0.65 * pitch
|
||||
# Стержень с резьбой: цилиндр основного диаметра + закрученный стержень меньшего диаметра
|
||||
shaft = cq.Workplane("XY").circle(spec_d / 2.0).extrude(length)
|
||||
# Резьбовая часть (вдоль +Z сверху, z в [length-threaded_len, length])
|
||||
rod = cq.Workplane("XY").circle(thr_d / 2.0).extrude(threaded_len)
|
||||
return shaft.union(rod.translate((0, 0, length - threaded_len)))
|
||||
root_d = spec_d - 0.65 * pitch
|
||||
r_c = (root_d / 2.0 + spec_d / 2.0) / 2.0
|
||||
thread_h = spec_d / 2.0 - root_d / 2.0
|
||||
# Число секментов спирали. PipeShell::MakeSolid ненадёжен при n>~30 (особенно
|
||||
# на крупных резьбах) — n=24 работает по всем размерам и даёт лучший профиль.
|
||||
def _sweep(n):
|
||||
path = _helix_path(r_c, threaded_len, pitch, n)
|
||||
tang = path.tangentAt(0.0)
|
||||
pnt = path.startPoint()
|
||||
# Локальные оси в старте спирали: +X — радиально наружу, нормаль — вдоль фланга
|
||||
radial = Vector(1.0, 0.0, 0.0)
|
||||
binorm = Vector(tang.y * radial.z - tang.z * radial.y,
|
||||
tang.z * radial.x - tang.x * radial.z,
|
||||
tang.x * radial.y - tang.y * radial.x)
|
||||
pl = Plane(origin=pnt, xDir=radial, normal=binorm)
|
||||
|
||||
half_flank = thread_h * math.tan(math.radians(30))
|
||||
prof_pts_xy = [(0.0, 0.0), (half_flank, thread_h),
|
||||
(thread_h - half_flank, thread_h), (thread_h, 0.0)]
|
||||
wire_pts = [Vector(pnt.x + u * radial.x + v * binorm.x,
|
||||
pnt.y + u * radial.y + v * binorm.y,
|
||||
pnt.z + u * radial.z + v * binorm.z)
|
||||
for u, v in prof_pts_xy + [prof_pts_xy[0]]]
|
||||
wire = Wire.makePolygon(wire_pts, True, True)
|
||||
sweep_wp = cq.Workplane(pl)
|
||||
sweep_wp.ctx.pendingWires = [wire]
|
||||
return sweep_wp.sweep(path, makeSolid=True)
|
||||
|
||||
try:
|
||||
thread = _sweep(24)
|
||||
except Exception:
|
||||
thread = _sweep(16)
|
||||
# Резьба — в верхней части стержня (z в [length-threaded_len, length])
|
||||
thread = thread.translate((0.0, 0.0, length - threaded_len))
|
||||
return thread
|
||||
|
||||
|
||||
def _bolt_thread(spec: BoltSpec) -> cq.CQ:
|
||||
@@ -55,24 +101,6 @@ def _bolt_thread(spec: BoltSpec) -> cq.CQ:
|
||||
threaded_len = min(2 * d + 6, spec.length)
|
||||
|
||||
return _external_thread(d, pitch, spec.length)
|
||||
# Канавки — в верхней части (threaded_len), сдвигаем вверх по +Z.
|
||||
return shaft.cut(notch.translate((0, 0, length - threaded_len)))
|
||||
|
||||
|
||||
def _bolt_thread(spec: BoltSpec) -> cq.CQ:
|
||||
"""Стержень болта с внешней метрической резьбой на свободной части.
|
||||
|
||||
Резьба занимает длину ~2d+6 сверху стержня (z в [length-threaded_len, length]),
|
||||
ниже — гладкий стержень диаметром d.
|
||||
"""
|
||||
b = _BOLT_PARAMS[spec.size]
|
||||
d = float(spec.size[1:])
|
||||
pitch = b["P"]
|
||||
threaded_len = min(2 * d + 6, spec.length)
|
||||
|
||||
# _external_thread уже возвращает полный стержень диаметром d (по вершине резьбы)
|
||||
# длиной spec.length с резьбой в верхней части (z в [length-threaded_len, length]).
|
||||
return _external_thread(d, pitch, spec.length)
|
||||
|
||||
|
||||
def hexagon_head_bolt(spec: BoltSpec) -> cq.CQ:
|
||||
@@ -87,12 +115,18 @@ def hexagon_head_bolt(spec: BoltSpec) -> cq.CQ:
|
||||
head_h = b["k"]
|
||||
head_r = b["S"] / 2.0 # диаметр описанной окружности шестигранника
|
||||
|
||||
# Стержень с резьбой
|
||||
shaft = _bolt_thread(spec)
|
||||
# Sweep-резьба (solid — может быть invalid для CadQuery, но STEP-обём читается корректно)
|
||||
thread = _bolt_thread(spec)
|
||||
# Целый гладкий стержень диаметром d
|
||||
shaft = cq.Workplane("XY").circle(d / 2.0).extrude(length)
|
||||
# Головка — шестигранник (true hexagon) по размеру под ключ
|
||||
head = cq.Workplane("XY").polygon(6, head_r).extrude(head_h)
|
||||
|
||||
bolt = shaft.union(head.translate((0, 0, head_h)))
|
||||
# Compound из трёх solid: sweep-резьба лежит на вершине стержня
|
||||
# (z в [length-threaded_len, length]). union() двух solidов с sweep-резьбой
|
||||
# стоит ~8 с в этой сборке — не приемлемо для gen-all; Compound мгновенен,
|
||||
# а STEP-обём (BRepGProp) складывает объёмы корректно.
|
||||
bolt = cq.Compound.makeCompound([thread.val(), shaft.val(), head.translate((0, 0, head_h)).val()])
|
||||
return bolt
|
||||
|
||||
|
||||
@@ -111,8 +145,13 @@ _BOLT_PARAMS = {
|
||||
|
||||
def generate_bolt(spec: BoltSpec, out_path: str, tolerance: float = 0.1) -> str:
|
||||
"""Сгенерировать болт в STEP. Возвращает путь к файлу."""
|
||||
solid = hexagon_head_bolt(spec).val()
|
||||
_export_step(solid, out_path, tolerance)
|
||||
bolt = hexagon_head_bolt(spec)
|
||||
# bolt — Workplane; .val() возвращает native Solid.cq.exporters.export
|
||||
# принимает cq.Shape, поэтому оборачиваем TopoDS_Compound/cq.Shape.
|
||||
shape = bolt.val() if hasattr(bolt, "val") else bolt
|
||||
if not isinstance(shape, cq.Shape):
|
||||
shape = cq.Shape(shape)
|
||||
_export_step(shape, out_path, tolerance)
|
||||
return out_path
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user