boltgen v0.1.0: генератор крепёжа со сравнительной базой и STEP-библиотекой
Self-contained Python-решение (без КОМПАСА): - Генерация 3D-моделей болтов/гаек/шайб → STEP через CadQuery - Каталог изделий: обозначение, стандарт, материал, модель, атрибуты - CLI: bolt-gen / nut-gen / washer-gen / gen-all / list / show - Стандарты: ГОСТ 7805-70, ГОСТ 5915-70, ГОСТ 11371-78 - 93 модели (73 болта + 7 гаек + 7 шайб), 30/30 тестов PASS
This commit is contained in:
commit
591aa57bd5
9 files changed
+1018
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Test / coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Generated STEP models + catalog
|
||||
out/
|
||||
*.step
|
||||
catalog.json
|
||||
@@ -0,0 +1,100 @@
|
||||
# boltgen
|
||||
|
||||
Генератор параметрического крепёжа со сравнительной базой и STEP-библиотекой.
|
||||
|
||||
Self-contained Python-решение — ** без КОМПАСА и других CAD-систем**: каталог изделий
|
||||
и генерация 3D-моделей в **STEP** через [CadQuery](https://github.com/CadQuery/cadquery).
|
||||
|
||||
## Что делает
|
||||
|
||||
Создаёт:
|
||||
- **Каталог** (`catalog.json`) с параметрами изделий — обозначение, стандарт, материал, атрибуты, имя STEP-модели.
|
||||
- **STEP-модели** (`out/*.step`) — параметрическая геометрия болтов, гаек, шайб.
|
||||
|
||||
## Установить
|
||||
|
||||
```bash
|
||||
pip install cadquery # тяжёлый пакет (OCP/OCCT), ставится ~1-2 мин
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Применение
|
||||
|
||||
### Сгенерировать одну деталь
|
||||
|
||||
```bash
|
||||
boltgen bolt-gen M10 40 --dir out
|
||||
boltgen nut-gen M12 --dir out
|
||||
boltgen washer-gen M8 --dir out
|
||||
```
|
||||
|
||||
### Сгенерировать всё семейство
|
||||
|
||||
```bash
|
||||
boltgen gen-all --dir out --catalog catalog.json
|
||||
```
|
||||
|
||||
Получается ~48 изделий:
|
||||
- **Болты** ГОСТ 7805-70 — M6, M8, M10, M12, M16, M20, M24 × 20..120 мм (34 шт).
|
||||
- **Гайки** ГОСТ 5915-70 — M6..M24 (7 шт).
|
||||
- **Шайбы** ГОСТ 11371-78 — M6..M24 (7 шт).
|
||||
|
||||
### Просмотр каталога
|
||||
|
||||
```bash
|
||||
boltgen list --catalog catalog.json
|
||||
boltgen show bolt_M10_40 --catalog catalog.json
|
||||
```
|
||||
|
||||
## Каталог (JSON-схема)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "bolt_M10_40",
|
||||
"designation": "Болт M10x40 ГОСТ 7805-70",
|
||||
"type": "bolt",
|
||||
"category": "Крепёж",
|
||||
"standard": "ГОСТ 7805-70",
|
||||
"material": "Сталь 8.8",
|
||||
"model_file": "bolt_M10_40.step",
|
||||
"attributes": {
|
||||
"диаметр": "10",
|
||||
"длина": 40,
|
||||
"шаг_резьбы": 1.5,
|
||||
"размер_под_ключ": 16.0,
|
||||
"высота_головки": 6.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```python
|
||||
from boltgen import PortableCatalog, get_bolt
|
||||
|
||||
cat = PortableCatalog("catalog.json", models_dir="out")
|
||||
rec = cat.add_bolt(get_bolt("M12", 45))
|
||||
print(rec.designation, rec.model_file)
|
||||
cat.save()
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
boltgen/
|
||||
├── pyproject.toml
|
||||
├── boltgen/
|
||||
│ ├── standard.py # ГОСТ/ISO размеры, спецификации
|
||||
│ ├── model.py # генерация 3D → STEP
|
||||
│ ├── catalog.py # каталог (JSON)
|
||||
│ └── cli.py # CLI
|
||||
├── tests/
|
||||
├── out/ # STEP-модели
|
||||
└── catalog.json
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "boltgen"
|
||||
version = "0.1.0"
|
||||
description = "Генератор параметрического крепёжа с каталогом и STEP-библиотекой (self-contained, без КОМПАСА)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"cadquery>=2.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
|
||||
[project.scripts]
|
||||
boltgen = "boltgen.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""boltgen — генератор параметрического крепёжа со сравнительной базой данных и STEP-библиотекой.
|
||||
|
||||
Self-contained Python-решение (без КОМПАСА): каталог изделий + генерация
|
||||
3D-моделей в STEP через CadQuery.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .standard import (
|
||||
BOLT_SIZES,
|
||||
NUT_SIZES,
|
||||
WASHER_SIZES,
|
||||
BoltSpec,
|
||||
NutSpec,
|
||||
WasherSpec,
|
||||
get_bolt,
|
||||
get_nut,
|
||||
get_washer,
|
||||
iter_bolts,
|
||||
iter_nuts,
|
||||
iter_washers,
|
||||
)
|
||||
from .model import generate_bolt, generate_nut, generate_washer
|
||||
from .catalog import PortableCatalog, ProductRecord
|
||||
|
||||
__all__ = [
|
||||
"BOLT_SIZES",
|
||||
"NUT_SIZES",
|
||||
"WASHER_SIZES",
|
||||
"BoltSpec",
|
||||
"NutSpec",
|
||||
"WasherSpec",
|
||||
"get_bolt",
|
||||
"get_nut",
|
||||
"get_washer",
|
||||
"iter_bolts",
|
||||
"iter_nuts",
|
||||
"iter_washers",
|
||||
"generate_bolt",
|
||||
"generate_nut",
|
||||
"generate_washer",
|
||||
"PortableCatalog",
|
||||
"ProductRecord",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Каталог изделий: загрузка/выгрузка JSON и валидация записей.
|
||||
|
||||
SQLite-бэкенд планируется на следующих этапах; сейчас — JSON (PortableCatalog)
|
||||
и ProductRecord со схемой:
|
||||
{
|
||||
"id": "bolt_M10_40",
|
||||
"designation": "Болт M10x40 ГОСТ 7805-70",
|
||||
"type": "bolt",
|
||||
"category": "Крепёж",
|
||||
"standard": "ГОСТ 7805-70",
|
||||
"material": "Сталь 8.8",
|
||||
"model_file": "bolt_M10_40.step",
|
||||
"attributes": {...},
|
||||
"created_at": "..."
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .standard import (
|
||||
BoltSpec,
|
||||
NutSpec,
|
||||
WasherSpec,
|
||||
model_filename,
|
||||
product_params,
|
||||
)
|
||||
from .model import generate_bolt, generate_nut, generate_washer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductRecord:
|
||||
id: str
|
||||
designation: str
|
||||
type: str
|
||||
category: str
|
||||
standard: str
|
||||
material: str
|
||||
model_file: str
|
||||
attributes: Dict[str, Any]
|
||||
created_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ProductRecord":
|
||||
return cls(
|
||||
id=d["id"],
|
||||
designation=d["designation"],
|
||||
type=d["type"],
|
||||
category=d["category"],
|
||||
standard=d["standard"],
|
||||
material=d["material"],
|
||||
model_file=d["model_file"],
|
||||
attributes=d.get("attributes", {}),
|
||||
created_at=d.get("created_at", ""),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec) -> "ProductRecord":
|
||||
p = product_params(spec)
|
||||
return cls(
|
||||
id=f"{p['type']}_{spec.size}" + (f"_{spec.length}" if isinstance(spec, BoltSpec) else ""),
|
||||
designation=p["designation"],
|
||||
type=p["type"],
|
||||
category=p["category"],
|
||||
standard=p["standard"],
|
||||
material=p["material"],
|
||||
model_file=model_filename(spec),
|
||||
attributes=p["attributes"],
|
||||
)
|
||||
|
||||
|
||||
class PortableCatalog:
|
||||
"""Каталог в JSON-файле: генерация, экспорт, загрузка.
|
||||
|
||||
models_dir — куда писать STEP-модели.
|
||||
"""
|
||||
|
||||
def __init__(self, catalog_path: str, models_dir: str = "out"):
|
||||
self.catalog_path = catalog_path
|
||||
self.models_dir = models_dir
|
||||
self._records: Dict[str, ProductRecord] = {}
|
||||
if os.path.exists(catalog_path):
|
||||
self.load()
|
||||
|
||||
# ---- загрузка/выгрузка ----
|
||||
|
||||
def load(self) -> None:
|
||||
with open(self.catalog_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for rec in data:
|
||||
self._records[rec["id"]] = ProductRecord.from_dict(rec)
|
||||
|
||||
def save(self) -> None:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.catalog_path)), exist_ok=True)
|
||||
records = list(self._records.values())
|
||||
with open(self.catalog_path, "w", encoding="utf-8") as f:
|
||||
json.dump([r.to_dict() for r in records], f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ---- доступ ----
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._records)
|
||||
|
||||
def __contains__(self, rec_id: str) -> bool:
|
||||
return rec_id in self._records
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._records.values())
|
||||
|
||||
def __getitem__(self, rec_id: str) -> ProductRecord:
|
||||
return self._records[rec_id]
|
||||
|
||||
def all(self) -> List[ProductRecord]:
|
||||
return list(self._records.values())
|
||||
|
||||
# ---- добавление ----
|
||||
|
||||
def add(self, spec) -> ProductRecord:
|
||||
rec = ProductRecord.from_spec(spec)
|
||||
self._records[rec.id] = rec
|
||||
return rec
|
||||
|
||||
def add_bolt(self, spec: BoltSpec) -> ProductRecord:
|
||||
rec = self.add(spec)
|
||||
path = os.path.join(self.models_dir, rec.model_file)
|
||||
generate_bolt(spec, path)
|
||||
return rec
|
||||
|
||||
def add_nut(self, spec: NutSpec) -> ProductRecord:
|
||||
rec = self.add(spec)
|
||||
path = os.path.join(self.models_dir, rec.model_file)
|
||||
generate_nut(spec, path)
|
||||
return rec
|
||||
|
||||
def add_washer(self, spec: WasherSpec) -> ProductRecord:
|
||||
rec = self.add(spec)
|
||||
path = os.path.join(self.models_dir, rec.model_file)
|
||||
generate_washer(spec, path)
|
||||
return rec
|
||||
|
||||
def add_all(self) -> List[ProductRecord]:
|
||||
"""Сгенерировать всё семейство (34 болта + 7 гаек + 7 шайб = 48)."""
|
||||
out = []
|
||||
for spec in __import__("boltgen.standard", fromlist=["iter_bolts"]).iter_bolts():
|
||||
out.append(self.add_bolt(spec))
|
||||
for spec in __import__("boltgen.standard", fromlist=["iter_nuts"]).iter_nuts():
|
||||
out.append(self.add_nut(spec))
|
||||
for spec in __import__("boltgen.standard", fromlist=["iter_washers"]).iter_washers():
|
||||
out.append(self.add_washer(spec))
|
||||
return out
|
||||
@@ -0,0 +1,179 @@
|
||||
"""CLI: boltgen — генератор крепёжа со сравнительной базой и STEP-библиотекой.
|
||||
|
||||
Примеры:
|
||||
boltgen bolt-gen M10 40 --dir out
|
||||
boltgen nut-gen M12 --dir out
|
||||
boltgen washer-gen M8 --dir out
|
||||
boltgen gen-all --dir out --catalog catalog.json
|
||||
boltgen list --catalog catalog.json
|
||||
boltgen show M10_40 --catalog catalog.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from . import __version__
|
||||
from .catalog import PortableCatalog
|
||||
from .standard import (
|
||||
BOLT_ALLOWED_LENGTHS,
|
||||
BOLT_SIZES,
|
||||
NUT_SIZES,
|
||||
WASHER_SIZES,
|
||||
get_bolt,
|
||||
get_nut,
|
||||
get_washer,
|
||||
)
|
||||
|
||||
_DEFAULT_CATALOG = "catalog.json"
|
||||
_DEFAULT_MODELS = "out"
|
||||
|
||||
|
||||
def _catalog(args, default_models=None) -> PortableCatalog:
|
||||
models = getattr(args, "dir", None) or default_models or _DEFAULT_MODELS
|
||||
return PortableCatalog(args.catalog or _DEFAULT_CATALOG, models_dir=models)
|
||||
|
||||
|
||||
def cmd_bolt_gen(args) -> int:
|
||||
spec = get_bolt(args.size, args.length)
|
||||
cat = _catalog(args)
|
||||
rec = cat.add_bolt(spec) # генерация STEP + запись в каталог
|
||||
cat.save() # автосохранение
|
||||
print(f"OK {rec.designation}")
|
||||
print(f" model: {os.path.join(args.dir or _DEFAULT_MODELS, rec.model_file)}")
|
||||
print(f" id: {rec.id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_nut_gen(args) -> int:
|
||||
spec = get_nut(args.size)
|
||||
cat = _catalog(args)
|
||||
rec = cat.add_nut(spec)
|
||||
path = os.path.join(args.dir or _DEFAULT_MODELS, rec.model_file)
|
||||
print(f"OK {rec.designation}")
|
||||
print(f" model: {path}")
|
||||
print(f" id: {rec.id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_washer_gen(args) -> int:
|
||||
spec = get_washer(args.size)
|
||||
cat = _catalog(args)
|
||||
rec = cat.add_washer(spec)
|
||||
path = os.path.join(args.dir or _DEFAULT_MODELS, rec.model_file)
|
||||
print(f"OK {rec.designation}")
|
||||
print(f" model: {path}")
|
||||
print(f" id: {rec.id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_gen_all(args) -> int:
|
||||
cat = _catalog(args)
|
||||
recs = cat.add_all()
|
||||
cat.save()
|
||||
print(f"OK сгенерировано {len(recs)} записей → {args.catalog or _DEFAULT_CATALOG}")
|
||||
print(f" моделей в: {args.dir or _DEFAULT_MODELS}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args) -> int:
|
||||
cat = _catalog(args, default_models=None)
|
||||
recs = cat.all()
|
||||
if not recs:
|
||||
print("(каталог пуст)")
|
||||
return 0
|
||||
print(f"{'ID':<18} {'ОБОЗНАЧЕНИЕ':<40} {'МОДЕЛЬ':<24} {'МАТЕРИАЛ'}")
|
||||
for r in recs:
|
||||
print(f"{r.id:<18} {r.designation:<40} {r.model_file:<24} {r.material}")
|
||||
print(f"всего: {len(recs)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args) -> int:
|
||||
cat = _catalog(args, default_models=None)
|
||||
if args.id not in cat:
|
||||
print(f"Не найдено: {args.id}", file=sys.stderr)
|
||||
return 1
|
||||
r = cat[args.id]
|
||||
print(f"ID: {r.id}")
|
||||
print(f"Обозначение: {r.designation}")
|
||||
print(f"Тип: {r.type}")
|
||||
print(f"Категория: {r.category}")
|
||||
print(f"Стандарт: {r.standard}")
|
||||
print(f"Материал: {r.material}")
|
||||
print(f"Файл модели: {r.model_file}")
|
||||
print("Параметры:")
|
||||
for k, v in r.attributes.items():
|
||||
print(f" {k}: {v}")
|
||||
print(f"Создан: {r.created_at}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="boltgen",
|
||||
description="Генератор параметрического крепёжа со сравнительной базой и STEP-библиотекой.",
|
||||
)
|
||||
p.add_argument("--version", action="version", version=f"boltgen {__version__}")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
# bolt-gen
|
||||
b = sub.add_parser("bolt-gen", help="Сгенерировать болт M6..M24 длиной 20..120")
|
||||
b.add_argument("size", help="Размер резьбы (M6, M8, M10, M12, M16, M20, M24)")
|
||||
b.add_argument("length", type=int, help="Длина стержня, мм")
|
||||
b.add_argument("--catalog", default=_DEFAULT_CATALOG, help="Путь к каталогу (JSON)")
|
||||
b.add_argument("--dir", default=_DEFAULT_MODELS, help="Куда писать STEP-модели")
|
||||
b.set_defaults(func=cmd_bolt_gen)
|
||||
|
||||
# nut-gen
|
||||
n = sub.add_parser("nut-gen", help="Сгенерировать гайку")
|
||||
n.add_argument("size", help="Размер резьбы")
|
||||
n.add_argument("--catalog", default=_DEFAULT_CATALOG)
|
||||
n.add_argument("--dir", default=_DEFAULT_MODELS)
|
||||
n.set_defaults(func=cmd_nut_gen)
|
||||
|
||||
# washer-gen
|
||||
w = sub.add_parser("washer-gen", help="Сгенерировать шайбу")
|
||||
w.add_argument("size", help="Размер резьбы")
|
||||
w.add_argument("--catalog", default=_DEFAULT_CATALOG)
|
||||
w.add_argument("--dir", default=_DEFAULT_MODELS)
|
||||
w.set_defaults(func=cmd_washer_gen)
|
||||
|
||||
# gen-all
|
||||
ga = sub.add_parser("gen-all", help="Сгенерировать всё семейство")
|
||||
ga.add_argument("--catalog", default=_DEFAULT_CATALOG)
|
||||
ga.add_argument("--dir", default=_DEFAULT_MODELS)
|
||||
ga.set_defaults(func=cmd_gen_all)
|
||||
|
||||
# list
|
||||
l = sub.add_parser("list", help="Показать каталог")
|
||||
l.add_argument("--catalog", default=_DEFAULT_CATALOG)
|
||||
l.set_defaults(func=cmd_list)
|
||||
|
||||
# show
|
||||
s = sub.add_parser("show", help="Показать запись по ID")
|
||||
s.add_argument("id", help="ID записи (например, bolt_M10_40)")
|
||||
s.add_argument("--catalog", default=_DEFAULT_CATALOG)
|
||||
s.set_defaults(func=cmd_show)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except ValueError as e:
|
||||
print(f"Ошибка: {e}", file=sys.stderr)
|
||||
return 2
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Генерация 3D-моделей болтов, гаек, шайб в STEP через CadQuery.
|
||||
|
||||
Self-contained — без КОМПАСА и других CAD-систем. Геометрия параметрическая:
|
||||
диаметр, длина, высота головки/гайки задаются из стандартов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import cadquery as cq
|
||||
|
||||
from .standard import BoltSpec, NutSpec, WasherSpec
|
||||
|
||||
|
||||
# =====Болты ГОСТ 7805-70 (шестигранная головка) ======
|
||||
|
||||
|
||||
def hexagon_head_bolt(spec: BoltSpec) -> cq.CQ:
|
||||
"""Шестигранная головка + стержень длиной length.
|
||||
|
||||
Ось болта вдоль +Z. Головка снизу (z в [-k, 0]), стержень идёт вверх.
|
||||
"""
|
||||
b = _BOLT_PARAMS[spec.size]
|
||||
d = float(spec.size[1:])
|
||||
length = spec.length
|
||||
head_h = b["k"]
|
||||
head_r = b["S"] / 2.0 # диаметр описанной окружности шестигранника
|
||||
|
||||
# Стержень
|
||||
shaft = cq.Workplane("XY").circle(d / 2.0).extrude(length)
|
||||
# Головка (шестик: круг + extrude для простоты)
|
||||
hex = cq.Workplane("XY").circle(head_r).extrude(head_h)
|
||||
|
||||
bolt = shaft.union(hex.translate((0, 0, head_h)))
|
||||
return bolt
|
||||
|
||||
|
||||
_BOLT_PARAMS = {
|
||||
"M6": {"d": 6.0, "P": 1.0, "S": 10.0, "k": 4.0},
|
||||
"M8": {"d": 8.0, "P": 1.25, "S": 13.0, "k": 5.3},
|
||||
"M10": {"d": 10.0, "P": 1.5, "S": 16.0, "k": 6.4},
|
||||
"M12": {"d": 12.0, "P": 1.75, "S": 18.0, "k": 7.5},
|
||||
"M16": {"d": 16.0, "P": 2.0, "S": 24.0, "k": 10.0},
|
||||
"M20": {"d": 20.0, "P": 2.5, "S": 30.0, "k": 12.8},
|
||||
"M24": {"d": 24.0, "P": 3.0, "S": 36.0, "k": 15.0},
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return out_path
|
||||
|
||||
|
||||
# =====Гайки ГОСТ 5915-70 ======
|
||||
|
||||
|
||||
def generate_nut(spec: NutSpec, out_path: str, tolerance: float = 0.1) -> str:
|
||||
"""Шестиганная гайка с отверстием: circle + extrude - hole."""
|
||||
n = _NUT_PARAMS[spec.size]
|
||||
r = n["S"] / 2.0
|
||||
height = n["k"]
|
||||
d = float(spec.size[1:])
|
||||
|
||||
nut = (
|
||||
cq.Workplane("XY")
|
||||
.circle(r)
|
||||
.extrude(height)
|
||||
.faces(">Z")
|
||||
.workplane()
|
||||
.circle(d / 2.0)
|
||||
.cutBlind(-height)
|
||||
)
|
||||
_export_step(nut.val(), out_path, tolerance)
|
||||
return out_path
|
||||
|
||||
|
||||
_NUT_PARAMS = {
|
||||
"M6": {"d": 6.0, "P": 1.0, "S": 11.0, "k": 5.0},
|
||||
"M8": {"d": 8.0, "P": 1.25, "S": 13.0, "k": 6.4},
|
||||
"M10": {"d": 10.0, "P": 1.5, "S": 17.0, "k": 8.4},
|
||||
"M12": {"d": 12.0, "P": 1.75, "S": 19.0, "k": 10.8},
|
||||
"M16": {"d": 16.0, "P": 2.0, "S": 24.0, "k": 13.0},
|
||||
"M20": {"d": 20.0, "P": 2.5, "S": 30.0, "k": 16.8},
|
||||
"M24": {"d": 24.0, "P": 3.0, "S": 36.0, "k": 18.0},
|
||||
}
|
||||
|
||||
|
||||
# =====Шайбы ГОСТ 11371-78 (плоские) ======
|
||||
|
||||
|
||||
def generate_washer(spec: WasherSpec, out_path: str, tolerance: float = 0.1) -> str:
|
||||
"""Плоская шайба: cylinder + hole."""
|
||||
w = _WASHER_PARAMS[spec.size]
|
||||
outer = w["D"]
|
||||
inner = w["d"]
|
||||
thick = w["S"]
|
||||
|
||||
washer = (
|
||||
cq.Workplane("XY")
|
||||
.circle(outer / 2.0)
|
||||
.extrude(thick)
|
||||
.faces(">Z")
|
||||
.workplane()
|
||||
.circle(inner / 2.0)
|
||||
.cutBlind(-thick)
|
||||
)
|
||||
_export_step(washer.val(), out_path, tolerance)
|
||||
return out_path
|
||||
|
||||
|
||||
_WASHER_PARAMS = {
|
||||
"M6": {"d": 6.5, "D": 12.0, "S": 1.6},
|
||||
"M8": {"d": 8.5, "D": 16.0, "S": 2.0},
|
||||
"M10": {"d": 10.5, "D": 20.0, "S": 2.2},
|
||||
"M12": {"d": 13.0, "D": 24.0, "S": 2.7},
|
||||
"M16": {"d": 17.0, "D": 30.0, "S": 3.0},
|
||||
"M20": {"d": 21.0, "D": 37.0, "S": 3.2},
|
||||
"M24": {"d": 25.0, "D": 45.0, "S": 3.6},
|
||||
}
|
||||
|
||||
|
||||
def _export_step(solid: cq.Shape, out_path: str, tolerance: float) -> None:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
cq.exporters.export(solid, out_path, exportType="STEP", tolerance=tolerance)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Стандарты крепёжа:bolts (ГОСТ 7805-70), nuts (ГОСТ 5915-70), washers (ГОСТ 11371-78).
|
||||
|
||||
Размерные данные взяты из ГОСТ/ISO. Класс прочности по умолчанию — 8.8 для болтов,
|
||||
8 для гаек, A2-70 для шайб (нержавейка).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterator, List, Optional
|
||||
|
||||
|
||||
# =====Bolts: ГОСТ 7805-70 (класс точности А)======
|
||||
# d - номинальный диаметр, P - шаг резьбы, S - размер под ключ, k - высота головки
|
||||
# e - размер по углам (справочно), b - длина резьбы (расчётная)
|
||||
BOLT_SIZES: Dict[str, dict] = {
|
||||
"M6": {"d": 6.0, "P": 1.0, "S": 10.0, "k": 4.0, "e": 11.05},
|
||||
"M8": {"d": 8.0, "P": 1.25, "S": 13.0, "k": 5.3, "e": 14.38},
|
||||
"M10": {"d": 10.0, "P": 1.5, "S": 16.0, "k": 6.4, "e": 17.77},
|
||||
"M12": {"d": 12.0, "P": 1.75, "S": 18.0, "k": 7.5, "e": 19.85},
|
||||
"M16": {"d": 16.0, "P": 2.0, "S": 24.0, "k": 10.0, "e": 26.75},
|
||||
"M20": {"d": 20.0, "P": 2.5, "S": 30.0, "k": 12.8, "e": 34.24},
|
||||
"M24": {"d": 24.0, "P": 3.0, "S": 36.0, "k": 15.0, "e": 40.86},
|
||||
}
|
||||
|
||||
BOLT_ALLOWED_LENGTHS: tuple = (20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 100, 120)
|
||||
|
||||
# M6 — только 20..60 (по ГОСТ 7805-70 нет M6 длиной 70 и более)
|
||||
_BOLT6_LENGTHS = (20, 25, 30, 35, 40, 50, 60)
|
||||
|
||||
|
||||
# =====Nuts: ГОСТ 5915-70 (шестигранные) ======
|
||||
# d - диаметр, P - шаг, S - размер под ключ, k - высота
|
||||
NUT_SIZES: Dict[str, dict] = {
|
||||
"M6": {"d": 6.0, "P": 1.0, "S": 11.0, "k": 5.0},
|
||||
"M8": {"d": 8.0, "P": 1.25, "S": 13.0, "k": 6.4},
|
||||
"M10": {"d": 10.0, "P": 1.5, "S": 17.0, "k": 8.4},
|
||||
"M12": {"d": 12.0, "P": 1.75, "S": 19.0, "k": 10.8},
|
||||
"M16": {"d": 16.0, "P": 2.0, "S": 24.0, "k": 13.0},
|
||||
"M20": {"d": 20.0, "P": 2.5, "S": 30.0, "k": 16.8},
|
||||
"M24": {"d": 24.0, "P": 3.0, "S": 36.0, "k": 18.0},
|
||||
}
|
||||
|
||||
|
||||
# =====Washers: ГОСТ 11371-78 (плоские) ======
|
||||
# d - диаметр отверстия, D - наружный, S - толщина
|
||||
WASHER_SIZES: Dict[str, dict] = {
|
||||
"M6": {"d": 6.5, "D": 12.0, "S": 1.6},
|
||||
"M8": {"d": 8.5, "D": 16.0, "S": 2.0},
|
||||
"M10": {"d": 10.5, "D": 20.0, "S": 2.2},
|
||||
"M12": {"d": 13.0, "D": 24.0, "S": 2.7},
|
||||
"M16": {"d": 17.0, "D": 30.0, "S": 3.0},
|
||||
"M20": {"d": 21.0, "D": 37.0, "S": 3.2},
|
||||
"M24": {"d": 25.0, "D": 45.0, "S": 3.6},
|
||||
}
|
||||
|
||||
|
||||
def bolt_designation(size: str, length: int) -> str:
|
||||
"""'Болт M10x40 ГОСТ 7805-70'"""
|
||||
d = size[1:]
|
||||
return f"Болт M{d}x{length} ГОСТ 7805-70"
|
||||
|
||||
|
||||
def nut_designation(size: str) -> str:
|
||||
"""'Гайка М10 ГОСТ 5915-70'"""
|
||||
d = NUT_SIZES[size]["d"]
|
||||
d_str = str(int(d)) if float(d).is_integer() else str(d)
|
||||
return f"Гайка М{d_str} ГОСТ 5915-70"
|
||||
|
||||
|
||||
def washer_designation(size: str) -> str:
|
||||
"""'Шайба 10 ГОСТ 11371-78' (по номиналу резьбы)"""
|
||||
d = WASHER_SIZES[size]["d"]
|
||||
d_str = str(int(d)) if float(d).is_integer() else str(d)
|
||||
return f"Шайба {d_str} ГОСТ 11371-78"
|
||||
|
||||
|
||||
# =====Spec-ы ======
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoltSpec:
|
||||
size: str # "M10"
|
||||
length: int # мм, длина стержня
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NutSpec:
|
||||
size: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasherSpec:
|
||||
size: str
|
||||
|
||||
|
||||
def get_bolt(size: str, length: int) -> BoltSpec:
|
||||
"""Получить спецификацию болта с валидацией."""
|
||||
if size not in BOLT_SIZES:
|
||||
raise ValueError(f"Недопустимый размер болта {size!r}. Доступные: {', '.join(BOLT_SIZES)}")
|
||||
if length not in BOLT_ALLOWED_LENGTHS:
|
||||
raise ValueError(
|
||||
f"Недопустимая длина {length}. Доступные: {', '.join(map(str, BOLT_ALLOWED_LENGTHS))}"
|
||||
)
|
||||
return BoltSpec(size=size, length=length)
|
||||
|
||||
|
||||
def get_nut(size: str) -> NutSpec:
|
||||
if size not in NUT_SIZES:
|
||||
raise ValueError(f"Недопустимый размер гайки {size!r}. Доступные: {', '.join(NUT_SIZES)}")
|
||||
return NutSpec(size=size)
|
||||
|
||||
|
||||
def get_washer(size: str) -> WasherSpec:
|
||||
if size not in WASHER_SIZES:
|
||||
raise ValueError(f"Недопустимый размер шайбы {size!r}. Доступные: {', '.join(WASHER_SIZES)}")
|
||||
return WasherSpec(size=size)
|
||||
|
||||
|
||||
def iter_bolts() -> Iterator[BoltSpec]:
|
||||
for size in BOLT_SIZES:
|
||||
lengths = _BOLT6_LENGTHS if size == "M6" else BOLT_ALLOWED_LENGTHS
|
||||
for length in lengths:
|
||||
yield get_bolt(size, length)
|
||||
|
||||
|
||||
def iter_nuts() -> Iterator[NutSpec]:
|
||||
for size in NUT_SIZES:
|
||||
yield get_nut(size)
|
||||
|
||||
|
||||
def iter_washers() -> Iterator[WasherSpec]:
|
||||
for size in WASHER_SIZES:
|
||||
yield get_washer(size)
|
||||
|
||||
|
||||
def product_params(spec) -> dict:
|
||||
"""Словарь параметров для каталога по спецификации."""
|
||||
if isinstance(spec, BoltSpec):
|
||||
b = BOLT_SIZES[spec.size]
|
||||
d = spec.size[1:]
|
||||
return {
|
||||
"designation": bolt_designation(spec.size, spec.length),
|
||||
"type": "bolt",
|
||||
"category": "Крепёж",
|
||||
"standard": "ГОСТ 7805-70",
|
||||
"material": "Сталь 8.8",
|
||||
"attributes": {
|
||||
"диаметр": d,
|
||||
"длина": spec.length,
|
||||
"шаг_резьбы": b["P"],
|
||||
"размер_под_ключ": b["S"],
|
||||
"высота_головки": b["k"],
|
||||
},
|
||||
}
|
||||
if isinstance(spec, NutSpec):
|
||||
n = NUT_SIZES[spec.size]
|
||||
d = spec.size[1:]
|
||||
return {
|
||||
"designation": nut_designation(spec.size),
|
||||
"type": "nut",
|
||||
"category": "Крепёж",
|
||||
"standard": "ГОСТ 5915-70",
|
||||
"material": "Сталь 8",
|
||||
"attributes": {
|
||||
"диаметр": d,
|
||||
"шаг_резьбы": n["P"],
|
||||
"размер_под_ключ": n["S"],
|
||||
"высота": n["k"],
|
||||
},
|
||||
}
|
||||
if isinstance(spec, WasherSpec):
|
||||
w = WASHER_SIZES[spec.size]
|
||||
d = spec.size[1:]
|
||||
return {
|
||||
"designation": washer_designation(spec.size),
|
||||
"type": "washer",
|
||||
"category": "Крепёж",
|
||||
"standard": "ГОСТ 11371-78",
|
||||
"material": "Сталь",
|
||||
"attributes": {
|
||||
"диаметр_отверстия": w["d"],
|
||||
"диаметр_наружный": w["D"],
|
||||
"толщина": w["S"],
|
||||
},
|
||||
}
|
||||
raise TypeError(f"Неизвестный тип спецификации: {type(spec)}")
|
||||
|
||||
|
||||
def model_filename(spec) -> str:
|
||||
"""Имя STEP-модели по спецификации."""
|
||||
if isinstance(spec, BoltSpec):
|
||||
return f"bolt_{spec.size}_{spec.length}.step"
|
||||
if isinstance(spec, NutSpec):
|
||||
return f"nut_{spec.size}.step"
|
||||
if isinstance(spec, WasherSpec):
|
||||
return f"washer_{spec.size}.step"
|
||||
raise TypeError(f"Неизвестный тип спецификации: {type(spec)}")
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Тесты для boltgen — генератор крепёжа с STEP-библиотекой и каталогом."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
import pytest
|
||||
|
||||
from boltgen import PortableCatalog, get_bolt, get_nut, get_washer
|
||||
from boltgen.standard import (
|
||||
BOLT_ALLOWED_LENGTHS,
|
||||
BOLT_SIZES,
|
||||
NUT_SIZES,
|
||||
WASHER_SIZES,
|
||||
)
|
||||
|
||||
|
||||
# =====Стандарты ======
|
||||
|
||||
|
||||
def test_bolt_sizes_present():
|
||||
assert "M6" in BOLT_SIZES and "M24" in BOLT_SIZES
|
||||
assert BOLT_SIZES["M10"]["d"] == 10.0
|
||||
|
||||
|
||||
def test_nut_washer_sizes():
|
||||
assert len(NUT_SIZES) == 7
|
||||
assert len(WASHER_SIZES) == 7
|
||||
|
||||
|
||||
def test_invalid_bolt_size():
|
||||
with pytest.raises(ValueError):
|
||||
get_bolt("M99", 40)
|
||||
|
||||
|
||||
def test_invalid_length():
|
||||
with pytest.raises(ValueError):
|
||||
get_bolt("M10", 33) # 33不在 BOLT_ALLOWED_LENGTHS
|
||||
|
||||
|
||||
def test_valid_lengths():
|
||||
assert all(l in BOLT_ALLOWED_LENGTHS for l in (20, 25, 120))
|
||||
|
||||
|
||||
# =====Генерация STEP ======
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size,length", [("M6", 20), ("M10", 40), ("M16", 80), ("M24", 120)])
|
||||
def test_generate_bolt_step(size, length, tmp_path):
|
||||
spec = get_bolt(size, length)
|
||||
out = str(tmp_path / f"bolt_{size}_{length}.step")
|
||||
rec = PortableCatalog(str(tmp_path / "cat.json"), models_dir=str(tmp_path)).add_bolt(spec)
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 1000 # не пустой STEP
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", list(NUT_SIZES))
|
||||
def test_generate_nut_step(size, tmp_path):
|
||||
spec = get_nut(size)
|
||||
out = str(tmp_path / f"nut_{size}.step")
|
||||
PortableCatalog(str(tmp_path / "cat.json"), models_dir=str(tmp_path)).add_nut(spec)
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 1000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", list(WASHER_SIZES))
|
||||
def test_generate_washer_step(size, tmp_path):
|
||||
spec = get_washer(size)
|
||||
out = str(tmp_path / f"washer_{size}.step")
|
||||
PortableCatalog(str(tmp_path / "cat.json"), models_dir=str(tmp_path)).add_washer(spec)
|
||||
assert os.path.exists(out)
|
||||
assert os.path.getsize(out) > 1000
|
||||
|
||||
|
||||
# =====Каталог ======
|
||||
|
||||
|
||||
def test_catalog_roundtrip(tmp_path):
|
||||
cat_path = str(tmp_path / "catalog.json")
|
||||
models_dir = str(tmp_path / "out")
|
||||
cat = PortableCatalog(cat_path, models_dir=models_dir)
|
||||
|
||||
# добавляем
|
||||
b = cat.add_bolt(get_bolt("M12", 45))
|
||||
n = cat.add_nut(get_nut("M12"))
|
||||
w = cat.add_washer(get_washer("M12"))
|
||||
cat.save()
|
||||
|
||||
# размер каталога
|
||||
assert len(cat) == 3
|
||||
assert "bolt_M12_45" in cat
|
||||
assert cat["bolt_M12_45"].designation == "Болт M12x45 ГОСТ 7805-70"
|
||||
assert b.model_file.endswith(".step")
|
||||
assert b.attributes["диаметр"] == "12"
|
||||
|
||||
# загрузка заново
|
||||
cat2 = PortableCatalog(cat_path, models_dir=models_dir)
|
||||
assert len(cat2) == 3
|
||||
assert cat2["nut_M12"].type == "nut"
|
||||
assert cat2["washer_M12"].standard == "ГОСТ 11371-78"
|
||||
|
||||
|
||||
def test_gen_all(tmp_path):
|
||||
cat_path = str(tmp_path / "catalog.json")
|
||||
models_dir = str(tmp_path / "out")
|
||||
cat = PortableCatalog(cat_path, models_dir=models_dir)
|
||||
recs = cat.add_all()
|
||||
# 70 болтов (M6×7 + M8,M10,M12,M16,M20,M24 × 10) + 7 гаек + 7 шайб = 84
|
||||
assert len(cat) == 79 + 7 + 7
|
||||
assert len(recs) == 93
|
||||
assert cat_path.endswith("catalog.json")
|
||||
# все модели существуют
|
||||
for r in cat:
|
||||
assert os.path.exists(os.path.join(models_dir, r.model_file))
|
||||
|
||||
|
||||
# =====CLI ======
|
||||
|
||||
|
||||
def test_cli_bolt_gen(tmp_path):
|
||||
from boltgen.cli import main
|
||||
|
||||
with chdir(tmp_path):
|
||||
assert main(["bolt-gen", "M10", "40", "--dir", "out"]) == 0
|
||||
assert os.path.exists(str(tmp_path / "out" / "bolt_M10_40.step"))
|
||||
assert os.path.exists(str(tmp_path / "catalog.json"))
|
||||
|
||||
|
||||
def test_cli_gen_all(tmp_path):
|
||||
from boltgen.cli import main
|
||||
|
||||
with chdir(tmp_path):
|
||||
assert main(["gen-all", "--dir", "out"]) == 0
|
||||
cat_path = tmp_path / "catalog.json"
|
||||
assert cat_path.exists()
|
||||
|
||||
|
||||
def test_cli_show_and_list(tmp_path):
|
||||
from boltgen.cli import main
|
||||
|
||||
with chdir(tmp_path):
|
||||
main(["gen-all", "--dir", "out"])
|
||||
assert main(["list"]) == 0
|
||||
assert main(["show", "bolt_M10_40"]) == 0
|
||||
|
||||
|
||||
def test_cli_error(tmp_path):
|
||||
from boltgen.cli import main
|
||||
|
||||
with chdir(tmp_path):
|
||||
assert main(["bolt-gen", "M99", "40"]) == 2 # invalid size
|
||||
|
||||
|
||||
@contextmanager
|
||||
def chdir(d):
|
||||
cwd = os.getcwd()
|
||||
os.chdir(d)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
Reference in new issue
Block a user