fix(geometry): correct hexagon across-flats (polygon r=2S/sqrt3); add STEP-volume mass calc + vedomost mass column
- 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:
1 parent
9d75f55867
commit
74bc4b0135
5 files changed
+170
-10
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user