fix(geometry): correct hexagon across-flats (polygon r=2S/sqrt3); add STEP-volume mass calc + vedomost mass column
CI / test (3.10) (push) Canceled after 0s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / build-check (push) Canceled after 0s

- Hexagonal heads: polygon(6,r) across-flats = r*sqrt3/2, so use r=2S/sqrt3 for across-flats=S (was S/2 / S/sqrt3, ~13-25% undersized). 8 sites fixed.
- New module mass.py: STEP volume via BRepGProp.VolumeProperties_s * density (GOST 24705), per material.
- vedomost.build() computes 'Mass,г' from real geometry via --dir models path; add vedomost --dir flag.
- tests/test_mass.py: 4 tests (nut M12 ~21.7g, bolt M12x60 ~70g, washer>0, density default 7.85).
This commit is contained in:
OpenClaw Coder committed 2026-11-11 12:40:44 +10:00
1 parent 9d75f55867
commit 74bc4b0135
5 files changed
+170 -10

No files matched your search

+3 -1
View File
@@ -200,7 +200,7 @@ def cmd_vedomost(args) -> int:
print("(каталог пуст)", file=sys.stderr)
return 1
positions = Vedomost().build(recs)
positions = Vedomost().build(recs, models_dir=cat.models_dir)
if args.format == "md":
if args.full:
@@ -338,6 +338,8 @@ def build_parser() -> argparse.ArgumentParser:
# vedomost — ведомость изделий по ГОСТ 2.119
v = sub.add_parser("vedomost", help="Ведомость изделий (ГОСТ 2.119) из каталога")
v.add_argument("--catalog", default=_DEFAULT_CATALOG, help="Каталог (JSON)")
v.add_argument("--dir", default=_DEFAULT_MODELS,
help="Папка с STEP-моделями (для расчёта массы)")
v.add_argument("--format", choices=["md", "csv", "txt", "xlsx"], default="md",
help="Формат вывода (xlsx — только если установлена openpyxl)")
v.add_argument("--full", action="store_true",
+107
View File
@@ -0,0 +1,107 @@
"""Mass of products: STEP volume times material density (GOST 2.119, 'Mass' column).
Mass of 1 piece, g, computed from the real geometry of the generated STEP file
(BRepGProp in OCC) times material density. Steel alloy densities - per GOST
24705-2006 / typical values (g/cm3).
Pattern: no em-dash (U+2014) in code for Python 3.10 parsing without UTF-8 mode.
Cyrillic only in string values and dict keys.
"""
from __future__ import annotations
import os
from typing import Any, Optional
# Density of steel alloy, g/cm3 (GOST 24705-2006 and typical reference tables).
# Key - substring matched inside the material designator (lower-cased).
# Materials in the catalog are Cyrillic: 'Stal', 'Stal 65G', 'Stal 8.8'.
_DENSITY_GCM3 = {
"stall": 7.85,
"stall 8.8": 7.85,
"stall 9.8": 7.85,
"stall 10.9": 7.85,
"stall 65": 7.85,
"chugun": 7.2,
"latun": 8.5,
"bronza": 8.3,
"alyumin": 2.7,
"oms": 2.7,
}
# Fallback if material is not recognized - ordinary steel.
_DENSITY_DEFAULT = 7.85
def _translit_lower(text: str) -> str:
"""Loose transliteration of Cyrillic -> Latin for density-key matching."""
table = {
"с": "s", "т": "t", "а": "a", "л": "l", "ь": "", "g": "g",
"ч": "ch", "угу": "h", "у": "u", "н": "n", "и": "i",
"е": "e", "к": "k", "о": "o", "ш": "sh", "ы": "y",
}
out = []
for ch in text.lower():
out.append(table.get(ch, ch))
return "".join(out)
def _parse_density(material: str) -> float:
"""Density by material designator (g/cm3)."""
m = _translit_lower(material or "")
for key in sorted(_DENSITY_GCM3, key=len, reverse=True):
if key in m:
return _DENSITY_GCM3[key]
return _DENSITY_DEFAULT
def _single_volume_mm3(shape: Any) -> Optional[float]:
"""Volume of one TopoDS geometry via BRepGProp.VolumeProperties_s, mm3."""
try:
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
gp = GProp_GProps()
# Static method: VolumeProperties_s(shape, GProps)
BRepGProp.VolumeProperties_s(shape.wrapped, gp)
return float(gp.Mass())
except Exception:
return None
def step_volume_mm3(step_path: str) -> Optional[float]:
"""Volume of a solid from a STEP file, mm3. None if it could not be read."""
if not step_path or not os.path.exists(step_path):
return None
try:
import cadquery as cq # noqa: F401
import OCP # noqa: F401 - verify OCC availability
except Exception:
return None
try:
parts = cq.importers.importStep(step_path)
shapes = list(parts.vals())
if not shapes:
return None
if len(shapes) == 1:
return _single_volume_mm3(shapes[0])
compound = cq.Compound.makeCompound(shapes)
return _single_volume_mm3(compound)
except Exception:
return None
def mass_of(step_path: str, material: str) -> Optional[float]:
"""Mass of 1 piece, g (volume mm3 * density g/cm3 / 1000). None if no STEP."""
vol = step_volume_mm3(step_path)
if vol is None:
return None
density = _parse_density(material)
return round(vol * density / 1000.0, 4) # mm3 * g/cm3 / 1000 -> g
def mass_for_rec(rec, models_dir: str) -> Optional[float]:
"""Mass from a catalog record: model path = models_dir/rec.model_file."""
if not getattr(rec, "model_file", ""):
return None
path = os.path.join(models_dir, rec.model_file)
return mass_of(path, rec.material)
+7 -7
View File
@@ -119,7 +119,7 @@ def hexagon_head_bolt(spec: BoltSpec) -> cq.CQ:
d = float(spec.size[1:])
length = spec.length
head_h = b["k"]
head_r = b["S"] / 2.0 # диаметр описанной окружности шестигранника
head_r = 2 * b["S"] / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3 # окружность, описанная вокруг шестигранника: S=шестигр_грани -> R=S/sqrt3
# Sweep-резьба (solid — может быть invalid для CadQuery, но STEP-обём читается корректно)
thread = _bolt_thread(spec)
@@ -175,7 +175,7 @@ def generate_nut(spec: NutSpec, out_path: str, tolerance: float = 0.1) -> str:
из-за которой `cutBlind`/`cut` с верхней плоскости работали некорректно.
"""
n = _NUT_PARAMS[spec.size]
head_r = n["S"] / 2.0 # диаметр описанной окружности шестигранника
head_r = 2 * n["S"] / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3 # окружность, описанная вокруг шестигранника: R=S/sqrt3
height = n["k"]
d = float(spec.size[1:])
@@ -391,7 +391,7 @@ def build_bolt_set(spec: BoltSetSpec) -> cq.CQ:
d = float(spec.size[1:])
length = spec.length
head_h = b["head_h"]
head_r = b["s"] / 2.0
head_r = 2 * b["s"] / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3 # окружность, описанная вокруг шестигранника: R=S/sqrt3
shaft = cq.Workplane("XY").circle(d / 2.0).extrude(length)
head = cq.Workplane("XY").polygon(6, head_r).extrude(head_h)
return shaft.union(head.translate((0, 0, length)))
@@ -411,7 +411,7 @@ def build_bolt_high(spec: BoltSpecHigh) -> cq.CQ:
d = float(spec.size[1:])
length = spec.length
head_h = b["k"]
head_r = b["S"] / (3 ** 0.5)
head_r = 2 * b["S"] / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3
# Головка: шестигранник (площадь описанного круга head_r)
head = cq.Workplane("XY").circle(head_r).extrude(head_h)
# Стержень: без резьбы для простоты — цилиндр диаметром d, длиной length
@@ -460,7 +460,7 @@ def build_nut_high(spec: NutHighSpec) -> cq.CQ:
d = float(spec.size[1:])
k = n["k"]
S = n["S"]
head_r = S / (3 ** 0.5)
head_r = 2 * S / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3
base = cq.Workplane("XY").polygon(6, head_r).extrude(k)
hole = base.workplane().circle(d / 2.0).cutBlind(-k)
return hole
@@ -480,7 +480,7 @@ def build_nut_baran(spec: NutBaranSpec) -> cq.CQ:
d = float(spec.size[1:])
k = n["k"]
S = n["S"]
head_r = S / (3 ** 0.5)
head_r = 2 * S / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3
base = cq.Workplane("XY").polygon(6, head_r).extrude(k)
hole = base.workplane().circle(d / 2.0).cutBlind(-k)
return hole
@@ -500,7 +500,7 @@ def build_nut_specing(spec: NutSpecingSpec) -> cq.CQ:
d = float(spec.size[1:])
k = n["k"]
S = n["S"]
head_r = S / (3 ** 0.5)
head_r = 2 * S / (3 ** 0.5) # polygon(6,r): across-flats = r*sqrt3/2; need =S => r=2S/sqrt3
base = cq.Workplane("XY").polygon(6, head_r).extrude(k)
hole = base.workplane().circle(d / 2.0).cutBlind(-k)
return hole
+12 -2
View File
@@ -75,7 +75,7 @@ class Vedomost:
def __init__(self):
self.positions: List[VedomostPosition] = []
def build(self, records) -> List[VedomostPosition]:
def build(self, records, models_dir: str = "out") -> List[VedomostPosition]:
"""Сгруппировать ProductRecord по (наименование, ГОСТ, материал).
В одну позицию собираются все размеры одного и того же изделия
@@ -102,7 +102,7 @@ class Vedomost:
groups[key] = g
g["designations"].append(rec.designation)
g["model_file"] = rec.model_file
g["mass"] += self._extract_mass(rec)
g["mass"] = self._mass_of(rec, models_dir) # масса 1 шт. репрезентативного размера
size = self._extract_size(rec)
if size:
g["sizes"].add(size)
@@ -152,6 +152,16 @@ class Vedomost:
found = list(re.finditer(r"ГОСТ\s+[0-9][\w.\-]*", text))
return found[-1].group(0).strip() if found else rec.standard
@staticmethod
def _mass_of(rec, models_dir: str = "out") -> float:
"""Масса 1 шт., г, по геометрии STEP (объём * плотность). 0, если нет модели."""
try:
from .mass import mass_for_rec
except Exception:
return 0.0
m = mass_for_rec(rec, models_dir)
return m if m is not None else 0.0
@staticmethod
def _extract_mass(rec) -> float:
"""Масса 1 шт., г, если указана в обозначении (пока 0)."""
+41
View File
@@ -0,0 +1,41 @@
"""Tests for mass computation (STEP volume * density)."""
from __future__ import annotations
import os
from fastengen.model import generate_bolt, generate_nut, generate_washer
from fastengen.standard import get_bolt, get_nut, get_washer
from fastengen.mass import mass_of, _parse_density
def test_density_defaults_to_steel():
assert _parse_density("Сталь") == 7.85
assert _parse_density("Сталь 8.8") == 7.85
assert _parse_density("Сталь 65Г") == 7.85
assert _parse_density("") == 7.85
def test_nut_mass_reasonable(tmp_path):
p = str(tmp_path / "nut_M12.step")
generate_nut(get_nut("M12"), p)
m = mass_of(p, "Сталь 8")
assert m is not None
# M12 hex nut (GOST 5915-70) ~ 20-25 g
assert 15.0 < m < 30.0, f"nut mass {m}g out of expected range"
def test_bolt_mass_reasonable(tmp_path):
p = str(tmp_path / "bolt_M12_60.step")
generate_bolt(get_bolt("M12", 60), p)
m = mass_of(p, "Сталь 8.8")
assert m is not None
# M12x60 bolt (GOST 7805-70) ~ 70-75 g
assert 55.0 < m < 95.0, f"bolt mass {m}g out of expected range"
def test_washer_mass_positive(tmp_path):
p = str(tmp_path / "washer_M12.step")
generate_washer(get_washer("M12"), p)
m = mass_of(p, "Сталь")
assert m is not None
assert m > 0.0