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:
OpenClaw Coder committed 2026-11-07 01:35:03 +10:00
commit 591aa57bd5
9 files changed
+1018

No files matched your search

+19
View File
@@ -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
+100
View File
@@ -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
```
+27
View File
@@ -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"
+44
View File
@@ -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",
]
+158
View File
@@ -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
+179
View File
@@ -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())
+127
View File
@@ -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)
+198
View File
@@ -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)}")
+166
View File
@@ -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)