Files
project/tool/recada.py
T

4009 lines
200 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
recada.py — инструмент реверса листовых деталей RECADA (единый файл, без FreeCAD)
CLI: python recada.py probe|metro|unfold|dxf|rebuild|fold|fold2|stretch|check|fit файл.step
Браузер: streamlit run recada.py
"""
import sys, os, json, math, tempfile
from collections import defaultdict
import numpy as np
import networkx as nx
from scipy.optimize import minimize
from build123d import export_step
import json, math, sys
from collections import defaultdict
import numpy as np
import networkx as nx
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
from OCP.TopExp import TopExp_Explorer, TopExp
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_WIRE, TopAbs_VERTEX
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone, GeomAbs_BSplineSurface, GeomAbs_Torus
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.TopTools import (TopTools_IndexedDataMapOfShapeListOfShape,
TopTools_IndexedMapOfShape)
from OCP.BRepBndLib import BRepBndLib
from OCP.Bnd import Bnd_Box
from OCP.BRepTools import BRepTools
# ---------- утилиты ----------
def read_step(path):
reader = STEPControl_Reader()
if reader.ReadFile(path) != IFSelect_RetDone:
raise RuntimeError("STEP не прочитан")
reader.TransferRoots()
return reader.OneShape()
def face_area(f):
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(f, props)
return props.Mass()
def solid_volume(shape):
props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, props)
return props.Mass()
def count_sub(shape, t):
ex, n = TopExp_Explorer(shape, t), 0
seen = []
while ex.More():
n += 1
ex.Next()
return n
def surf_info(f):
"""тип поверхности + параметры"""
a = BRepAdaptor_Surface(f)
t = a.GetType()
if t == GeomAbs_Plane:
pl = a.Plane()
n = pl.Axis().Direction()
p = pl.Location()
return dict(kind="plane",
normal=[n.X(), n.Y(), n.Z()],
point=[p.X(), p.Y(), p.Z()])
if t == GeomAbs_Cylinder:
cy = a.Cylinder()
ax = cy.Axis().Direction()
loc = cy.Axis().Location()
return dict(kind="cylinder", radius=cy.Radius(),
axis=[ax.X(), ax.Y(), ax.Z()],
axis_point=[loc.X(), loc.Y(), loc.Z()])
if t == GeomAbs_Cone:
return dict(kind="cone")
if t == GeomAbs_Torus:
return dict(kind="torus")
if t == GeomAbs_BSplineSurface:
return dict(kind="bspline")
return dict(kind="other(%d)" % t)
def uv_extent(f):
umin, umax, vmin, vmax = BRepTools.UVBounds_s(f)
return umin, umax, vmin, vmax
# ---------- основной анализ ----------
def probe(path, thickness_hint=None):
shape = read_step(path)
vol = solid_volume(shape)
# 1. собрать грани (индексированная карта = стабильные id)
fmap = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, fmap)
faces = [TopoDS.Face_s(fmap.FindKey(i)) for i in range(1, fmap.Extent() + 1)]
info = []
for i, f in enumerate(faces):
si = surf_info(f)
si["id"] = i
si["area"] = face_area(f)
si["n_wires"] = count_sub(f, TopAbs_WIRE)
si["n_edges"] = count_sub(f, TopAbs_EDGE)
si["uv"] = list(uv_extent(f))
info.append(si)
# 2. AAG: грани-соседи через общие рёбра
m = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(shape, TopAbs_EDGE, TopAbs_FACE, m)
G = nx.Graph()
for i in range(len(faces)):
G.add_node(i, **{k: v for k, v in info[i].items() if k != "uv"})
for k in range(1, m.Extent() + 1):
lst = m.FindFromIndex(k)
ids = []
for sh in lst:
idx = fmap.FindIndex(sh)
if idx > 0:
ids.append(idx - 1)
for a in range(len(ids)):
for b in range(a + 1, len(ids)):
if ids[a] != ids[b]:
G.add_edge(ids[a], ids[b], edge_key=k)
return shape, faces, info, G, vol
"""Детекция отверстий по круглым рёбрам (устойчивее, чем по цилиндрам)"""
import math
from collections import defaultdict
import numpy as np
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Circle
def circular_edges(shape):
out = []
ex = TopExp_Explorer(shape, TopAbs_EDGE)
while ex.More():
e = TopoDS.Edge_s(ex.Current())
try:
c = BRepAdaptor_Curve(e)
if c.GetType() == GeomAbs_Circle:
circ = c.Circle()
sweep = c.LastParameter() - c.FirstParameter()
loc, ax = circ.Location(), circ.Axis().Direction()
out.append(dict(r=circ.Radius(),
sweep=math.degrees(sweep),
c=(round(loc.X(),3), round(loc.Y(),3), round(loc.Z(),3)),
ax=(round(ax.X(),3), round(ax.Y(),3), round(ax.Z(),3))))
except Exception:
pass
ex.Next()
return out
import json, math, sys
from collections import defaultdict
import numpy as np
TOL_DIR = 1e-4 # совпадение направлений
TOL_POS = 1e-3 # совпадение положения оси
def v(a):
return np.array(a, dtype=float)
def unit(a):
a = v(a)
n = np.linalg.norm(a)
return a / n if n > 1e-12 else a
def axis_key(info):
"""ключ оси цилиндра: направление (с точностью до знака) + положение линии"""
d = unit(info["axis"])
if (d[0], d[1], d[2]) < (-d[0], -d[1], -d[2]):
d = -d
p = v(info["axis_point"])
# точка, ближайшая к началу координат на этой оси
p_perp = p - np.dot(p, d) * d
return (tuple(np.round(d, 4)), tuple(np.round(p_perp, 3)))
def measure(path):
shape, faces, info, G, vol = probe(path)
cyl = [i for i in info if i["kind"] == "cylinder"]
pln = [i for i in info if i["kind"] == "plane"]
# ---------- группировка цилиндров по осям ----------
groups = defaultdict(list)
for c in cyl:
groups[axis_key(c)].append(c)
# ---------- толщина: соосные пары с разностью радиусов ----------
dr_hist = defaultdict(int)
coax_pairs = []
for key, g in groups.items():
radii = sorted(set(round(x["radius"], 3) for x in g))
for a in range(len(radii)):
for b in range(a + 1, len(radii)):
d = round(radii[b] - radii[a], 3)
if 0.3 < d < 10:
dr_hist[d] += 1
coax_pairs.append((key, radii[a], radii[b], d))
thickness = max(dr_hist.items(), key=lambda kv: kv[1])[0] if dr_hist else None
# ---------- толщина по парам параллельных плоскостей (контроль) ----------
par_d = defaultdict(int)
for i in range(len(pln)):
for j in range(i + 1, len(pln)):
n1, n2 = unit(pln[i]["normal"]), unit(pln[j]["normal"])
if abs(abs(np.dot(n1, n2)) - 1) < 1e-3:
d = abs(np.dot(v(pln[j]["point"]) - v(pln[i]["point"]), n1))
if 0.3 < d < 10:
par_d[round(d, 2)] += 1
t_planes = max(par_d.items(), key=lambda kv: kv[1])[0] if par_d else None
# ---------- классификация цилиндров ----------
bends, holes, others = [], [], []
for key, g in groups.items():
radii = {round(x["radius"], 3): x for x in g}
rs = sorted(radii)
# гиб: две соосные грани с разностью радиусов = толщина
matched = set()
for a in range(len(rs)):
for b in range(a + 1, len(rs)):
if thickness and abs((rs[b] - rs[a]) - thickness) < 0.05:
inner, outer = radii[rs[a]], radii[rs[b]]
sweep_i = inner["uv"][1] - inner["uv"][0]
sweep_o = outer["uv"][1] - outer["uv"][0]
length = abs(inner["uv"][3] - inner["uv"][2])
bends.append(dict(
inner_face=inner["id"], outer_face=outer["id"],
r_inner=rs[a], r_outer=rs[b],
angle_deg=round(math.degrees(max(sweep_i, sweep_o)), 3),
dihedral_deg=round(180 - math.degrees(max(sweep_i, sweep_o)), 3),
length=round(length, 2),
axis=[float(q) for q in np.round(unit(inner["axis"]), 5)],
axis_point=[round(x, 3) for x in inner["axis_point"]],
n_wires=inner["n_wires"], n_edges=inner["n_edges"],
relief=bool(inner["n_wires"] > 1 or inner["n_edges"] > 4
or outer["n_wires"] > 1 or outer["n_edges"] > 4),
))
matched.add(rs[a]); matched.add(rs[b])
# суммируем развороты частичных цилиндров одного радиуса на одной оси
by_r = defaultdict(list)
for x in g:
rr = round(x["radius"], 3)
if rr not in matched:
by_r[rr].append(x)
for r, lst in by_r.items():
sweep_tot = sum(x["uv"][1] - x["uv"][0] for x in lst)
f = lst[0]
depth = max(abs(x["uv"][3] - x["uv"][2]) for x in lst)
rec = dict(faces=[x["id"] for x in lst], radius=r,
sweep_deg=round(math.degrees(sweep_tot), 1),
depth=round(depth, 2), pieces=len(lst),
axis=[float(q) for q in np.round(unit(f["axis"]), 5)],
axis_point=[round(x, 3) for x in f["axis_point"]])
if abs(sweep_tot - 2 * math.pi) < 1e-2:
holes.append(rec) # полный оборот = отверстие
else:
others.append(rec)
# ---------- отверстия по круглым рёбрам (надёжнее цилиндров) ----------
ce = circular_edges(shape)
arc_sum = defaultdict(float);
for x in ce:
d = np.array(x["ax"], float); n = np.linalg.norm(d)
if n < 1e-9: continue
d = d / n
if tuple(d) < tuple(-d): d = -d
arc_sum[(round(x["r"], 2), tuple(np.round(d, 3)), tuple(np.round(x["c"], 2)))] += x["sweep"]
circles = [k for k, s in arc_sum.items() if s > 359]
hole_axes = defaultdict(list)
keys = []
for (r, ax, c) in circles:
p = np.array(c, float); dd = np.array(ax, float)
perp = p - np.dot(p, dd) * dd
# склейка с допуском 0.05 мм: ищем уже существующую близкую ось
found = None
for (kr, kax, kperp) in keys:
if abs(kr - r) < 0.02 and abs(abs(float(np.dot(np.array(kax), dd))) - 1) < 1e-3 \
and float(np.linalg.norm(np.array(kperp) - perp)) < 0.05:
found = (kr, kax, kperp); break
if found is None:
found = (r, ax, tuple(np.round(perp, 3))); keys.append(found)
hole_axes[found].append(list(c))
hole_by_d = defaultdict(list)
for (r, ax, perp), cs in hole_axes.items():
hole_by_d[round(r * 2, 2)].append(dict(axis=list(ax), center=list(perp), circles=len(cs)))
# ---------- разгрузки: гибы с нарушенной каноникой ----------
reliefs = [b for b in bends if b["relief"]]
# ---------- габарит ----------
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bb = Bnd_Box()
BRepBndLib.Add_s(shape, bb)
xmin, ymin, zmin, xmax, ymax, zmax = bb.Get()
out = dict(
source=path.split("/")[-1],
volume_mm3=round(vol, 2),
bbox=dict(dx=round(xmax - xmin, 2), dy=round(ymax - ymin, 2), dz=round(zmax - zmin, 2)),
faces_total=len(info),
faces_by_kind={k: sum(1 for i in info if i["kind"] == k) for k in set(i["kind"] for i in info)},
thickness_from_bends=thickness,
thickness_from_planes=t_planes,
bends=sorted(bends, key=lambda b: -b["length"]),
holes_total=sum(len(v_) for v_ in hole_by_d.values()),
holes_by_diameter={str(d): dict(count=len(v_), items=v_) for d, v_ in sorted(hole_by_d.items())},
arcs_other=len(others),
reliefs_detected=len(reliefs),
)
return out
import sys, math
import numpy as np
from OCP.TopExp import TopExp
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS, TopoDS_Vertex
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepTools import BRepTools
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.gp import gp_Pnt
from OCP.BRepTopAdaptor import BRepTopAdaptor_FClass2d
from OCP.gp import gp_Pnt2d
from OCP.TopAbs import TopAbs_OUT, TopAbs_ON
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
def _as_solid(shape):
"""Замкнутую оболочку превратить в SOLID — иначе порталы поставщиков
(Blechcon и др.) не распознают деталь: видят поверхность без тела."""
from OCP.TopAbs import TopAbs_SOLID, TopAbs_SHELL
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
ex = TopExp_Explorer(shape, TopAbs_SOLID)
if ex.More():
return shape # уже тело
ex = TopExp_Explorer(shape, TopAbs_SHELL)
if not ex.More():
return shape
from OCP.TopoDS import TopoDS
from OCP.BRepCheck import BRepCheck_Shell, BRepCheck_NoError
mk = BRepBuilderAPI_MakeSolid()
while ex.More():
sh = TopoDS.Shell_s(ex.Current())
try:
chk = BRepCheck_Shell(sh)
if chk.Closed() == BRepCheck_NoError:
sh.Closed(True) # без флага STEP запишет поверхность, не тело
except Exception:
sh.Closed(True)
mk.Add(sh)
ex.Next()
if not mk.IsDone():
return shape
sol = mk.Solid()
# проверить ориентацию: объём должен быть положительным
p = GProp_GProps(); BRepGProp.VolumeProperties_s(sol, p)
if p.Mass() < 0:
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeShape
sol.Reverse()
return sol
def surface_of(shape):
"""Оболочка (только грани) — цель для замера расстояний.
ВАЖНО: distToShape до СОЛИДА возвращает 0 для точек внутри материала,
поэтому мерить всегда до поверхности, а не до тела."""
from OCP.TopAbs import TopAbs_SHELL
from OCP.TopoDS import TopoDS_Compound
from OCP.BRep import BRep_Builder
ex = TopExp_Explorer(shape, TopAbs_SHELL)
comp = TopoDS_Compound(); b = BRep_Builder(); b.MakeCompound(comp)
n = 0
while ex.More():
b.Add(comp, ex.Current()); n += 1; ex.Next()
if n == 0:
fm = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, fm)
for i in range(1, fm.Extent() + 1):
b.Add(comp, fm.FindKey(i))
return comp
def sample_points(shape, per_face=6, seed=7):
"""равномерная выборка точек по UV каждой грани"""
rng = np.random.default_rng(seed)
fmap = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, fmap)
pts = []
for i in range(1, fmap.Extent() + 1):
f = TopoDS.Face_s(fmap.FindKey(i))
umin, umax, vmin, vmax = BRepTools.UVBounds_s(f)
surf = BRepAdaptor_Surface(f)
clf = BRepTopAdaptor_FClass2d(f, 1e-6)
got, tries = 0, 0
while got < per_face and tries < per_face * 40:
tries += 1
u = umin + (umax - umin) * rng.random()
v = vmin + (vmax - vmin) * rng.random()
if clf.Perform(gp_Pnt2d(u, v)) in (TopAbs_OUT,):
continue # точка вне обрезанной грани — пропускаем
try:
p = surf.Value(u, v)
pts.append((p.X(), p.Y(), p.Z()))
got += 1
except Exception:
pass
return pts
def deviations(ref_shape, cand_shape, per_face=6):
pts = sample_points(ref_shape, per_face)
target = surface_of(cand_shape) # до ПОВЕРХНОСТИ, не до тела
dev = []
for (x, y, z) in pts:
vtx = BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex()
d = BRepExtrema_DistShapeShape(vtx, target)
d.Perform()
if d.IsDone():
dev.append(d.Value())
return np.array(dev)
def volume(shape):
p = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, p)
return p.Mass()
def report(ref_path, cand_path, per_face=6, tol=0.05):
ref = read_step(ref_path)
cand = read_step(cand_path) if isinstance(cand_path, str) else cand_path
dev = deviations(ref, cand, per_face)
vr, vc = volume(ref), volume(cand)
inside = float((dev <= tol).sum()) / len(dev) * 100
print("=" * 58)
print("МЕТРОЛОГИЯ")
print(" контрольных точек : %d" % len(dev))
print(" среднее отклонение : %.5f мм" % dev.mean())
print(" максимальное отклонение : %.5f мм" % dev.max())
print(" 95-й процентиль : %.5f мм" % np.percentile(dev, 95))
print(" точек в допуске %.2f мм : %.3f %%" % (tol, inside))
print(" объём эталон / кандидат : %.2f / %.2f мм3 (Δ %.4f %%)"
% (vr, vc, (vc - vr) / vr * 100 if vr else 0))
verdict = "ПРИНЯТО" if (inside >= 99.9 and abs(vc - vr) / vr * 100 < 1) else "НЕ ПРИНЯТО"
print(" вердикт (порог 99.9%%) : %s" % verdict)
print("=" * 58)
return dict(points=len(dev), mean=float(dev.mean()), max=float(dev.max()),
inside_pct=inside, vol_ref=vr, vol_cand=vc)
"""
recada_lib.py — кубики листового металла для build123d (RECADA)
Своя реализация: в build123d 0.11 операций Bend/Flange/Hem НЕТ.
Модель детали: цепочка сегментов вдоль сечения
("flat", L) — прямой участок длиной L
("bend", angle_deg, r_in) — гиб: поворот на angle_deg, внутренний радиус r_in
Сечение "протягивается" на ширину W. Гибы с параллельными осями.
"""
import math
from build123d import (BuildPart, BuildSketch, BuildLine, Line, JernArc, Polyline,
trace, extrude, Plane, Axis, Location, Locations, Hole,
Mode, Vector, export_step)
# ---------- математика развёртки ----------
def k_factor(r_inner, t, table=None):
"""K-фактор по таблице R/t (Material Definition Sheet). Дефолт — практика для нержавейки."""
if table:
ratio = r_inner / t
best = min(table, key=lambda x: abs(x - ratio))
return table[best]
ratio = r_inner / t
if ratio < 1.0: return 0.38
if ratio < 2.0: return 0.42
if ratio < 4.0: return 0.44
return 0.46
def bend_allowance(angle_deg, r_inner, t, k=None):
"""BA — длина нейтральной оси в зоне гиба (припуск на изгиб)."""
k = k_factor(r_inner, t) if k is None else k
return math.radians(angle_deg) * (r_inner + k * t)
def bend_deduction(angle_deg, r_inner, t, k=None):
"""BD — вычет: OSSB*2 - BA."""
a = math.radians(angle_deg)
ossb = (r_inner + t) * math.tan(min(a, math.radians(179.9)) / 2)
return 2 * ossb - bend_allowance(angle_deg, r_inner, t, k)
def flat_length(segments, t, k=None):
"""Длина развёртки цепочки сегментов."""
L = 0.0
for s in segments:
if s[0] == "flat":
L += s[1]
elif s[0] == "bend":
L += bend_allowance(s[1], s[2], t, k)
return L
# ---------- построение тела ----------
def sheet_profile_line(segments, t):
"""Линия по нейтральной оси сечения (радиус дуги = r_in + t/2)."""
with BuildLine() as bl:
pos = Vector(0, 0)
tangent = Vector(1, 0)
for s in segments:
if s[0] == "flat":
nxt = pos + tangent * s[1]
Line(pos, nxt)
pos = nxt
elif s[0] == "bend":
ang, r_in = s[1], s[2]
arc = JernArc(start=pos, tangent=tangent,
radius=r_in + t / 2, arc_size=ang)
pos = arc @ 1
tangent = arc % 1
return bl.line
def make_bent_part(segments, width, t, holes=None):
"""Тело детали: сечение по нейтральной оси, утолщённое на t, протянутое на ширину."""
line = sheet_profile_line(segments, t)
with BuildPart() as part:
with BuildSketch(Plane.XZ) as sk:
trace(line, line_width=t)
extrude(amount=width)
if holes:
for h in holes:
with Locations(Location(Vector(*h["at"]))):
Hole(radius=h["d"] / 2)
return part.part
# ---------- самопроверка ----------
import json, math
from collections import defaultdict
import numpy as np
def _axis_groups(bends, tol=0.02):
"""группируем гибы по направлению оси (параллельные оси = одна цепочка)"""
groups = []
for b in bends:
d = np.array(b["axis"], float)
d = d / np.linalg.norm(d)
placed = False
for g in groups:
if abs(abs(float(np.dot(d, g["dir"]))) - 1) < tol:
g["items"].append(b); placed = True; break
if not placed:
groups.append(dict(dir=d, items=[b]))
return groups
def make_params(res):
t = res["thickness_from_bends"]
p = {
"sku": "TODO-SKU",
"vehicle": "TODO-vehicle",
"material": {"grade": "1.4301 2B", "thickness": t, "k_factor": "from_table"},
"bend_radius_inner": res["bends"][0]["r_inner"] if res["bends"] else None,
"bends": [{"rotation_deg": b["angle_deg"], "dihedral_deg": b["dihedral_deg"],
"length": b["length"], "axis": b["axis"], "relief": b["relief"]}
for b in res["bends"]],
"holes": {d: {"count": g["count"], "centers": [i["center"] for i in g["items"]]}
for d, g in res["holes_by_diameter"].items()},
"bbox": res["bbox"],
"reference_volume_mm3": res["volume_mm3"],
}
return p
def make_generator(res, module_name="recada"):
t = res["thickness_from_bends"]
bends = res["bends"]
groups = _axis_groups(bends)
single_chain = len(groups) == 1
r_in = bends[0]["r_inner"] if bends else 3.0
L = []
A = L.append
A('"""')
A("Черновик параметрического генератора (создан автоматически из обмера STEP).")
A("ПРОВЕРИТЬ ИНЖЕНЕРУ: что параметр, что правило, что артефакт производства.")
A('"""')
A("import json")
A(f"from {module_name} import make_bent_part, flat_length, bend_allowance")
A("from build123d import export_step")
A("")
A("p = json.load(open('params.json'))")
A("t = p['material']['thickness'] # ПАРАМЕТР материала")
A(f"R = p['bend_radius_inner'] # ПРАВИЛО: R = {round(r_in / t, 2)} x t"
if t else "R = p['bend_radius_inner']")
A("")
A("# ---- гибы (углы поворота; двугранный = 180 - поворот) ----")
for i, b in enumerate(bends, 1):
note = " # разгрузка: генерируется правилом features.py" if b["relief"] else ""
A(f"ANG{i} = {b['angle_deg']:.3f} # двугранный {b['dihedral_deg']:.2f}°, "
f"длина гиба {b['length']:.1f} мм{note}")
A("")
A("# ---- прямые участки (между осями гибов вычислены из обмера) ----")
P = [np.array(b["axis_point"], float) for b in bends] if bends and "axis_point" in bends[0] else []
A("FLAT0 = 0.0 # TODO: от кромки до первого гиба (измерить)")
for i in range(1, len(bends)):
if len(P) > i:
d = float(np.linalg.norm(P[i] - P[i - 1]))
A(f"FLAT{i} = {d:.3f} # расстояние между осями гибов {i} и {i+1}")
else:
A(f"FLAT{i} = 0.0 # TODO")
A(f"FLAT{len(bends)} = 0.0 # TODO: от последнего гиба до кромки (измерить)")
A("")
if single_chain:
A("# все оси гибов параллельны -> деталь строится сечением + протяжкой")
segs = []
for i, b in enumerate(bends, 1):
segs.append(f'("flat", FLAT{i-1})')
segs.append(f'("bend", ANG{i}, R)')
segs.append(f'("flat", FLAT{len(bends)})')
A("segments = [\n " + ",\n ".join(segs) + ",\n]")
A("WIDTH = %.1f # длина гибов из обмера" % max(b["length"] for b in bends))
A("")
A("part = make_bent_part(segments, width=WIDTH, t=t)")
else:
A("# ВНИМАНИЕ: оси гибов НЕ параллельны (%d направлений) —" % len(groups))
A("# деталь пространственная, одной протяжкой не строится.")
A("# Порядок: базовая пластина -> цепочки гибов по группам осей.")
for gi, g in enumerate(groups, 1):
ax = ", ".join(f"{x:.3f}" for x in g["dir"])
A(f"# группа {gi}: ось [{ax}], гибов {len(g['items'])}: "
+ ", ".join("%.2f°" % b["angle_deg"] for b in g["items"]))
A("")
A("# порядок гибов по удалению от базовой грани (черновая цепочка):")
try:
order = sorted(range(len(bends)), key=lambda i: -bends[i]["length"])
for k, i in enumerate(order, 1):
b = bends[i]
A("# %d) поворот %.2f° вокруг оси [%s] через точку (%s), длина %.1f%s"
% (k, b["angle_deg"], ", ".join("%.3f" % x for x in b["axis"]),
", ".join("%.1f" % x for x in b["axis_point"]), b["length"],
", разгрузка" if b["relief"] else ""))
except Exception:
pass
A("part = None # TODO: собрать цепочку поворотов вокруг осей выше")
A("")
A("# ---- отверстия ----")
for d, g in res["holes_by_diameter"].items():
A(f"# D{d}: {g['count']} шт")
for it in g["items"]:
c = ", ".join("%.2f" % x for x in it["center"])
A(f"# центр ({c}) ось ({', '.join('%.2f' % x for x in it['axis'])})")
A("# TODO: перенести в hole_array() с привязкой к базам (datums), а не к нулю CAD")
A("")
A("# ---- контроль ----")
A(f"REFERENCE_VOLUME = {res['volume_mm3']} # мм3, эталон из STEP")
A("if part is not None:")
A(" print('объём:', round(part.volume, 1), 'эталон:', REFERENCE_VOLUME)")
A(" print('развёртка:', round(flat_length(segments, t), 2), 'мм')"
if single_chain else " pass")
A(" export_step(part, 'out.step')")
return "\n".join(L)
import json, math, sys, tempfile, os
import numpy as np
from scipy.optimize import minimize
def build_from_vector(x, angles, r_in, t):
"""x = [flat_0, flat_1, ..., flat_n, width]"""
flats, width = x[:-1], x[-1]
segs = []
for i, a in enumerate(angles):
segs.append(("flat", max(float(flats[i]), 0.01)))
segs.append(("bend", float(a), r_in))
segs.append(("flat", max(float(flats[-1]), 0.01)))
return make_bent_part(segs, width=max(float(width), 1.0), t=t), segs
def objective(x, ref_shape, angles, r_in, t, ref_pts, tmp):
try:
part, _ = build_from_vector(x, angles, r_in, t)
except Exception:
return 1e6
p = os.path.join(tmp, "cand.step")
export_step(part, p)
cand = read_step(p)
# отклонение точек эталона до кандидата
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.gp import gp_Pnt
def rms(points, target):
s = 0.0
for (px, py, pz) in points:
v = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex()
d = BRepExtrema_DistShapeShape(v, target)
d.Perform()
s += d.Value() ** 2 if d.IsDone() else 100.0
return math.sqrt(s / max(len(points), 1))
# ДВУСТОРОННЯЯ невязка: эталон->кандидат И кандидат->эталон
# (односторонняя не видит лишнего металла на кандидате)
cand_pts = sample_points(cand, 2)
return rms(ref_pts, surface_of(cand)) + rms(cand_pts, surface_of(ref_shape))
def fit(step_path, x0=None, maxiter=60, sample_per_face=3, verbose=True):
res = measure(step_path)
t = res["thickness_from_bends"]
bends = res["bends"]
r_in = bends[0]["r_inner"]
angles = [b["angle_deg"] for b in bends]
# известные из обмера: расстояния между осями гибов
P = [np.array(b["axis_point"], float) for b in bends]
mid = [float(np.linalg.norm(P[i] - P[i - 1])) for i in range(1, len(P))]
width0 = max(b["length"] for b in bends)
bb = res["bbox"]
guess_end = max(bb["dx"], bb["dy"], bb["dz"]) * 0.25 # грубая догадка
x0 = x0 or ([guess_end] + mid + [guess_end] + [width0])
ref = read_step(step_path)
ref_pts = sample_points(ref, sample_per_face)
tmp = tempfile.mkdtemp()
if verbose:
print("подбираем %d параметров, контрольных точек %d" % (len(x0), len(ref_pts)))
print("старт:", [round(v, 2) for v in x0])
hist = []
def cb(xk):
val = objective(xk, ref, angles, r_in, t, ref_pts, tmp)
hist.append(val)
if verbose:
print(" итерация %2d: RMS %.4f мм x=%s" %
(len(hist), val, [round(float(v), 2) for v in xk]))
r = minimize(objective, x0, args=(ref, angles, r_in, t, ref_pts, tmp),
method="Powell", callback=cb,
options=dict(maxiter=maxiter, xtol=1e-3, ftol=1e-4))
part, segs = build_from_vector(r.x, angles, r_in, t)
out = os.path.join(tmp, "fitted.step")
export_step(part, out)
return dict(x=[float(v) for v in r.x], rms=float(r.fun), segments=segs,
thickness=t, r_inner=r_in, angles=angles,
fitted_step=out, ref_step=step_path, measurements=res)
def export_unfold_dxf(res, path="unfold.dxf", width=None):
"""Развёртка в DXF: контур заготовки + линии гиба (для деталей с параллельными осями)."""
import ezdxf
t = res["thickness_from_bends"]
bends = res["bends"]
groups = _axis_groups(bends)
if len(groups) != 1:
raise ValueError("оси гибов не параллельны — развёртка одной картой невозможна")
W = width or max(b["length"] for b in bends)
P = [np.array(b["axis_point"], float) for b in bends]
flats = [float(np.linalg.norm(P[i] - P[i-1])) for i in range(1, len(P))]
L = sum(flats) + sum(bend_allowance(b["angle_deg"], b["r_inner"], t) for b in bends)
doc = ezdxf.new(); msp = doc.modelspace()
msp.add_lwpolyline([(0,0),(L,0),(L,W),(0,W),(0,0)], dxfattribs={"layer":"CONTOUR"})
x = 0.0
for i, b in enumerate(bends):
ba = bend_allowance(b["angle_deg"], b["r_inner"], t)
if i > 0: x += flats[i-1]
msp.add_line((x,0),(x,W), dxfattribs={"layer":"BEND"})
msp.add_line((x+ba,0),(x+ba,W), dxfattribs={"layer":"BEND"})
msp.add_text("R%.1f %.2f deg" % (b["r_inner"], b["angle_deg"]),
dxfattribs={"layer":"BEND","height":2.0}).set_placement((x, W+2))
x += ba
doc.saveas(path)
return dict(path=path, flat_length=round(L,2), width=round(W,2), bends=len(bends))
# ================= ПРОСТРАНСТВЕННАЯ РЕКОНСТРУКЦИЯ =================
# Расщепление детали на две оболочки щупом + парование + двухцветная раскраска,
# затем сшивка одной стороны и наращивание на толщину. Работает на деталях
# с непараллельными осями гибов (там, где протяжка сечением невозможна).
def _face_probe_points(f, n=3, rng=None):
from OCP.TopAbs import TopAbs_OUT
from OCP.BRepTopAdaptor import BRepTopAdaptor_FClass2d
from OCP.gp import gp_Pnt2d
from OCP.BRepLProp import BRepLProp_SLProps
rng = rng or np.random.default_rng(5)
umin, umax, vmin, vmax = BRepTools.UVBounds_s(f)
clf = BRepTopAdaptor_FClass2d(f, 1e-6); surf = BRepAdaptor_Surface(f)
out, tries = [], 0
while len(out) < n and tries < 200:
tries += 1
u = umin + (umax - umin) * rng.random(); v = vmin + (vmax - vmin) * rng.random()
if clf.Perform(gp_Pnt2d(u, v)) == TopAbs_OUT: continue
pr = BRepLProp_SLProps(surf, u, v, 1, 1e-6)
if not pr.IsNormalDefined(): continue
p = pr.Value(); nv = pr.Normal()
nn = np.array([nv.X(), nv.Y(), nv.Z()]); nn /= np.linalg.norm(nn)
out.append((np.array([p.X(), p.Y(), p.Z()]), nn))
return out
def split_shells(shape, faces, G, t, tol=0.05):
"""-> (side_A_ids, side_B_ids, thin_ids). Щуп на глубину t + двухцветная раскраска."""
from OCP.TopAbs import TopAbs_FACE
SURF = surface_of(shape)
fmap = TopTools_IndexedMapOfShape(); TopExp.MapShapes_s(shape, TopAbs_FACE, fmap)
rng = np.random.default_rng(5)
def hit(pt):
v = BRepBuilderAPI_MakeVertex(gp_Pnt(*pt)).Vertex()
d = BRepExtrema_DistShapeShape(v, SURF); d.Perform()
if not d.IsDone() or d.Value() > tol: return None
sup = d.SupportOnShape2(1)
return fmap.FindIndex(sup) - 1 if sup.ShapeType() == TopAbs_FACE else -1
shell, thin, pairs = [], [], nx.Graph()
for i, f in enumerate(faces):
pts = _face_probe_points(f, 3, rng)
if not pts: thin.append(i); continue
hits, votes = 0, {}
for (p, nn) in pts:
for sgn in (-1, 1):
j = hit(p + sgn * nn * t)
if j is not None:
hits += 1
if j >= 0 and j != i: votes[j] = votes.get(j, 0) + 1
break
if hits >= max(1, len(pts) - 1):
shell.append(i)
if votes: pairs.add_edge(i, max(votes, key=votes.get))
else:
thin.append(i)
pairs.add_nodes_from(shell)
sub = pairs.subgraph(shell)
if not nx.is_bipartite(sub):
raise ValueError("оболочки не разделяются (нечётные циклы в паровании)")
color = nx.bipartite.color(sub)
A = [k for k, v in color.items() if v == 0]
B = [k for k, v in color.items() if v == 1]
return A, B, thin
def rebuild_spatial(step_path, thickness=None, out_path=None):
"""Реконструкция пространственной детали: оболочка + наращивание на толщину.
Толщина — настоящий параметр: t=2 даёт ту же форму в 2 мм."""
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin
from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_Arc
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
shape, faces, info, G, vol = probe(step_path)
res = measure(step_path)
t = thickness or res["thickness_from_bends"]
A, B, thin = split_shells(shape, faces, G, res["thickness_from_bends"])
best = None
for name, ids in (("B", B), ("A", A)):
sol, npc = thicken_by_faces([faces[i] for i in ids], t)
if sol is None: continue
p = GProp_GProps(); BRepGProp.VolumeProperties_s(sol, p)
v = p.Mass()
if v <= 0: continue
err = abs(v - vol) / vol if thickness is None else 0.0
if best is None or err < best[0]:
best = (err, sol, name, v)
if best is None:
raise ValueError("наращивание оболочки не удалось")
err, sol, side, v = best
out_path = out_path or os.path.join(tempfile.mkdtemp(), "rebuilt.step")
sol = _as_solid(sol)
w = STEPControl_Writer(); w.Transfer(sol, STEPControl_AsIs); w.Write(out_path)
return dict(path=out_path, side=side, volume=v, ref_volume=vol,
thickness=t, faces_A=len(A), faces_B=len(B), faces_thin=len(thin))
def make_freecad_code(res, step_path=None, sides=None):
"""Генерирует Python-код для вставки в консоль FreeCAD (только модуль Part)."""
t = res["thickness_from_bends"]
bends = res["bends"]
groups = _axis_groups(bends)
L = []; A = L.append
A("# -*- coding: utf-8 -*-")
A("# Код сгенерирован RECADA из обмера STEP. Вставить в консоль FreeCAD (View > Panels > Python console)")
A("import FreeCAD as App, Part, math")
A("from FreeCAD import Vector")
A("")
A("# ================= ПАРАМЕТРЫ =================")
A("T = %.3f # толщина листа (ПАРАМЕТР: можно 2.0 / 2.5 / 3.0)" % t)
A("R = %.3f # внутренний радиус гиба (ПРАВИЛО: R = %.2f x T)"
% (bends[0]["r_inner"] if bends else 3.0, (bends[0]["r_inner"]/t) if bends and t else 1))
A("")
if len(groups) == 1 and bends:
P = [np.array(b["axis_point"], float) for b in bends]
mid = [float(np.linalg.norm(P[i]-P[i-1])) for i in range(1, len(P))]
W = max(b["length"] for b in bends)
A("W = %.3f # ширина детали (длина линий гиба)" % W)
A("FLAT = [%s] # прямые участки: первый и последний ПРОВЕРИТЬ" %
", ".join(["30.0"] + ["%.3f" % m for m in mid] + ["30.0"]))
A("ANG = [%s] # углы поворота, град (двугранный = 180 - угол)" %
", ".join("%.3f" % b["angle_deg"] for b in bends))
A("")
A("# ---- профиль по нейтральной оси ----")
A("pts, tang, pos = [], Vector(1,0,0), Vector(0,0,0)")
A("edges = []")
A("for i, a in enumerate(ANG + [None]):")
A(" nxt = pos + tang * FLAT[i]")
A(" edges.append(Part.LineSegment(pos, nxt).toShape()); pos = nxt")
A(" if a is None: break")
A(" rad = math.radians(a); rm = R + T/2.0")
A(" nrm = Vector(-tang.y, tang.x, 0) # влево от направления")
A(" ctr = pos + nrm * rm")
A(" v1 = pos - ctr")
A(" v2 = Vector(v1.x*math.cos(-rad) - v1.y*math.sin(-rad),")
A(" v1.x*math.sin(-rad) + v1.y*math.cos(-rad), 0)")
A(" end = ctr + v2")
A(" mid_ang = -rad/2.0")
A(" vm = Vector(v1.x*math.cos(mid_ang) - v1.y*math.sin(mid_ang),")
A(" v1.x*math.sin(mid_ang) + v1.y*math.cos(mid_ang), 0)")
A(" edges.append(Part.Arc(pos, ctr + vm, end).toShape())")
A(" pos = end")
A(" tang = Vector(tang.x*math.cos(-rad) - tang.y*math.sin(-rad),")
A(" tang.x*math.sin(-rad) + tang.y*math.cos(-rad), 0)")
A("")
A("wire = Part.Wire(edges)")
A("face = wire.makeOffset2D(T/2.0, join=2, fill=True) # полоса шириной T вдоль оси")
A("solid = face.extrude(Vector(0, 0, W))")
A("Part.show(solid, 'RECADA_part')")
A("App.ActiveDocument.recompute()")
A("print('объём:', round(solid.Volume, 1), ' эталон:', %.2f)" % res["volume_mm3"])
else:
A("# Деталь ПРОСТРАНСТВЕННАЯ (%d направлений осей гибов) —" % len(groups))
A("# строится реконструкцией: одна оболочка + наращивание на толщину T.")
A("STEP = r'%s' # путь к исходному STEP" % (step_path or "C:\\путь\\деталь.step"))
if sides:
A("SHELL_FACES = %s # номера граней одной стороны (из обмера RECADA)" % sides)
else:
A("SHELL_FACES = [] # заполнится при обмере")
A("")
A("src = Part.Shape(); src.read(STEP)")
A("faces = [src.Faces[i] for i in SHELL_FACES]")
A("shell = Part.Shell(faces)")
A("solid = shell.makeOffsetShape(-T, 1e-4, fill=True) # знак поменять, если вывернуло")
A("Part.show(solid, 'RECADA_rebuilt_T%g')")
A("App.ActiveDocument.recompute()")
A("print('объём:', round(solid.Volume, 1), ' эталон:', %.2f)" % res["volume_mm3"])
A("")
A("# ---- отверстия (координаты из обмера; перенести на базы) ----")
for d, g in res["holes_by_diameter"].items():
for it in g["items"]:
A("# D%s центр (%s) ось (%s)" % (d,
", ".join("%.2f" % x for x in it["center"]),
", ".join("%.2f" % x for x in it["axis"])))
return "\n".join(L)
def make_freecad_code_selfcontained(step_path, side=None):
"""Самодостаточный код для FreeCAD: скелет-оболочка вшита в текст (внешний файл не нужен).
Толщина остаётся параметром — та же форма пересобирается в любом металле."""
import zlib, base64
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
shape, faces, info, G, vol = probe(step_path)
res = measure(step_path)
t = res["thickness_from_bends"]
A, B, thin = split_shells(shape, faces, G, t)
ids = sorted(A if (side == "A" or (side is None and len(A) >= len(B))) else B)
sew = BRepBuilderAPI_Sewing(0.05)
for i in ids: sew.Add(faces[i])
sew.Perform(); shell = sew.SewedShape()
p = os.path.join(tempfile.mkdtemp(), "shell.brep")
BRepTools.Write_s(shell, p)
blob = base64.b64encode(zlib.compress(open(p, "rb").read(), 9)).decode()
chunks = [blob[i:i+120] for i in range(0, len(blob), 120)]
body = "\n".join(' "%s"' % c for c in chunks)
hole_lines = []
for d, g in res["holes_by_diameter"].items():
for it in g["items"]:
hole_lines.append("# D%s центр (%s) ось (%s)" % (d,
", ".join("%.2f" % x for x in it["center"]),
", ".join("%.2f" % x for x in it["axis"])))
bend_lines = ["# поворот %6.2f° двугранный %6.2f° R%.1f длина %6.1f ось (%s)%s" %
(b["angle_deg"], b["dihedral_deg"], b["r_inner"], b["length"],
", ".join("%.3f" % x for x in b["axis"]),
" разгрузка" if b["relief"] else "")
for b in res["bends"]]
return f"""# -*- coding: utf-8 -*-
# RECADA — самодостаточный код воссоздания детали. Внешние файлы НЕ нужны.
# Вставить в консоль FreeCAD: View -> Panels -> Python console
#
# Обмер исходной детали:
# толщина {t} мм, гибов {len(res['bends'])}, отверстий {res.get('holes_total', 0)},
# объём эталона {res['volume_mm3']} мм3, оболочка из {len(ids)} граней
#
# ГИБЫ:
{chr(10).join(bend_lines)}
#
# ОТВЕРСТИЯ:
{chr(10).join(hole_lines) if hole_lines else "# нет"}
import FreeCAD as App, Part, zlib, base64
# ================= ПАРАМЕТРЫ =================
T = {t} # толщина листа — ПАРАМЕТР (поставьте 2.0 или 2.5 и пересоберите)
SIGN = -1 # сторона наращивания; если деталь вывернуло — поменяйте на +1
# ================= СКЕЛЕТ (срединная оболочка детали) =================
SKELETON = (
{body}
)
_brep = zlib.decompress(base64.b64decode(SKELETON)).decode("utf-8", "ignore")
shell = Part.Shape()
shell.importBrepFromString(_brep)
print("оболочка: граней", len(shell.Faces))
# makeOffsetShape(offset, tol, inter, self_inter, offsetMode, join, fill)
# join: 0=Arc (падает на этой геометрии), 1=Tangent, 2=Intersection <- рабочий
solid = None
for _join in (2, 1, 0):
for _sgn in (SIGN, -SIGN):
try:
_s = shell.makeOffsetShape(_sgn * T, 1.0e-4, False, False, 0, _join, True)
if _s.Volume > 0:
solid = _s
print("построено: join=%d, знак=%+d" % (_join, _sgn))
break
except Exception as _e:
pass
if solid is not None:
break
if solid is None:
print("Не удалось нарастить оболочку. Попробуйте makeThickness или другой допуск.")
else:
Part.show(solid, "RECADA_T%g" % T)
App.ActiveDocument.recompute()
print("объём:", round(solid.Volume, 1), "мм3 эталон:", {res['volume_mm3']})
"""
# ================= ПАРАМЕТРИЧЕСКОЕ СКЛАДЫВАНИЕ (оригами-кинематика) =================
# ============ ПЕРЕСТРОЕНИЕ ЗОНЫ ГИБА И ЗАДЕЛКА ДЫР ============
import math, numpy as np
from OCP.gp import gp_Ax1, gp_Ax3, gp_Pnt, gp_Dir, gp_Cylinder, gp_Circ, gp_Ax2, gp_Vec
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepTools import BRepTools
from OCP.BRepBuilderAPI import (BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeWire)
from OCP.GC import GC_MakeArcOfCircle
from OCP.TopoDS import TopoDS
def bend_zone_faces(inner_face, t, extra_deg=0.0):
"""По исходной внутренней цилиндрической грани строит зону гиба нового угла."""
a = BRepAdaptor_Surface(inner_face)
cyl = a.Cylinder()
ax3 = cyl.Position()
r_in = cyl.Radius()
r_out = r_in + t
u0, u1, v0, v1 = BRepTools.UVBounds_s(inner_face)
du = (u1 - u0) + math.radians(extra_deg)
faces = []
# два цилиндра
for R in (r_in, r_out):
c = gp_Cylinder(ax3, R)
mf = BRepBuilderAPI_MakeFace(c, u0, u0 + du, v0, v1)
if not mf.IsDone(): return None
faces.append(mf.Face())
# два торцевых сектора (плоскости, перпендикулярные оси)
loc = ax3.Location(); dirz = ax3.Direction(); dirx = ax3.XDirection()
for v in (v0, v1):
center = gp_Pnt(loc.X() + dirz.X()*v, loc.Y() + dirz.Y()*v, loc.Z() + dirz.Z()*v)
ax2 = gp_Ax2(center, dirz, dirx)
try:
arc_i = GC_MakeArcOfCircle(gp_Circ(ax2, r_in), u0, u0 + du, True).Value()
arc_o = GC_MakeArcOfCircle(gp_Circ(ax2, r_out), u0, u0 + du, True).Value()
except Exception:
return None
e_i = BRepBuilderAPI_MakeEdge(arc_i).Edge()
e_o = BRepBuilderAPI_MakeEdge(arc_o).Edge()
p_i0 = arc_i.Value(arc_i.FirstParameter()); p_o0 = arc_o.Value(arc_o.FirstParameter())
p_i1 = arc_i.Value(arc_i.LastParameter()); p_o1 = arc_o.Value(arc_o.LastParameter())
e_s = BRepBuilderAPI_MakeEdge(p_i0, p_o0).Edge()
e_e = BRepBuilderAPI_MakeEdge(p_i1, p_o1).Edge()
mw = BRepBuilderAPI_MakeWire()
for e in (e_i, e_e, e_o, e_s): mw.Add(e)
if not mw.IsDone(): return None
mf = BRepBuilderAPI_MakeFace(mw.Wire(), True)
if not mf.IsDone(): return None
faces.append(mf.Face())
return faces
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
from OCP.TopExp import TopExp, TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE, TopAbs_SHELL
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_HSequenceOfShape
from OCP.TopoDS import TopoDS
from OCP.BRepFill import BRepFill_Filling
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing, BRepBuilderAPI_MakeSolid
from OCP.GeomAbs import GeomAbs_C0
def free_edges(shape):
m = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(shape, TopAbs_EDGE, TopAbs_FACE, m)
return [TopoDS.Edge_s(m.FindKey(k)) for k in range(1, m.Extent() + 1)
if m.FindFromIndex(k).Size() < 2]
def cap_holes(shape, tol=0.05):
edges = free_edges(shape)
if not edges:
return shape, 0
seq = TopTools_HSequenceOfShape()
for e in edges: seq.Append(e)
wires = TopTools_HSequenceOfShape()
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(seq, tol, False, wires)
patches = []
for i in range(1, wires.Length() + 1):
w = TopoDS.Wire_s(wires.Value(i))
try:
f = BRepFill_Filling()
ex = TopExp_Explorer(w, TopAbs_EDGE)
while ex.More():
f.Add(TopoDS.Edge_s(ex.Current()), GeomAbs_C0, True)
ex.Next()
f.Build()
if f.IsDone():
patches.append(f.Face())
except Exception:
pass
if not patches:
return shape, 0
sew = BRepBuilderAPI_Sewing(tol)
ex = TopExp_Explorer(shape, TopAbs_FACE)
while ex.More(): sew.Add(ex.Current()); ex.Next()
for p in patches: sew.Add(p)
sew.Perform(); sh = sew.SewedShape()
ex = TopExp_Explorer(sh, TopAbs_SHELL); mk = BRepBuilderAPI_MakeSolid(); n = 0
while ex.More():
s = TopoDS.Shell_s(ex.Current()); s.Closed(True); mk.Add(s); n += 1; ex.Next()
return (mk.Solid() if n else sh), len(patches)
def ov_viewer_html(model_url, height=640):
return """
<style>
.ovwrap{font:13px system-ui;color:#cfe3f5}
.ovbar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.ovbar button{background:#1e2630;color:#cfe3f5;border:1px solid #2e3a48;border-radius:6px;
padding:6px 10px;cursor:pointer;font:12px system-ui}
.ovbar button:hover{background:#27313d}
.ovbar button.on{background:#2f6ea8;border-color:#3d86c6}
.ovrow{display:flex;gap:10px;align-items:flex-start}
#ovcanvas{flex:1;height:HEIGHTpx;min-height:380px;background:#14181d;border-radius:8px}
#ovlist{width:250px;height:HEIGHTpx;overflow:auto;background:#161b21;border-radius:8px;padding:8px}
#ovlist div{padding:3px 4px;border-radius:4px;cursor:pointer;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
#ovlist div:hover{background:#222c36}
#ovlist input{margin-right:6px}
.ovstat{margin-top:6px;color:#8fa8bf;font-size:12px}
</style>
<div class="ovwrap">
<div class="ovbar">
<button onclick="ovFit()">Вписать</button>
<button onclick="ovView('iso')">Изометрия</button>
<button onclick="ovView('front')">Спереди</button>
<button onclick="ovView('top')">Сверху</button>
<button onclick="ovView('side')">Сбоку</button>
<button id="bEdges" onclick="ovEdges()">Рёбра</button>
<button id="bWire" onclick="ovWire()">Каркас</button>
<button id="bBg" onclick="ovBg()">Светлый фон</button>
<button onclick="ovAll(true)">Показать все</button>
<button onclick="ovAll(false)">Скрыть все</button>
<button onclick="ovShot()">Снимок PNG</button>
<button id="bAxes" onclick="ovAxes()">Оси и сетка</button>
<button onclick="ovReset()">Сброс</button>
<button onclick="ovIsolateSel()">Изолировать выбранную</button>
<button onclick="ovHideSel()">Скрыть выбранную</button>
</div>
<div class="ovbar">
<span style="align-self:center">Разлёт</span>
<input id="sExp" type="range" min="0" max="150" value="0" oninput="ovExplode(this.value)">
<span style="align-self:center">Прозрачность</span>
<input id="sOpa" type="range" min="10" max="100" value="100" oninput="ovOpacity(this.value)">
<span style="align-self:center">Сечение</span>
<select id="clipAx" onchange="ovClip()">
<option value="-1">нет</option><option value="0">по X</option>
<option value="1">по Y</option><option value="2">по Z</option>
</select>
<input id="sClip" type="range" min="-100" max="100" value="0" oninput="ovClip()">
<button onclick="ovClipFlip()">Другая сторона</button>
<button onclick="ovDiag()">Диагностика</button>
</div>
<div class="ovrow">
<div id="ovcanvas"></div>
<div id="ovlist">детали появятся после загрузки…</div>
</div>
<div class="ovstat" id="ovstat">загрузка модели…</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/online-3d-viewer@0.16.0/build/engine/o3dv.min.js"></script>
<script type="module">
import * as T from 'https://unpkg.com/three@0.160.0/build/three.module.js';
window.T3 = T;
</script>
<script>
let ov=null, model=null, hidden=new Set(), edgesOn=true, wireOn=false, light=false;
let SCENE=null, CAM=null, REND=null, MESHES=[], HOME=[], selIdx=-1, selObj=null, selMat=null;
let axesObj=null, clipSign=1;
const stat = t => { const e=document.getElementById('ovstat'); if(e) e.textContent=t; };
function boot(){
const el = document.getElementById('ovcanvas');
ov = new OV.EmbeddedViewer(el, {
backgroundColor: new OV.RGBAColor(20,24,29,255),
defaultColor: new OV.RGBColor(150,170,190),
edgeSettings: new OV.EdgeSettings(true, new OV.RGBColor(34,48,64), 35),
onModelLoaded: () => { model = ov.GetModel(); setTimeout(discover, 300); }
});
ov.LoadModelFromUrlList(['MODELURL']);
}
// ---- автопоиск сцены, камеры и рендерера внутри движка ----
function discover(){
SCENE=null; CAM=null; REND=null; MESHES=[]; HOME=[];
const seen = new Set();
function scan(o, d){
if (!o || d>3 || typeof o!=='object' || seen.has(o)) return;
seen.add(o);
try{
if (!SCENE && o.isScene) SCENE=o;
if (!CAM && o.isCamera) CAM=o;
if (!REND && o.domElement && o.render && o.setSize) REND=o;
for (const k in o){ if (SCENE&&CAM&&REND) return; scan(o[k], d+1); }
}catch(e){}
}
try{ scan(ov.GetViewer(), 0); }catch(e){}
if (!SCENE) { const c=document.querySelector('#ovcanvas canvas');
stat('движок загружен, но сцена не найдена'); }
if (SCENE) SCENE.traverse(o => { if (o.isMesh){ MESHES.push(o); HOME.push(o.position.clone()); } });
stat('загружено · деталей: ' + (model?model.MeshCount():'?') +
' · сеток в сцене: ' + MESHES.length +
(CAM?' · камера ок':' · камера не найдена') + (REND?' · рендер ок':''));
buildList(); bindPick();
setTimeout(() => { ovView('iso'); }, 150);
}
function draw(){ try{ if (REND && SCENE && CAM) REND.render(SCENE, CAM);
else ov.GetViewer().Render(); }catch(e){} }
function buildList(){
const box = document.getElementById('ovlist'); if(!box) return; box.innerHTML='';
const n = MESHES.length || (model?model.MeshCount():0);
for (let i=0;i<n;i++){
let nm = '';
try{ nm = (MESHES[i] && MESHES[i].name) || ''; }catch(e){}
if (!nm) { try{ nm = model.GetMesh(i).GetName() || ''; }catch(e){} }
if (!nm) nm = 'деталь '+(i+1);
const row = document.createElement('div');
row.innerHTML = '<input type="checkbox" checked data-i="'+i+'"> ' + nm;
row.querySelector('input').onchange = e => {
const k = parseInt(e.target.dataset.i);
e.target.checked ? hidden.delete(k) : hidden.add(k);
applyVis();
};
row.ondblclick = () => ovIsolate(i);
row.title = 'двойной клик — только эта деталь';
box.appendChild(row);
}
}
function applyVis(){
MESHES.forEach((o,i)=>{ o.visible = !hidden.has(i); });
draw();
}
function ovAll(v){
hidden.clear();
if(!v) MESHES.forEach((_,i)=>hidden.add(i));
document.querySelectorAll('#ovlist input').forEach(x=>x.checked=v);
applyVis();
}
function ovIsolate(i){
hidden.clear(); MESHES.forEach((_,k)=>{ if(k!==i) hidden.add(k); });
document.querySelectorAll('#ovlist input').forEach((x,k)=>x.checked=(k===i));
applyVis(); highlight(MESHES[i]); selIdx=i;
let nm=(MESHES[i]&&MESHES[i].name)||'';
if(!nm){ try{ nm=model.GetMesh(i).GetName()||''; }catch(e){} }
if(!nm) nm='деталь '+(i+1);
stat('показана только: '+nm);
}
function ovIsolateSel(){ selIdx>=0 ? ovIsolate(selIdx) : stat('сначала кликните по детали'); }
function ovHideSel(){
if(selIdx<0){ stat('сначала кликните по детали'); return; }
hidden.add(selIdx);
const inp=document.querySelectorAll('#ovlist input')[selIdx]; if(inp) inp.checked=false;
applyVis(); stat('скрыта деталь '+(selIdx+1));
}
// ---- выбор кликом ----
function highlight(o){
if(!o) return;
try{
if (selObj && selMat) selObj.material = selMat;
selObj=o; selMat=o.material;
const m = o.material.clone();
if (m.color) m.color.set('#ff8c32');
if (m.emissive) m.emissive.set('#5a3000');
o.material = m; draw();
}catch(e){}
}
function bindPick(){
const dom = REND ? REND.domElement : document.querySelector('#ovcanvas canvas');
if (!dom){ stat('канва не найдена — выбор кликом недоступен'); return; }
dom.addEventListener('pointerdown', ev => {
if (ev.button!==0 || !CAM || !window.T3) return;
const b = dom.getBoundingClientRect();
const m = new T3.Vector2(((ev.clientX-b.left)/b.width)*2-1,
-((ev.clientY-b.top)/b.height)*2+1);
const rc = new T3.Raycaster(); rc.setFromCamera(m, CAM);
const hits = rc.intersectObjects(MESHES.filter(o=>o.visible), true);
if (!hits.length) return;
let o = hits[0].object; let k = MESHES.indexOf(o);
while (k<0 && o.parent){ o=o.parent; k=MESHES.indexOf(o); }
if (k<0) return;
selIdx=k; highlight(MESHES[k]);
let nm=(MESHES[k]&&MESHES[k].name)||'';
if(!nm){ try{ nm=model.GetMesh(k).GetName()||''; }catch(e){} }
if(!nm) nm='деталь '+(k+1);
stat('выбрана: '+nm);
const rows=document.querySelectorAll('#ovlist div');
rows.forEach((r,i)=> r.style.background=(i===k?'#2f6ea8':''));
if (rows[k]) rows[k].scrollIntoView({block:'nearest'});
});
}
// ---- виды и оформление ----
function bsphere(){
try{ const T=window.T3, box=new T.Box3();
MESHES.forEach(o=>{ if(o.visible) box.expandByObject(o); });
const c=box.getCenter(new T.Vector3()), s=box.getSize(new T.Vector3());
return {c:c, r:Math.max(s.x,s.y,s.z)/2 || 100}; }
catch(e){ return {c:{x:0,y:0,z:0}, r:100}; }
}
function ovFit(){ ovView('iso'); }
function ovView(k){
if(!CAM){ stat('камера не найдена'); return; }
const b=bsphere(), r=b.r*3.0;
const P={iso:[r*0.7,-r*0.8,r*0.6], front:[0,-r,0], top:[0,0,r], side:[r,0,0]}[k];
try{
CAM.position.set(b.c.x+P[0], b.c.y+P[1], b.c.z+P[2]);
CAM.up.set(0,0,1); CAM.lookAt(b.c.x,b.c.y,b.c.z);
if (CAM.updateProjectionMatrix) CAM.updateProjectionMatrix();
draw();
}catch(e){ stat('вид: '+e); }
}
function ovEdges(){
edgesOn=!edgesOn;
document.getElementById('bEdges').classList.toggle('on', edgesOn);
try{ ov.GetViewer().SetEdgeSettings(new OV.EdgeSettings(edgesOn,
new OV.RGBColor(34,48,64), 35)); }catch(e){ stat('рёбра недоступны'); }
}
function ovWire(){
wireOn=!wireOn;
document.getElementById('bWire').classList.toggle('on', wireOn);
MESHES.forEach(o=>{ if(o.material) o.material.wireframe=wireOn; }); draw();
}
function ovBg(){
light=!light;
document.getElementById('bBg').classList.toggle('on', light);
try{ if (SCENE && window.T3) SCENE.background = new T3.Color(light?0xf5f6f8:0x14181d); draw(); }
catch(e){}
}
function ovShot(){
try{ const dom = REND?REND.domElement:document.querySelector('#ovcanvas canvas');
draw();
const a=document.createElement('a'); a.href=dom.toDataURL('image/png');
a.download='recada_view.png'; a.click(); }
catch(e){ stat('снимок: '+e); }
}
function ovExplode(v){
const k=parseFloat(v)/100.0, b=bsphere();
try{
MESHES.forEach((o,i)=>{
o.geometry.computeBoundingBox();
const mid=o.geometry.boundingBox.getCenter(new T3.Vector3());
const p=o.localToWorld(mid.clone());
const d={x:p.x-b.c.x, y:p.y-b.c.y, z:p.z-b.c.z};
const n=Math.hypot(d.x,d.y,d.z)||1;
o.position.copy(HOME[i]);
o.position.x+=d.x/n*k*b.r; o.position.y+=d.y/n*k*b.r; o.position.z+=d.z/n*k*b.r;
});
draw();
}catch(e){ stat('разлёт: '+e); }
}
function ovOpacity(v){
const a=parseFloat(v)/100.0;
MESHES.forEach(o=>{ if(o.material){ o.material.transparent=a<0.99; o.material.opacity=a;
o.material.depthWrite=a>0.99; o.material.needsUpdate=true; }});
draw();
}
function ovClip(){
const ax=parseInt(document.getElementById('clipAx').value);
const t=parseFloat(document.getElementById('sClip').value)/100.0;
const b=bsphere();
try{
if (!REND){ stat('рендерер не найден — сечение недоступно'); return; }
if (ax<0){ REND.localClippingEnabled=false;
MESHES.forEach(o=>{ if(o.material) o.material.clippingPlanes=null; }); draw(); return; }
REND.localClippingEnabled=true;
const nv=[[1,0,0],[0,1,0],[0,0,1]][ax];
const n=new T3.Vector3(nv[0]*clipSign, nv[1]*clipSign, nv[2]*clipSign);
const pos=[b.c.x,b.c.y,b.c.z][ax]+t*b.r;
const pl=new T3.Plane(n, -pos*clipSign);
MESHES.forEach(o=>{ if(o.material) o.material.clippingPlanes=[pl]; });
draw();
}catch(e){ stat('сечение: '+e); }
}
function ovClipFlip(){ clipSign=-clipSign; ovClip(); }
function ovAxes(){
try{
if (!SCENE || !window.T3){ stat('сцена не найдена'); return; }
if (axesObj){ SCENE.remove(axesObj); axesObj=null; }
else { const b=bsphere(); axesObj=new T3.Group();
axesObj.add(new T3.AxesHelper(b.r*0.8));
const g=new T3.GridHelper(b.r*4, 20, 0x2a3540, 0x1e262e); g.rotation.x=Math.PI/2;
axesObj.add(g); axesObj.position.set(b.c.x,b.c.y,b.c.z-b.r); SCENE.add(axesObj); }
document.getElementById('bAxes').classList.toggle('on', !!axesObj); draw();
}catch(e){ stat('оси: '+e); }
}
function ovReset(){
hidden.clear(); document.querySelectorAll('#ovlist input').forEach(x=>x.checked=true);
applyVis();
document.getElementById('sExp').value=0; ovExplode(0);
document.getElementById('sOpa').value=100; ovOpacity(100);
document.getElementById('clipAx').value=-1; ovClip();
ovFit(); stat('сброшено');
}
function ovDiag(){
stat('сцена:'+(SCENE?'да':'нет')+' камера:'+(CAM?'да':'нет')+
' рендерер:'+(REND?'да':'нет')+' сеток:'+MESHES.length+
' three:'+(window.T3?'да':'нет'));
}
if (window.OV) boot(); else window.addEventListener('load', boot);
</script>
""".replace("MODELURL", model_url).replace("HEIGHT", str(height))
# ================= ИЗДЕЛИЕ AIRHOLDER =================
import numpy as np
from OCP.gp import gp_Pnt, gp_Vec, gp_Trsf, gp_Ax1, gp_Dir
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.TopoDS import TopoDS_Compound
from OCP.BRep import BRep_Builder
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
PROFILE_H = 33.0 # высота алюминиевого профиля платформы
PROFILE_W = 40.0
RHO_ALU = 2.70e-6 # кг/мм3
RHO_STEEL = 7.90e-6
def _box(x, y, z, dx, dy, dz):
return BRepPrimAPI_MakeBox(gp_Pnt(x, y, z), gp_Pnt(x + dx, y + dy, z + dz)).Shape()
def _move(sh, dx, dy, dz):
t = gp_Trsf(); t.SetTranslation(gp_Vec(dx, dy, dz))
return BRepBuilderAPI_Transform(sh, t, True).Shape()
def _rotz(sh, deg, cx=0, cy=0):
t = gp_Trsf()
t.SetRotation(gp_Ax1(gp_Pnt(cx, cy, 0), gp_Dir(0, 0, 1)), np.radians(deg))
return BRepBuilderAPI_Transform(sh, t, True).Shape()
def build_product(P):
"""P: dict(length, width, height, crossbars, leg_t, mounts_per_side)
-> (список (имя, форма), метрики)"""
from recada import make_bent_part
L, W, H = P["length"], P["width"], P["height"]
n_cb = int(P["crossbars"])
t = P.get("leg_t", 3.0)
n_m = int(P.get("mounts_per_side", 3))
parts = []
# платформа: два продольных профиля + поперечины
for name, y in (("профиль левый", 0.0), ("профиль правый", W - PROFILE_W)):
parts.append((name, _box(0, y, 0, L, PROFILE_W, PROFILE_H)))
if n_cb > 1:
step = (L - PROFILE_W) / (n_cb - 1)
else:
step = 0
for i in range(n_cb):
x = i * step if n_cb > 1 else (L - PROFILE_W) / 2
parts.append(("поперечина %d" % (i + 1),
_box(x, PROFILE_W, 0, PROFILE_W, W - 2 * PROFILE_W, PROFILE_H)))
# ноги: Г-образные гнутые, вертикаль = H, горизонтальная полка под профиль
FOOT = 60.0
leg = make_bent_part([("flat", H + PROFILE_H), ("bend", 90, 2 * t), ("flat", FOOT)], 50.0, t)
# make_bent_part строит в своей СК: разворачиваем стоймя
from OCP.gp import gp_Ax1 as _A1
tr = gp_Trsf()
tr.SetRotation(_A1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), np.radians(90))
leg0 = BRepBuilderAPI_Transform(leg.wrapped if hasattr(leg, "wrapped") else leg,
tr, True).Shape()
xs = [L * (i + 1) / (n_m + 1) for i in range(n_m)]
k = 0
for side, y in (("Л", 8.0), ("П", W - 8.0)):
for x in xs:
k += 1
lg = leg0
if side == "П":
lg = _rotz(lg, 180)
lg = _move(lg, 0, 0, 0)
lg = _move(lg, x - 25.0, y, -(H))
parts.append(("нога %s%d" % (side, (k - 1) % n_m + 1), lg))
# метрики
mass = 0.0
for nm, sh in parts:
p = GProp_GProps(); BRepGProp.VolumeProperties_s(sh, p)
rho = RHO_STEEL if nm.startswith("нога") else RHO_ALU
mass += abs(p.Mass()) * rho
total_h = H + PROFILE_H
checks = {
"полная высота над крышей, мм": round(total_h, 1),
"цель < 70 мм (конкурент)": "да" if total_h < 70 else "НЕТ",
"длина ≤ 2000 (GLS)": "да" if L <= 2000 else "НЕТ",
"масса, кг": round(mass, 1),
"вес коробки ≤ 40 кг": "да" if mass <= 40 else "НЕТ",
"деталей": len(parts),
}
return parts, checks
# ============ СВОЙ ПРОСМОТРЩИК НА THREE.JS ============
import base64, json, zlib
import numpy as np
def assembly_payload(parts, deflection=0.8, colors=None, highlight=None):
"""parts: [(имя, shape)] -> компактный payload. colors: hex по деталям, highlight: индексы."""
out = []
for nm, sh in parts:
try:
v, t, f = mesh_by_face(sh, deflection, 0.6)
except Exception:
continue
if len(v) == 0 or len(t) == 0:
continue
out.append({
"n": nm,
"c": (colors[len(out)] if colors and len(out) < len(colors) else 0x8fa8bf),
"hl": bool(highlight and len(out) in highlight),
"v": base64.b64encode(np.asarray(v, np.float32).tobytes()).decode(),
"i": base64.b64encode(np.asarray(t, np.uint32).tobytes()).decode(),
"nv": len(v), "nt": len(t),
})
return out
def viewer3_html(payload, height=560, show_list=True):
data = base64.b64encode(zlib.compress(json.dumps(payload).encode(), 6)).decode()
return r"""
<style>
.v3{font:13px system-ui;color:#cfe3f5}
.v3bar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;align-items:center}
.v3bar button{background:#1e2630;color:#cfe3f5;border:1px solid #2e3a48;border-radius:6px;
padding:5px 9px;cursor:pointer;font:12px system-ui}
.v3bar button:hover{background:#27313d}
.v3bar button.on{background:#2f6ea8;border-color:#3d86c6}
.v3bar input[type=range]{width:110px}
.v3row{display:flex;gap:8px}
#c3{flex:1;height:HEIGHTpx;background:#14181d;border-radius:8px}
#l3{width:300px;height:HEIGHTpx;overflow:auto;background:#161b21;border-radius:8px;padding:6px}
#l3 div{padding:3px 5px;border-radius:4px;cursor:pointer;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
#l3 div:hover{background:#222c36}
.v3st{margin-top:5px;color:#8fa8bf;font-size:12px}
</style>
<div class="v3">
<div class="v3bar">
<button onclick="V.fit()">Вписать</button>
<button onclick="V.view('iso')">Изо</button>
<button onclick="V.view('front')">Спереди</button>
<button onclick="V.view('top')">Сверху</button>
<button onclick="V.view('side')">Сбоку</button>
<button id="b_edge" class="on" onclick="V.edges()">Рёбра</button>
<button id="b_wire" onclick="V.wire()">Каркас</button>
<button id="b_grid" onclick="V.grid()">Сетка</button>
<button id="b_bg" onclick="V.bg()">Светлый</button>
<button onclick="V.shot()">PNG</button>
</div>
<div class="v3bar">
<button onclick="V.iso()">Изолировать</button>
<button onclick="V.hide()">Скрыть</button>
<button onclick="V.all(true)">Все</button>
<button onclick="V.reset()">Сброс</button>
<span>Разлёт</span><input type="range" id="s_exp" min="0" max="150" value="0"
oninput="V.explode(this.value)">
<span>Прозр.</span><input type="range" id="s_opa" min="15" max="100" value="100"
oninput="V.opacity(this.value)">
<span>Сечение</span>
<select id="s_ax" onchange="V.clip()"><option value="-1">нет</option>
<option value="0">X</option><option value="1">Y</option><option value="2">Z</option></select>
<input type="range" id="s_pos" min="-100" max="100" value="0" oninput="V.clip()">
<button onclick="V.flip()">↔</button>
<button id="b_meas" onclick="V.measure()">Измерить</button>
<button id="b_multi" onclick="V.multi()">Мультивыбор</button>
<button id="b_pin" onclick="V.pin()">Точки крепления</button>
<button onclick="V.copySel()">Копировать выбор</button>
<button onclick="V.copyPins()">Копировать точки</button>
<button onclick="V.clearPins()">Очистить точки</button>
</div>
<div class="v3bar">
<input id="out_sel" style="flex:1;background:#101418;color:#9fd0ff;border:1px solid #2e3a48;
border-radius:6px;padding:5px 8px;font:12px monospace" readonly
placeholder="выбранные детали появятся здесь">
</div>
<div class="v3row"><div id="c3"></div><div id="l3" style="display:LISTDISP"></div></div>
<div class="v3st" id="st3">загрузка…</div>
</div>
<script src="https://unpkg.com/pako@2.1.0/dist/pako.min.js"></script>
<script type="importmap">
{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const RAW = "PAYLOAD";
const st = t => document.getElementById('st3').textContent = t;
const b64 = s => { const b = atob(s), a = new Uint8Array(b.length);
for (let i=0;i<b.length;i++) a[i]=b.charCodeAt(i); return a; };
const parts = JSON.parse(pako.inflate(b64(RAW), {to:'string'}));
const wrap = document.getElementById('c3');
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x14181d);
const cam = new THREE.PerspectiveCamera(45, wrap.clientWidth/HEIGHT, 0.1, 1e6);
const rnd = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
rnd.setSize(wrap.clientWidth, HEIGHT); rnd.setPixelRatio(devicePixelRatio);
rnd.localClippingEnabled = true;
wrap.appendChild(rnd.domElement);
const ctl = new OrbitControls(cam, rnd.domElement); ctl.enableDamping = true;
scene.add(new THREE.AmbientLight(0xffffff, .55));
const L1 = new THREE.DirectionalLight(0xffffff, 1.0); L1.position.set(1,-1,1); scene.add(L1);
const L2 = new THREE.DirectionalLight(0xffffff, .45); L2.position.set(-1,1,-.5); scene.add(L2);
const meshes = [], edges = [], home = [];
let selSet = new Set();
const root = new THREE.Group(); scene.add(root);
parts.forEach((p, i) => {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(
new Float32Array(b64(p.v).buffer), 3));
g.setIndex(new THREE.BufferAttribute(new Uint32Array(b64(p.i).buffer), 1));
g.computeVertexNormals(); g.computeBoundingBox();
const base = (p.c !== undefined ? p.c : 0x8fa8bf);
if (p.hl) selSet.add(i);
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial(
{color:base, metalness:.3, roughness:.5, side:THREE.DoubleSide}));
m.userData = {i:i, name:p.n, base:base};
root.add(m); meshes.push(m); home.push(m.position.clone());
const e = new THREE.LineSegments(new THREE.EdgesGeometry(g, 25),
new THREE.LineBasicMaterial({color:0x223040}));
root.add(e); edges.push(e);
});
const box = new THREE.Box3().setFromObject(root);
const C = box.getCenter(new THREE.Vector3()), S = box.getSize(new THREE.Vector3());
const R = Math.max(S.x,S.y,S.z)/2 || 100;
const list = document.getElementById('l3');
// группировка одинаковых имён
const gmap = new Map();
parts.forEach((p,i) => {
const base = (p.n||('деталь '+(i+1))).replace(/\s*\[\d+\]\s*$/, '');
if (!gmap.has(base)) gmap.set(base, []);
gmap.get(base).push(i);
});
const groups = [...gmap.entries()];
groups.forEach(([base, idxs], gi) => {
const d = document.createElement('div');
const cnt = idxs.length > 1 ? (' ×' + idxs.length) : '';
d.innerHTML = '<input type="checkbox" checked data-g="'+gi+'"> ' + base +
'<span style="color:#7f96ad">' + cnt + '</span>';
d.querySelector('input').onchange = e => {
const on = e.target.checked;
groups[+e.target.dataset.g][1].forEach(k => {
meshes[k].visible = on; edges[k].visible = on && edgeOn; });
};
d.onclick = e => { if (e.target.tagName!=='INPUT') selectGroup(gi); };
d.ondblclick = () => { selectGroup(gi); V.isoGroup(gi); };
d.title = base + (idxs.length>1 ? (' — экземпляров: '+idxs.length) : '');
list.appendChild(d);
});
function groupOf(i){ return groups.findIndex(g => g[1].includes(i)); }
function selectGroup(gi){
selSet.clear();
groups[gi][1].forEach(k => selSet.add(k));
meshes.forEach((m,k)=> m.material.color.set(selSet.has(k)?0xff8c32:m.userData.base));
[...list.children].forEach((r,k)=> r.style.background = (k===gi?'#2f6ea8':''));
sel = groups[gi][1][0];
const bb = new THREE.Box3(); groups[gi][1].forEach(k=>bb.expandByObject(meshes[k]));
const s = bb.getSize(new THREE.Vector3());
document.getElementById('out_sel').value = [...selSet].sort((a,b)=>a-b).join(',');
st(groups[gi][0] + (groups[gi][1].length>1?(' ×'+groups[gi][1].length):'') +
' · габарит ' + s.x.toFixed(1)+' × '+s.y.toFixed(1)+' × '+s.z.toFixed(1)+' мм');
}
let sel = -1, edgeOn = true, wireOn = false, gridOn = false, lightBg = false;
let multiMode = false, pinMode = false, pins = [], pinObjs = [];
meshes.forEach((m,k)=>{ if (selSet.has(k)) m.material.color.set(0xff8c32); });
if (selSet.size) document.getElementById('out_sel').value = [...selSet].sort((a,b)=>a-b).join(',');
let gridObj = null, clipSign = 1, measMode = false, measPts = [], measLine = null;
function select(i){
if (sel >= 0) meshes[sel].material.color.set(meshes[sel].userData.base);
sel = i; meshes[i].material.color.set(0xff8c32);
[...list.children].forEach((r,k)=> r.style.background = (k===i?'#2f6ea8':''));
list.children[i].scrollIntoView({block:'nearest'});
const bb = new THREE.Box3().setFromObject(meshes[i]), s = bb.getSize(new THREE.Vector3());
st('выбрана: ' + (parts[i].n||('деталь '+(i+1))) +
' · габарит ' + s.x.toFixed(1)+' × '+s.y.toFixed(1)+' × '+s.z.toFixed(1)+' мм' +
' · треугольников ' + parts[i].nt);
}
rnd.domElement.addEventListener('dblclick', ev => {
ev.preventDefault();
const b = rnd.domElement.getBoundingClientRect();
const mv = new THREE.Vector2(((ev.clientX-b.left)/b.width)*2-1,
-((ev.clientY-b.top)/b.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(mv, cam);
const hit = rc.intersectObjects(meshes.filter(m=>m.visible));
if (!hit.length) return;
if (pinMode){
const p = hit[0].point.clone();
pins.push(p);
const s = new THREE.Mesh(new THREE.SphereGeometry(R*0.012, 16, 12),
new THREE.MeshBasicMaterial({color:0x35d07f}));
s.position.copy(p); scene.add(s); pinObjs.push(s);
document.getElementById('out_sel').value =
pins.map(q=>q.x.toFixed(1)+','+q.y.toFixed(1)+','+q.z.toFixed(1)).join(';');
st('точек крепления: ' + pins.length + ' (кнопка «Копировать точки»)');
return;
}
if (measMode){
measPts.push(hit[0].point.clone());
if (measPts.length === 2){
const d = measPts[0].distanceTo(measPts[1]);
st('расстояние: ' + d.toFixed(2) + ' мм');
if (measLine) scene.remove(measLine);
measLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(measPts),
new THREE.LineBasicMaterial({color:0xff8c32}));
scene.add(measLine); measPts = [];
} else st('первая точка поставлена — кликните вторую');
return;
}
const idx = hit[0].object.userData.i;
if (!multiMode && !ev.ctrlKey && !ev.shiftKey){
const gi = groupOf(idx);
if (gi >= 0){ selectGroup(gi); list.children[gi].scrollIntoView({block:'nearest'}); return; }
}
if (multiMode || ev.ctrlKey || ev.shiftKey){
if (selSet.has(idx)){ selSet.delete(idx); meshes[idx].material.color.set(meshes[idx].userData.base); }
else { selSet.add(idx); meshes[idx].material.color.set(0xff8c32); }
const arr = [...selSet].sort((a,b)=>a-b);
document.getElementById('out_sel').value = arr.join(',');
[...list.children].forEach((r,k)=> r.style.background = (selSet.has(k)?'#2f6ea8':''));
st('выбрано деталей: ' + arr.length + ' (кнопка «Копировать выбор»)');
return;
}
select(idx);
});
window.V = {
fit(){ this.view('iso'); },
view(k){
const r = R*3.2;
const P = {iso:[r*.7,-r*.8,r*.6], front:[0,-r,0], top:[0,0,r], side:[r,0,0]}[k];
cam.position.set(C.x+P[0], C.y+P[1], C.z+P[2]); cam.up.set(0,0,1);
ctl.target.copy(C); cam.lookAt(C); ctl.update();
},
edges(){ edgeOn=!edgeOn; document.getElementById('b_edge').classList.toggle('on',edgeOn);
edges.forEach((e,i)=> e.visible = edgeOn && meshes[i].visible); },
wire(){ wireOn=!wireOn; document.getElementById('b_wire').classList.toggle('on',wireOn);
meshes.forEach(m=> m.material.wireframe=wireOn); },
grid(){ gridOn=!gridOn; document.getElementById('b_grid').classList.toggle('on',gridOn);
if (gridObj){ scene.remove(gridObj); gridObj=null; }
else { gridObj = new THREE.Group();
gridObj.add(new THREE.AxesHelper(R*.8));
const g = new THREE.GridHelper(R*4, 20, 0x2a3540, 0x1e262e); g.rotation.x=Math.PI/2;
gridObj.add(g); gridObj.position.set(C.x,C.y,C.z-R); scene.add(gridObj); } },
bg(){ lightBg=!lightBg; document.getElementById('b_bg').classList.toggle('on',lightBg);
scene.background = new THREE.Color(lightBg?0xf5f6f8:0x14181d); },
shot(){ rnd.render(scene,cam); const a=document.createElement('a');
a.href=rnd.domElement.toDataURL('image/png'); a.download='view.png'; a.click(); },
isoGroup(gi){ const ids=groups[gi][1];
meshes.forEach((m,i)=>{ const on=ids.includes(i); m.visible=on; edges[i].visible=on&&edgeOn; });
[...list.querySelectorAll('input')].forEach((x,k)=> x.checked=(k===gi));
st('изолировано: '+groups[gi][0]); },
iso(){ if (sel<0){ st('сначала выберите деталь'); return; }
meshes.forEach((m,i)=>{ m.visible=(i===sel); edges[i].visible=(i===sel)&&edgeOn; });
[...list.querySelectorAll('input')].forEach((x,i)=> x.checked=(i===sel));
st('изолирована: ' + (parts[sel].n||('деталь '+(sel+1)))); },
hide(){ if (sel<0){ st('сначала выберите деталь'); return; }
meshes[sel].visible=false; edges[sel].visible=false;
list.querySelectorAll('input')[sel].checked=false; },
all(v){ meshes.forEach((m,i)=>{ m.visible=v; edges[i].visible=v&&edgeOn; });
[...list.querySelectorAll('input')].forEach(x=>x.checked=v); },
explode(v){ const k=+v/100;
meshes.forEach((m,i)=>{ const bb=new THREE.Box3().setFromObject(m);
const c=bb.getCenter(new THREE.Vector3()).sub(C);
const n=c.length()||1; const off=c.multiplyScalar(k*R/n);
m.position.copy(home[i]).add(off); edges[i].position.copy(m.position); }); },
opacity(v){ const a=+v/100;
meshes.forEach(m=>{ m.material.transparent=a<.99; m.material.opacity=a;
m.material.depthWrite=a>.99; }); },
clip(){ const ax=+document.getElementById('s_ax').value;
const t=+document.getElementById('s_pos').value/100;
if (ax<0){ meshes.forEach(m=>m.material.clippingPlanes=null); return; }
const nv=[[1,0,0],[0,1,0],[0,0,1]][ax];
const n=new THREE.Vector3(nv[0]*clipSign,nv[1]*clipSign,nv[2]*clipSign);
const pos=[C.x,C.y,C.z][ax]+t*R;
const pl=new THREE.Plane(n, -pos*clipSign);
meshes.forEach(m=>m.material.clippingPlanes=[pl]); },
flip(){ clipSign=-clipSign; this.clip(); },
measure(){ measMode=!measMode; measPts=[];
document.getElementById('b_meas').classList.toggle('on',measMode);
st(measMode?'режим измерения: двойной клик по двум точкам':'измерение выключено'); },
multi(){ multiMode=!multiMode; if(multiMode) pinMode=false;
document.getElementById('b_multi').classList.toggle('on',multiMode);
document.getElementById('b_pin').classList.remove('on');
st(multiMode?'мультивыбор: двойной клик по деталям (или Ctrl+двойной клик)':'мультивыбор выключен'); },
pin(){ pinMode=!pinMode; if(pinMode) multiMode=false;
document.getElementById('b_pin').classList.toggle('on',pinMode);
document.getElementById('b_multi').classList.remove('on');
st(pinMode?'точки крепления: двойной клик по модели, где нога стоит на крыше'
:'режим точек выключен'); },
copySel(){ const v=document.getElementById('out_sel').value;
navigator.clipboard.writeText(v).then(()=>st('скопировано: '+v)); },
copyPins(){ const v=pins.map(q=>q.x.toFixed(2)+','+q.y.toFixed(2)+','+q.z.toFixed(2)).join(';');
navigator.clipboard.writeText(v).then(()=>st('точки скопированы: '+pins.length)); },
clearPins(){ pinObjs.forEach(o=>scene.remove(o)); pinObjs=[]; pins=[];
document.getElementById('out_sel').value=''; st('точки очищены'); },
reset(){ this.all(true); selSet.clear(); document.getElementById('s_exp').value=0; this.explode(0);
document.getElementById('s_opa').value=100; this.opacity(100);
document.getElementById('s_ax').value=-1; this.clip(); this.fit(); }
};
V.fit();
st('готово · деталей: ' + parts.length + ' · треугольников: ' +
parts.reduce((s,p)=>s+p.nt,0).toLocaleString('ru') +
' · выбор — двойной клик, вращение — обычное перетаскивание');
(function loop(){ requestAnimationFrame(loop); ctl.update(); rnd.render(scene,cam); })();
addEventListener('resize', ()=>{ rnd.setSize(wrap.clientWidth, HEIGHT);
cam.aspect=wrap.clientWidth/HEIGHT; cam.updateProjectionMatrix(); });
</script>
""".replace("PAYLOAD", data).replace("HEIGHT", str(height)).replace("LISTDISP", "block" if show_list else "none")
# ================= РАЗБОР СБОРКИ =================
import numpy as np
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID, TopAbs_FACE, TopAbs_SHELL
from OCP.TopoDS import TopoDS
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.TDocStd import TDocStd_Document
from OCP.XCAFDoc import XCAFDoc_DocumentTool
from OCP.TCollection import TCollection_ExtendedString
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.IFSelect import IFSelect_RetDone
def read_assembly(path):
"""Читает STEP-сборку через XCAF: имя + геометрия с накоплением положений
по дереву (иначе детали 'разлетаются'). -> [(имя, TopoDS_Shape), ...]"""
from OCP.TopLoc import TopLoc_Location
doc = TDocStd_Document(TCollection_ExtendedString("d"))
rd = STEPCAFControl_Reader()
rd.SetNameMode(True)
if rd.ReadFile(path) != IFSelect_RetDone:
return None
rd.Transfer(doc)
st = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
tops = TDF_LabelSequence()
st.GetFreeShapes(tops)
out = []
def name_of(label):
nm = TDataStd_Name()
if label.FindAttribute(TDataStd_Name.GetID_s(), nm):
s = nm.Get().ToExtString()
if s and s.strip():
return s.strip()
return None
def walk(label, loc, prefix=""):
# ссылка -> идём в исходную деталь, положение накапливаем
target = label
if st.IsReference_s(label):
from OCP.TDF import TDF_Label
ref = TDF_Label()
if st.GetReferredShape_s(label, ref):
target = ref
loc = loc.Multiplied(st.GetLocation_s(label))
nm = name_of(label) or name_of(target) or prefix or "деталь"
if st.IsAssembly_s(target):
sub = TDF_LabelSequence()
st.GetComponents_s(target, sub)
for i in range(1, sub.Length() + 1):
walk(sub.Value(i), loc, nm)
return
sh = st.GetShape_s(target)
if sh is None or sh.IsNull():
return
out.append((nm, sh.Moved(loc) if not loc.IsIdentity() else sh))
for i in range(1, tops.Length() + 1):
walk(tops.Value(i), TopLoc_Location())
return out
def split_solids(shape):
"""Разбивает форму на отдельные тела."""
res = []
ex = TopExp_Explorer(shape, TopAbs_SOLID)
while ex.More():
res.append(TopoDS.Solid_s(ex.Current())); ex.Next()
if not res:
ex = TopExp_Explorer(shape, TopAbs_SHELL)
while ex.More():
res.append(ex.Current()); ex.Next()
return res
def part_stats(sh):
p = GProp_GProps(); BRepGProp.VolumeProperties_s(sh, p)
b = Bnd_Box(); BRepBndLib.Add_s(sh, b)
x0, y0, z0, x1, y1, z1 = b.Get()
ex = TopExp_Explorer(sh, TopAbs_FACE); n = 0
while ex.More(): n += 1; ex.Next()
return dict(volume=p.Mass(), faces=n,
bbox=(round(x1-x0, 1), round(y1-y0, 1), round(z1-z0, 1)))
# ================= ОПЕРАЦИИ ИЗМЕНЕНИЯ =================
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec, gp_Ax1, gp_Ax2, gp_Trsf, gp_Pln
from OCP.BRepPrimAPI import (BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeHalfSpace,
BRepPrimAPI_MakeBox)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse, BRepAlgoAPI_Common
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform, BRepBuilderAPI_MakeFace
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet, BRepFilletAPI_MakeChamfer
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_AbscissaPoint
def _bbox(shape):
b = Bnd_Box(); BRepBndLib.Add_s(shape, b)
x0, y0, z0, x1, y1, z1 = b.Get()
return np.array([x0, y0, z0]), np.array([x1, y1, z1])
def mirror(shape, plane="yz", origin=None):
"""Зеркальная копия — для левой/правой ноги."""
lo, hi = _bbox(shape)
o = np.array(origin) if origin is not None else (lo + hi) / 2
nrm = {"yz": (1, 0, 0), "xz": (0, 1, 0), "xy": (0, 0, 1)}[plane]
t = gp_Trsf()
t.SetMirror(gp_Ax2(gp_Pnt(*o), gp_Dir(*nrm)))
return BRepBuilderAPI_Transform(shape, t, True).Shape()
def drill(shape, center, axis, diameter, depth=None):
"""Просверлить отверстие (насквозь, если depth не задан)."""
lo, hi = _bbox(shape)
L = depth or (float(np.linalg.norm(hi - lo)) * 2)
d = np.array(axis, float); d /= np.linalg.norm(d)
start = np.array(center, float) - d * L / 2
ax2 = gp_Ax2(gp_Pnt(*start), gp_Dir(*d))
cyl = BRepPrimAPI_MakeCylinder(ax2, diameter / 2.0, L).Shape()
return BRepAlgoAPI_Cut(shape, cyl).Shape()
def plug(shape, center, axis, diameter, length=None):
"""Заглушить отверстие (заполнить материалом)."""
lo, hi = _bbox(shape)
L = length or (float(np.linalg.norm(hi - lo)) * 2)
d = np.array(axis, float); d /= np.linalg.norm(d)
start = np.array(center, float) - d * L / 2
cyl = BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(*start), gp_Dir(*d)),
diameter / 2.0, L).Shape()
add = BRepAlgoAPI_Common(cyl, _solid_box(shape)).Shape()
return BRepAlgoAPI_Fuse(shape, add).Shape()
def _solid_box(shape, pad=0.0):
lo, hi = _bbox(shape)
return BRepPrimAPI_MakeBox(gp_Pnt(*(lo - pad)), gp_Pnt(*(hi + pad))).Shape()
def enlarge_holes(shape, holes, new_diameter):
"""Рассверлить перечисленные отверстия: holes = [(center, axis), ...]"""
res = shape
for c, a in holes:
res = drill(res, c, a, new_diameter)
return res
def trim(shape, point, normal, keep="negative"):
"""Обрезать деталь плоскостью."""
lo, hi = _bbox(shape)
size = float(np.linalg.norm(hi - lo)) * 2
n = np.array(normal, float); n /= np.linalg.norm(n)
if keep == "positive": n = -n
pl = gp_Pln(gp_Pnt(*point), gp_Dir(*n))
f = BRepBuilderAPI_MakeFace(pl, -size, size, -size, size).Face()
ref = gp_Pnt(*(np.array(point, float) - n * size * 0.5))
hs = BRepPrimAPI_MakeHalfSpace(f, ref).Solid()
return BRepAlgoAPI_Cut(shape, hs).Shape()
def round_edges(shape, radius, min_len=1.0, max_len=1e9, mode="fillet"):
"""Скругление или фаска на рёбрах в заданном диапазоне длин."""
mk = BRepFilletAPI_MakeFillet(shape) if mode == "fillet" else BRepFilletAPI_MakeChamfer(shape)
ex = TopExp_Explorer(shape, TopAbs_EDGE); n = 0
while ex.More():
e = TopoDS.Edge_s(ex.Current())
try:
L = GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e))
if min_len <= L <= max_len:
mk.Add(radius, e); n += 1
except Exception:
pass
ex.Next()
if n == 0:
return shape, 0
try:
mk.Build()
return (mk.Shape(), n) if mk.IsDone() else (shape, 0)
except Exception:
return shape, 0
# ================= РАСТЯЖЕНИЕ ДЕТАЛИ =================
# Разрез плоскостью, сдвиг части, вставка призмы сечения. Только булевы операции
# над телом — результат остаётся замкнутым солидом, годным для производства.
import numpy as np
from OCP.gp import gp_Pnt, gp_Vec, gp_Dir, gp_Trsf, gp_Pln, gp_Ax3
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
import numpy as np
from OCP.gp import gp_Pnt, gp_Vec, gp_Dir, gp_Trsf, gp_Pln, gp_Ax3
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakePrism
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
AXES = {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}
def _vol(s):
p = GProp_GProps(); BRepGProp.VolumeProperties_s(s, p); return p.Mass()
def stretch(shape, axis="z", cut=None, amount=10.0, out=None):
"""axis: 'x'/'y'/'z' или вектор (dx,dy,dz) — направление растяжения.
Для наклонных стенок направление должно лежать В плоскости стенки,
иначе в контуре появляется ступенька."""
d = np.array(AXES[axis] if isinstance(axis, str) else axis, float)
d = d / np.linalg.norm(d)
bb = Bnd_Box(); BRepBndLib.Add_s(shape, bb)
xmin, ymin, zmin, xmax, ymax, zmax = bb.Get()
lo = np.array([xmin, ymin, zmin]); hi = np.array([xmax, ymax, zmax])
pad = 10.0
c = float(np.dot(d, lo) + np.dot(d, hi)) / 2 if cut is None else cut
def halfspace(below):
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
n = gp_Dir(*(d if not below else -d))
origin = gp_Pnt(*(d * c))
pl = gp_Pln(origin, n)
size = float(np.linalg.norm(hi - lo)) + 2 * pad
f = BRepBuilderAPI_MakeFace(pl, -size, size, -size, size).Face()
ref = gp_Pnt(*(d * (c + (-size if below else size) * 0.5)))
return BRepPrimAPI_MakeHalfSpace(f, ref).Solid()
low = BRepAlgoAPI_Common(shape, halfspace(True)).Shape()
up = BRepAlgoAPI_Common(shape, halfspace(False)).Shape()
tr = gp_Trsf(); tr.SetTranslation(gp_Vec(*(d * amount)))
up_m = BRepBuilderAPI_Transform(up, tr, True).Shape()
# грани сечения: плоские, нормаль вдоль оси, лежат на уровне c
mids = []
ex = TopExp_Explorer(low, TopAbs_FACE)
while ex.More():
f = TopoDS.Face_s(ex.Current())
a = BRepAdaptor_Surface(f)
if a.GetType() == GeomAbs_Plane:
pl = a.Plane(); n = pl.Axis().Direction(); p = pl.Location()
nn = np.array([n.X(), n.Y(), n.Z()])
if abs(abs(float(np.dot(nn, d))) - 1) < 1e-3 and \
abs(float(np.dot(np.array([p.X(), p.Y(), p.Z()]), d)) - c) < 1e-3:
mids.append(f)
ex.Next()
res = low
for f in mids:
pr = BRepPrimAPI_MakePrism(f, gp_Vec(*(d * amount))).Shape()
res = BRepAlgoAPI_Fuse(res, pr).Shape()
res = BRepAlgoAPI_Fuse(res, up_m).Shape()
if out:
w = STEPControl_Writer(); w.Transfer(res, STEPControl_AsIs); w.Write(out)
return res, len(mids), _vol(low), _vol(up), _vol(res)
def guess_role(step_path):
"""Грубая классификация детали по геометрии: что это в багажнике."""
try:
shape = read_step(step_path)
st_ = part_stats(shape)
dx, dy, dz = st_["bbox"]
dims = sorted([dx, dy, dz], reverse=True)
vol = abs(st_["volume"])
nf = st_["faces"]
if dims[0] > 1500:
return "кузов", "габарит %.0f мм" % dims[0]
if vol < 4000 and nf < 60:
return "крепёж", "объём %.0f мм3" % vol
try:
m = measure(step_path)
t = m["thickness_from_bends"]
nb = len(m["bends"])
except Exception:
t, nb = None, 0
if nb >= 1 and t and 1.0 <= t <= 5.0:
return ("нога" if dims[0] > 150 else "кронштейн крыши",
"лист t=%.1f, гибов %d" % (t, nb))
# профиль: длинный, сечение постоянно
if dims[0] > 300 and dims[0] / max(dims[1], 1e-6) > 4:
return "поперечина", "длина %.0f, сечение %.0fx%.0f" % (dims[0], dims[1], dims[2])
if t and 1.0 <= t <= 5.0:
return "кронштейн крыши", "лист t=%.1f, без гибов" % t
return "—", "%d граней, %.0f мм3" % (nf, vol)
except Exception as e:
return "—", str(e)[:40]
def best_cut(step_path, axis, samples=25, tol_area=0.5):
"""Ищет место разреза, где плоскость режет ТОЛЬКО одно сечение и площадь
локально постоянна — там растяжение не даёт ступеньки в контуре."""
import numpy as _np
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.gp import gp_Pln, gp_Pnt, gp_Dir
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
shape = read_step(step_path)
d = _np.array(AXES[axis] if isinstance(axis, str) else axis, float)
d /= _np.linalg.norm(d)
b = Bnd_Box(); BRepBndLib.Add_s(shape, b)
x0, y0, z0, x1, y1, z1 = b.Get()
lo = _np.array([x0, y0, z0]); hi = _np.array([x1, y1, z1])
c0, c1 = float(_np.dot(d, lo)), float(_np.dot(d, hi))
size = float(_np.linalg.norm(hi - lo)) * 2
out = []
for c in _np.linspace(c0 + (c1-c0)*0.15, c1 - (c1-c0)*0.15, samples):
try:
pl = gp_Pln(gp_Pnt(*(d*c)), gp_Dir(*d))
f = BRepBuilderAPI_MakeFace(pl, -size, size, -size, size).Face()
ref = gp_Pnt(*(d*(c - size*0.5)))
hs = BRepPrimAPI_MakeHalfSpace(f, ref).Solid()
low = BRepAlgoAPI_Common(shape, hs).Shape()
n = 0; area = 0.0
ex = TopExp_Explorer(low, TopAbs_FACE)
while ex.More():
fc = TopoDS.Face_s(ex.Current())
a = BRepAdaptor_Surface(fc)
if a.GetType() == GeomAbs_Plane:
pln = a.Plane(); nn = pln.Axis().Direction(); pp = pln.Location()
v = _np.array([nn.X(), nn.Y(), nn.Z()])
if abs(abs(float(_np.dot(v, d))) - 1) < 1e-3 and \
abs(float(_np.dot(_np.array([pp.X(), pp.Y(), pp.Z()]), d)) - c) < 1e-3:
g = GProp_GProps(); BRepGProp.SurfaceProperties_s(fc, g)
n += 1; area += g.Mass()
ex.Next()
out.append((float(c), n, area))
except Exception:
continue
single = [(c, n, a) for c, n, a in out if n == 1]
if not single:
return None, out
# среди одиночных берём тот, где площадь наиболее стабильна к соседям
best, score = None, 1e18
for i, (c, n, a) in enumerate(single):
nb = [x[2] for x in single[max(0, i-1):i+2]]
var = max(nb) - min(nb)
if var < score:
score, best = var, c
return best, out
def stretch_part(step_path, axis="z", cut=None, amount=10.0, out_path=None, auto=True):
shape = read_step(step_path)
if cut is None and auto:
try:
c, _scan = best_cut(step_path, axis)
if c is not None:
cut = c
except Exception:
pass
out_path = out_path or os.path.join(tempfile.mkdtemp(), "stretched.step")
res, nmid, vlow, vup, vres = stretch(shape, axis, cut, amount, out_path)
chk = check_watertight(out_path)
return dict(path=out_path, sections=nmid, volume=vres, check=chk)
# ================= 3D-ПРОСМОТРЩИК =================
import json
import numpy as np
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
from OCP.TopoDS import TopoDS
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
def mesh_by_face(shape, deflection=0.4, angle=0.4):
"""-> verts Nx3, tris Mx3, face_id длины M (номер грани для каждого треугольника)"""
BRepMesh_IncrementalMesh(shape, deflection, False, angle, True)
fmap = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, fmap)
verts, tris, fid = [], [], []
for k in range(1, fmap.Extent() + 1):
f = TopoDS.Face_s(fmap.FindKey(k))
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(f, loc)
if tri is None:
continue
base = len(verts)
trsf = loc.Transformation()
for i in range(1, tri.NbNodes() + 1):
p = tri.Node(i).Transformed(trsf)
verts.append((p.X(), p.Y(), p.Z()))
rev = f.Orientation() == TopAbs_REVERSED
for i in range(1, tri.NbTriangles() + 1):
a, b, c = tri.Triangle(i).Get()
if rev: a, c = c, a
tris.append((base + a - 1, base + b - 1, base + c - 1))
fid.append(k - 1)
return np.array(verts, float), np.array(tris, int), np.array(fid, int)
def viewer_select_html(verts, tris, fids, labels=None, highlight=None,
height=520, bg="#14181d"):
"""Просмотрщик с подсветкой выбранной грани и кликом для определения номера."""
v = np.asarray(verts, float)
c = v.mean(axis=0) if len(v) else np.zeros(3)
r = float(np.linalg.norm(v - c, axis=1).max()) if len(v) else 1.0
payload = json.dumps({
"v": (v - c).round(3).flatten().tolist(),
"f": np.asarray(tris, int).flatten().tolist(),
"id": np.asarray(fids, int).tolist(),
"hl": int(highlight) if highlight is not None else -1,
"lab": labels or {},
})
return f"""
<div style="position:relative">
<div id="wrap" style="width:100%;height:{height}px;background:{bg};border-radius:8px"></div>
<div id="hud" style="position:absolute;left:12px;top:12px;color:#cfe3f5;font:13px system-ui;
background:rgba(10,14,18,.75);padding:6px 10px;border-radius:6px">
кликните по грани</div>
</div>
<script type="importmap">
{{"imports":{{"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}}}
</script>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const D = {payload};
const wrap = document.getElementById('wrap'), hud = document.getElementById('hud');
const scene = new THREE.Scene(); scene.background = new THREE.Color('{bg}');
const R = {r:.3f};
const cam = new THREE.PerspectiveCamera(45, wrap.clientWidth/{height}, 0.1, 100000);
cam.position.set(R*1.8, -R*2.2, R*1.4);
const rnd = new THREE.WebGLRenderer({{antialias:true}});
rnd.setSize(wrap.clientWidth, {height}); rnd.setPixelRatio(devicePixelRatio);
wrap.appendChild(rnd.domElement);
const ctl = new OrbitControls(cam, rnd.domElement); ctl.enableDamping = true;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(D.v, 3));
g.setIndex(D.f); g.computeVertexNormals();
// цвет по вершинам: подсветка выбранной грани
const nV = D.v.length/3;
const col = new Float32Array(nV*3);
const base = new THREE.Color('#8fa8bf'), hi = new THREE.Color('#ff8c32');
for (let i=0;i<nV;i++){{ col[i*3]=base.r; col[i*3+1]=base.g; col[i*3+2]=base.b; }}
function paint(sel){{
for (let i=0;i<nV;i++){{ col[i*3]=base.r; col[i*3+1]=base.g; col[i*3+2]=base.b; }}
if (sel >= 0) for (let t=0;t<D.id.length;t++) if (D.id[t]===sel)
for (let k=0;k<3;k++){{ const vi=D.f[t*3+k]; col[vi*3]=hi.r; col[vi*3+1]=hi.g; col[vi*3+2]=hi.b; }}
g.setAttribute('color', new THREE.BufferAttribute(col,3));
g.attributes.color.needsUpdate = true;
}}
paint(D.hl);
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial(
{{vertexColors:true, metalness:0.3, roughness:0.5, side:THREE.DoubleSide}}));
scene.add(mesh);
scene.add(new THREE.LineSegments(new THREE.EdgesGeometry(g, 25),
new THREE.LineBasicMaterial({{color:0x223040}})));
scene.add(new THREE.AmbientLight(0xffffff,0.55));
const d1=new THREE.DirectionalLight(0xffffff,1.0); d1.position.set(1,-1,1); scene.add(d1);
const d2=new THREE.DirectionalLight(0xffffff,0.5); d2.position.set(-1,1,-0.5); scene.add(d2);
if (D.hl>=0) hud.textContent = 'выбрана грань #' + D.hl + (D.lab[D.hl]?(' · '+D.lab[D.hl]):'');
const ray = new THREE.Raycaster(), m = new THREE.Vector2();
rnd.domElement.addEventListener('pointerdown', e => {{
const b = rnd.domElement.getBoundingClientRect();
m.x = ((e.clientX-b.left)/b.width)*2-1; m.y = -((e.clientY-b.top)/b.height)*2+1;
ray.setFromCamera(m, cam);
const hit = ray.intersectObject(mesh);
if (hit.length){{
const fi = D.id[hit[0].faceIndex];
paint(fi);
hud.textContent = 'грань #' + fi + (D.lab[fi] ? (' · ' + D.lab[fi]) : '');
}}
}});
(function loop(){{ requestAnimationFrame(loop); ctl.update(); rnd.render(scene,cam); }})();
addEventListener('resize', ()=>{{ rnd.setSize(wrap.clientWidth,{height});
cam.aspect=wrap.clientWidth/{height}; cam.updateProjectionMatrix(); }});
</script>
"""
import json
import numpy as np
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
from OCP.TopoDS import TopoDS
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
def mesh_arrays(shape, deflection=0.4, angle=0.4):
"""-> (vertices Nx3, triangles Mx3) в мировых координатах"""
BRepMesh_IncrementalMesh(shape, deflection, False, angle, True)
verts, tris = [], []
ex = TopExp_Explorer(shape, TopAbs_FACE)
while ex.More():
f = TopoDS.Face_s(ex.Current())
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation_s(f, loc)
if tri is not None:
base = len(verts)
trsf = loc.Transformation()
for i in range(1, tri.NbNodes() + 1):
p = tri.Node(i).Transformed(trsf)
verts.append((p.X(), p.Y(), p.Z()))
rev = f.Orientation() == TopAbs_REVERSED
for i in range(1, tri.NbTriangles() + 1):
t = tri.Triangle(i)
a, b, c = t.Get()
if rev: a, c = c, a
tris.append((base + a - 1, base + b - 1, base + c - 1))
ex.Next()
return np.array(verts, dtype=float), np.array(tris, dtype=int)
def viewer_html(verts, tris, height=520, color="#8fa8bf", bg="#14181d"):
v = np.asarray(verts, float)
c = v.mean(axis=0) if len(v) else np.zeros(3)
r = float(np.linalg.norm(v - c, axis=1).max()) if len(v) else 1.0
data = json.dumps({"v": (v - c).round(3).flatten().tolist(),
"f": np.asarray(tris, int).flatten().tolist()})
return f"""
<div id="wrap" style="width:100%;height:{height}px;background:{bg};border-radius:8px"></div>
<script type="importmap">
{{"imports":{{"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}}}
</script>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const D = {data};
const wrap = document.getElementById('wrap');
const scene = new THREE.Scene();
scene.background = new THREE.Color('{bg}');
const cam = new THREE.PerspectiveCamera(45, wrap.clientWidth/{height}, 0.1, 100000);
const R = {r:.3f};
cam.position.set(R*1.8, -R*2.2, R*1.4);
const rnd = new THREE.WebGLRenderer({{antialias:true}});
rnd.setSize(wrap.clientWidth, {height}); rnd.setPixelRatio(window.devicePixelRatio);
wrap.appendChild(rnd.domElement);
const ctl = new OrbitControls(cam, rnd.domElement); ctl.enableDamping = true;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(D.v, 3));
g.setIndex(D.f); g.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({{color:'{color}', metalness:0.35, roughness:0.45,
side:THREE.DoubleSide, flatShading:false}});
scene.add(new THREE.Mesh(g, mat));
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(g, 25),
new THREE.LineBasicMaterial({{color:0x223040}}));
scene.add(edges);
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const d1 = new THREE.DirectionalLight(0xffffff, 1.0); d1.position.set(1,-1,1); scene.add(d1);
const d2 = new THREE.DirectionalLight(0xffffff, 0.5); d2.position.set(-1,1,-0.5); scene.add(d2);
scene.add(new THREE.GridHelper(R*4, 20, 0x2a3540, 0x1e262e).rotateX(Math.PI/2));
function loop(){{ requestAnimationFrame(loop); ctl.update(); rnd.render(scene, cam); }}
loop();
window.addEventListener('resize', () => {{
rnd.setSize(wrap.clientWidth, {height});
cam.aspect = wrap.clientWidth/{height}; cam.updateProjectionMatrix();
}});
</script>
"""
def fold_prepare2(path):
"""Кинематика на ВСЕХ гранях тела (не только оболочки) — номинал выходит
канонически идентичным оригиналу, значит проходит распознавание поставщика."""
shape, faces, info, G, vol = probe(path)
res = measure(path)
root = max(range(len(faces)), key=lambda i: info[i]["area"])
joints = []
for b in res["bends"]:
i_, o_ = b["inner_face"], b["outer_face"]
both = set(G.neighbors(i_)) & set(G.neighbors(o_))
cut = {i_, o_} | both
H = G.copy(); H.remove_nodes_from(cut)
sub = set()
for c in nx.connected_components(H):
if root not in c: sub |= c
if sub:
joints.append(dict(name="bend_%d" % i_, bend=b, cut=cut, sub=sub,
angle=b["angle_deg"], dihedral=b["dihedral_deg"],
length=b["length"], relief=b["relief"]))
return dict(faces=faces, res=res, vol=vol, joints=joints, n=len(faces), path=path)
def fold_apply2(ctx, deltas=None, out_path=None):
"""Поворот поддеревьев + перестроение зоны гиба под новый угол + заделка дыр."""
import math
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepBuilderAPI import (BRepBuilderAPI_Transform, BRepBuilderAPI_Sewing,
BRepBuilderAPI_MakeSolid)
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.TopAbs import TopAbs_SHELL
from OCP.TopoDS import TopoDS
deltas = deltas or {}
faces, res = ctx["faces"], ctx["res"]
t = res["thickness_from_bends"]
moved = {i: faces[i] for i in range(ctx["n"])}
drop, add, applied = set(), [], []
for j in ctx["joints"]:
d = float(deltas.get(j["name"], 0.0))
if abs(d) < 1e-9: continue
b = j["bend"]
ax = gp_Ax1(gp_Pnt(*b["axis_point"]), gp_Dir(*b["axis"]))
tr = gp_Trsf(); tr.SetRotation(ax, math.radians(d))
for fid in j["sub"]:
moved[fid] = BRepBuilderAPI_Transform(moved[fid], tr, True).Shape()
drop |= j["cut"]
nf = bend_zone_faces(faces[b["inner_face"]], t, d)
if nf: add.extend(nf)
applied.append((j["name"], d, len(j["sub"])))
sew = BRepBuilderAPI_Sewing(0.05)
for i, f in moved.items():
if i not in drop: sew.Add(f)
for f in add: sew.Add(f)
sew.Perform(); sh = sew.SewedShape()
from OCP.TopExp import TopExp_Explorer as _TE
ex = _TE(sh, TopAbs_SHELL); mk = BRepBuilderAPI_MakeSolid(); n = 0
while ex.More():
s = TopoDS.Shell_s(ex.Current()); s.Closed(True); mk.Add(s); n += 1; ex.Next()
outshape = mk.Solid() if n else sh
patches = 0
try:
fixed, patches = cap_holes(outshape)
if patches: outshape = fixed
except Exception:
pass
out_path = out_path or os.path.join(tempfile.mkdtemp(), "folded.step")
w = STEPControl_Writer(); w.Transfer(outshape, STEPControl_AsIs); w.Write(out_path)
chk = check_watertight(out_path)
return dict(path=out_path, applied=applied, patches=patches,
joints=[j["name"] for j in ctx["joints"]],
is_solid=chk["solids"] > 0, volume=chk["volume"],
ref_volume=ctx["vol"], thickness=t, plates=ctx["n"], check=chk)
def fold_prepare(step_path):
"""Тяжёлая часть: обмер, расщепление, шарниры. Кэшируется в интерфейсе."""
shape, faces, info, G, vol = probe(step_path)
res = measure(step_path)
A, B, thin = split_shells(shape, faces, G, res["thickness_from_bends"])
side = set(A if len(A) >= len(B) else B)
root = max(side, key=lambda i: info[i]["area"])
H = G.subgraph(side).copy()
joints = []
for b in res["bends"]:
fid = b["inner_face"] if b["inner_face"] in side else (
b["outer_face"] if b["outer_face"] in side else None)
if fid is None: continue
H2 = H.copy(); H2.remove_node(fid)
sub = set()
for c in nx.connected_components(H2):
if root not in c: sub |= c
joints.append(dict(name="bend_%d" % fid, face=fid, axis=b["axis"],
point=b["axis_point"], angle=b["angle_deg"],
dihedral=b["dihedral_deg"], length=b["length"],
relief=b["relief"], r=b["r_inner"], subtree=sub))
return dict(shape=shape, faces=faces, info=info, vol=vol, res=res,
side=side, root=root, joints=joints)
def fold_apply(ctx, deltas=None, thickness=None, out_path=None):
"""Быстрая часть: применить углы, сшить, нарастить."""
import math
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform, BRepBuilderAPI_Sewing
from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin
from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_Arc
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
deltas = deltas or {}
faces, side, joints = ctx["faces"], ctx["side"], ctx["joints"]
vol, res = ctx["vol"], ctx["res"]
t = thickness or res["thickness_from_bends"]
moved = {i: faces[i] for i in side}
applied = []
for j in joints:
d = float(deltas.get(j["name"], 0.0))
if abs(d) < 1e-9: continue
ax = gp_Ax1(gp_Pnt(*j["point"]), gp_Dir(*j["axis"]))
tr = gp_Trsf(); tr.SetRotation(ax, math.radians(d))
for fid in j["subtree"] | {j["face"]}:
moved[fid] = BRepBuilderAPI_Transform(moved[fid], tr, True).Shape()
applied.append((j["name"], d, len(j["subtree"])))
sew = BRepBuilderAPI_Sewing(0.05)
for f in moved.values(): sew.Add(f)
sew.Perform(); shell = sew.SewedShape()
# наращивание по граням + объединение (даёт замкнутое тело)
solid, vnew = None, None
s, npieces = thicken_by_faces([moved[i] for i in side], t)
if s is not None:
p = GProp_GProps(); BRepGProp.VolumeProperties_s(s, p)
if 0 < p.Mass() < vol * 5:
solid, vnew = s, p.Mass()
out_path = out_path or os.path.join(tempfile.mkdtemp(), "folded.step")
_out = _as_solid(solid) if solid is not None else shell
w = STEPControl_Writer(); w.Transfer(_out, STEPControl_AsIs)
w.Write(out_path)
return dict(path=out_path, joints=[j["name"] for j in joints], applied=applied,
is_solid=solid is not None, volume=vnew, ref_volume=vol,
thickness=t, plates=len(side))
def fold_model(step_path, deltas=None, thickness=None, out_path=None):
"""CLI-обёртка: подготовка + применение."""
return fold_apply(fold_prepare(step_path), deltas, thickness, out_path)
def thicken_by_faces(faces_list, t, unify=True):
"""Наращивание по граням + объединение. Надёжнее, чем offset всей оболочки:
offset одной грани корректен всегда, а стыки достраивает булева операция,
поэтому результат получается замкнутым (свободных рёбер 0) и точным."""
from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin
from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_Arc
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
pieces = []
for f in faces_list:
for join in (GeomAbs_Intersection, GeomAbs_Arc):
done = False
for sgn in (-1, 1):
try:
mk = BRepOffset_MakeOffset()
mk.Initialize(f, sgn * t, 1e-4, BRepOffset_Skin, False, False, join, True)
mk.MakeOffsetShape()
if mk.IsDone():
s = mk.Shape()
p = GProp_GProps(); BRepGProp.VolumeProperties_s(s, p)
if p.Mass() > 1e-6:
pieces.append(s); done = True; break
except Exception:
pass
if done: break
if not pieces:
return None, 0
res = pieces[0]
for s in pieces[1:]:
try:
fu = BRepAlgoAPI_Fuse(res, s); fu.Build()
if fu.IsDone(): res = fu.Shape()
except Exception:
pass
if unify:
try:
u = ShapeUpgrade_UnifySameDomain(res, True, True, True); u.Build(); res = u.Shape()
except Exception:
pass
return res, len(pieces)
def check_watertight(shape_or_path):
"""Проверка топологической целостности: свободные рёбра = дыры в оболочке.
Критично для производства: порталы поставщиков (Blechcon) принимают только
замкнутые тела. Метрология этого НЕ ловит — точки могут лежать на поверхности
идеально, а деталь при этом быть дырявой."""
from OCP.TopAbs import TopAbs_SOLID, TopAbs_SHELL
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_AbscissaPoint
s = read_step(shape_or_path) if isinstance(shape_or_path, str) else shape_or_path
m = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(s, TopAbs_EDGE, TopAbs_FACE, m)
free = []
for k in range(1, m.Extent() + 1):
if m.FindFromIndex(k).Size() < 2:
e = TopoDS.Edge_s(m.FindKey(k))
try:
L = GCPnts_AbscissaPoint.Length_s(BRepAdaptor_Curve(e))
except Exception:
L = 0.0
free.append(round(L, 4))
def count(t):
ex = TopExp_Explorer(s, t); n = 0
while ex.More(): n += 1; ex.Next()
return n
p = GProp_GProps(); BRepGProp.VolumeProperties_s(s, p)
micro = [x for x in free if x < 0.05]
real = [x for x in free if x >= 0.05]
return dict(free_edges=len(free), free_micro=len(micro), free_real=len(real),
lengths=sorted(free, reverse=True)[:10],
solids=count(TopAbs_SOLID), shells=count(TopAbs_SHELL),
volume=p.Mass(),
ok=(len(free) == 0 and count(TopAbs_SOLID) > 0))
def fill_code(code, fitted):
"""подставить подобранные значения в черновик кода"""
vals = fitted["x"]; out = []; fi = 0
for ln in code.split("\n"):
if ln.startswith("FLAT") and fi < len(vals) - 1:
name = ln.split("=")[0].strip()
out.append("%s = %.3f # подобрано петлёй (RMS %.5f мм)" % (name, vals[fi], fitted["rms"]))
fi += 1
elif ln.startswith("WIDTH"):
out.append("WIDTH = %.3f # подобрано петлёй" % vals[-1])
else:
out.append(ln)
return "\n".join(out)
def _cli():
if len(sys.argv) < 2: print(__doc__); return
c = sys.argv[1]
if c == "probe":
res = measure(sys.argv[2]); json.dump(res, open("measurements.json","w"), ensure_ascii=False, indent=2)
print("толщина %s мм | гибов %d | отверстий %d | объём %.1f" %
(res["thickness_from_bends"], len(res["bends"]), res.get("holes_total",0), res["volume_mm3"]))
for b in res["bends"]:
print(" R%.1f/%.1f поворот %.2f° двугранный %.2f° длина %.1f разгрузка:%s" %
(b["r_inner"],b["r_outer"],b["angle_deg"],b["dihedral_deg"],b["length"],
"да" if b["relief"] else "нет"))
elif c == "metro": report(sys.argv[2], sys.argv[3])
elif c == "unfold":
res = measure(sys.argv[2]); t = res["thickness_from_bends"]; tot=0
for b in res["bends"]:
ba = bend_allowance(b["angle_deg"], b["r_inner"], t); tot += ba
print(" поворот %6.2f° R%.1f K=%.2f BA=%6.3f" % (b["angle_deg"],b["r_inner"],k_factor(b["r_inner"],t),ba))
print("суммарный припуск: %.2f мм" % tot)
elif c == "dxf":
res = measure(sys.argv[2])
r = export_unfold_dxf(res, sys.argv[3] if len(sys.argv) > 3 else "unfold.dxf")
print("развёртка %.2f x %.2f мм, гибов %d -> %s" % (r["flat_length"], r["width"], r["bends"], r["path"]))
elif c == "rebuild":
th = float(sys.argv[3]) if len(sys.argv) > 3 else None
r = rebuild_spatial(sys.argv[2], th)
print("оболочка %s (A=%d, B=%d, торцов %d) | t=%.2f | объём %.1f (эталон %.1f, Δ %.3f %%)"
% (r["side"], r["faces_A"], r["faces_B"], r["faces_thin"], r["thickness"],
r["volume"], r["ref_volume"], (r["volume"]-r["ref_volume"])/r["ref_volume"]*100))
print("->", r["path"])
if th is None: report(sys.argv[2], r["path"])
elif c == "fold":
d = {}
for a in sys.argv[3:]:
if "=" in a:
k, v = a.split("="); d[k.strip()] = float(v)
r = fold_model(sys.argv[2], d)
print("пластин %d | шарниры: %s" % (r["plates"], ", ".join(r["joints"])))
for n, dd, ns in r["applied"]:
print(" %s: %+.2f° (поддерево %d пластин)" % (n, dd, ns))
if r["is_solid"]:
print("тело: объём %.1f (эталон %.1f, Δ %.2f %%)" %
(r["volume"], r["ref_volume"], (r["volume"]-r["ref_volume"])/r["ref_volume"]*100))
else:
print("тело не построилось — сохранена оболочка")
print("->", r["path"])
w = check_watertight(r["path"])
print("целостность: свободных рёбер %d (дыр %d), солидов %d%s"
% (w["free_edges"], w["free_real"], w["solids"],
"годен для производства" if w["ok"] else "НЕ ГОДЕН для отправки поставщику"))
if not d: report(sys.argv[2], r["path"])
elif c == "fold2":
d = {}
for a in sys.argv[3:]:
if "=" in a:
k, v = a.split("="); d[k.strip()] = float(v)
ctx = fold_prepare2(sys.argv[2])
r = fold_apply2(ctx, d)
print("граней %d | шарниры: %s" % (r["plates"], ", ".join(r["joints"])))
for n_, dd, ns in r["applied"]:
print(" %s: %+.2f° (поддерево %d граней)" % (n_, dd, ns))
c2 = r["check"]
print("объём %.1f (эталон %.1f, Δ %.3f %%) | заплаток %d" %
(r["volume"], r["ref_volume"], (r["volume"]-r["ref_volume"])/r["ref_volume"]*100, r["patches"]))
print("целостность: свободных рёбер %d, солидов %d -> %s"
% (c2["free_edges"], c2["solids"], "ГОДЕН ДЛЯ ПРОИЗВОДСТВА" if c2["ok"] else "не годен"))
print("->", r["path"])
if not d: report(sys.argv[2], r["path"])
elif c == "stretch":
ax = sys.argv[3] if len(sys.argv) > 3 else "z"
amt = float(sys.argv[4]) if len(sys.argv) > 4 else 10.0
cutpos = float(sys.argv[5]) if len(sys.argv) > 5 else None
r = stretch_part(sys.argv[2], ax, cutpos, amt)
c2 = r["check"]
print("растянуто по оси %s на %+.1f мм | сечений %d | объём %.1f" % (ax, amt, r["sections"], r["volume"]))
print("целостность: своб.рёбер %d, солидов %d -> %s"
% (c2["free_edges"], c2["solids"], "ГОДЕН ДЛЯ ПРОИЗВОДСТВА" if c2["ok"] else "не годен"))
print("->", r["path"])
elif c == "check":
r = check_watertight(sys.argv[2])
print("солидов %d | оболочек %d | объём %.1f" % (r["solids"], r["shells"], r["volume"]))
print("свободных рёбер: %d (микро <0.05мм: %d, настоящих дыр: %d)"
% (r["free_edges"], r["free_micro"], r["free_real"]))
if r["lengths"]: print(" длины: %s" % ", ".join("%.3f" % x for x in r["lengths"]))
print("ГОДЕН ДЛЯ ПРОИЗВОДСТВА" if r["ok"] else
"НЕ ГОДЕН: деталь не замкнута — портал поставщика её отклонит")
elif c == "fit":
r = fit(sys.argv[2]); print("подобрано:", [round(v,3) for v in r["x"]], "RMS %.5f" % r["rms"])
report(r["ref_step"], r["fitted_step"])
print("\n" + fill_code(make_generator(r["measurements"]), r))
else: print(__doc__)
def _is_streamlit():
try:
from streamlit.runtime.scriptrunner import get_script_run_ctx
return get_script_run_ctx() is not None
except Exception: return False
if _is_streamlit():
"""
app.py — браузерный интерфейс инструмента реверса RECADA
Запуск: streamlit run app.py -> откроется http://localhost:8501
"""
import os, json, tempfile
import streamlit as st
st.set_page_config(page_title="RECADA", layout="wide", initial_sidebar_state="expanded")
st.title("RECADA · реверс листовых деталей")
st.caption("STEP → обмер → развёртка → приёмка. Без FreeCAD, без GUI, локально на вашем компьютере.")
tab_work, tab_prod, tab_ov, tab_asm, tab_view, tab_ops, tab_probe, tab_param, tab_code, tab_metro, tab_help = st.tabs(
["Рабочее окно", "Изделие", "Просмотр 3D", "Сборка", "3D просмотр и изменение", "Операции", "Обмер детали", "Параметрическая модель", "Генерация кода", "Приёмка", "Справка"])
def save_upload(up):
d = tempfile.mkdtemp()
p = os.path.join(d, up.name)
with open(p, "wb") as f:
f.write(up.getbuffer())
return p
# ================= РАБОЧЕЕ ОКНО =================
with tab_work:
import streamlit.components.v1 as components
from OCP.STEPControl import STEPControl_Writer as _SW, STEPControl_AsIs as _SA
def _save_shape(sh, name):
p = os.path.join(tempfile.mkdtemp(), name)
w = _SW(); w.Transfer(sh, _SA); w.Write(p); return p
S = st.session_state
if "wk_parts" not in S:
S.wk_parts = [] # [{name, path, status, role, log:[...], meas}]
S.wk_journal = [] # журнал операций проекта
S.wk_sel = 0
upw = st.file_uploader("Загрузите деталь или сборку (STEP)", type=["step", "stp"], key="wk")
if upw and S.get("wk_loaded") != "%s:%d" % (upw.name, upw.size):
pw = save_upload(upw)
raw = []
try:
named = read_assembly(pw)
if named:
for nm, sh in named:
for j, s in enumerate(split_solids(sh), 1):
raw.append((nm if j == 1 else "%s [%d]" % (nm, j), s))
except Exception:
raw = []
if len(raw) <= 1:
raw = [(upw.name.rsplit(".", 1)[0] if len(split_solids(read_step(pw))) == 1
else "деталь %d" % (i + 1), s)
for i, s in enumerate(split_solids(read_step(pw)))]
parts = []
for nm, s in raw:
try:
stt = part_stats(s)
except Exception:
continue
if abs(stt["volume"]) < 50 or stt["faces"] < 4:
continue
parts.append(dict(name=nm, path=_save_shape(s, "p.step"), status="исходник",
role="—", log=[], meas=None, vol=abs(stt["volume"])))
parts.sort(key=lambda p: -p["vol"])
S.wk_parts = parts; S.wk_journal = []; S.wk_sel = 0
S.wk_loaded = "%s:%d" % (upw.name, upw.size)
if not S.wk_parts:
st.info("Загрузите STEP: одна деталь или сборка багажника. Дальше всё здесь — "
"реверс, редактирование, подъём платформы, экспорт.")
else:
P = S.wk_parts
# группировка одинаковых имён
def _base(nm):
import re as _re
return _re.sub(r"\s*\[\d+\]\s*$", "", nm)
groups = {}
for i, p in enumerate(P):
groups.setdefault(_base(p["name"]), []).append(i)
gkeys = list(groups.keys())
ROLES = ["—", "нога", "кронштейн крыши", "платформа", "поперечина", "крепёж", "кузов"]
icons = {"исходник": "○", "реверснута": "◐", "параметрическая": "●"}
with st.sidebar:
st.markdown("### Модель")
st.caption("%d деталей · %d уникальных" % (len(P), len(gkeys)))
q = st.text_input("Поиск", "", placeholder="фильтр по имени",
label_visibility="collapsed")
show = [g for g in gkeys if q.lower() in g.lower()] if q else gkeys
# таблица-дерево: видимость, выбор, имя, кол-во, статус, роль
if "wk_vis" not in S: S.wk_vis = {}
if "wk_pick" not in S: S.wk_pick = {}
rows = []
for g in show:
ids = groups[g]; p0 = P[ids[0]]
rows.append({"вид": S.wk_vis.get(g, True), "выбор": S.wk_pick.get(g, False),
"деталь": g, "×": len(ids), "ст": icons[p0["status"]],
"роль": p0["role"]})
import pandas as _pd
df = _pd.DataFrame(rows)
ed = st.data_editor(
df, hide_index=True, use_container_width=True, height=min(520, 40 + 35 * len(rows)),
column_config={
"вид": st.column_config.CheckboxColumn("👁", width="small"),
"выбор": st.column_config.CheckboxColumn("✓", width="small"),
"деталь": st.column_config.TextColumn("деталь", disabled=True),
"×": st.column_config.NumberColumn("×", width="small", disabled=True),
"ст": st.column_config.TextColumn("", width="small", disabled=True),
"роль": st.column_config.SelectboxColumn("роль", options=ROLES, width="medium"),
}, key="wk_tree")
for _, r in ed.iterrows():
g = r["деталь"]
S.wk_vis[g] = bool(r["вид"]); S.wk_pick[g] = bool(r["выбор"])
for i in groups[g]:
P[i]["role"] = r["роль"]
picked = [g for g in gkeys if S.wk_pick.get(g)]
b1, b2 = st.columns(2)
if b1.button("Все", use_container_width=True):
for g in gkeys: S.wk_vis[g] = True
st.rerun()
if b2.button("Только выбранные", use_container_width=True, disabled=not picked):
for g in gkeys: S.wk_vis[g] = g in picked
st.rerun()
if st.button("Определить роли автоматически", use_container_width=True):
with st.spinner("Классифицирую…"):
for g, ids in groups.items():
if P[ids[0]]["role"] == "—":
r_, why = guess_role(P[ids[0]]["path"])
for i in ids: P[i]["role"] = r_; P[i]["why"] = why
st.rerun()
# ---------- ДЕТАЛЬ / ВЫБРАННЫЕ ----------
st.divider()
if not picked:
st.info("Отметьте детали галочкой ✓ в таблице")
gsel = None; gids = []; cur = None
else:
gsel = picked[0]; gids = [i for g in picked for i in groups[g]]; cur = P[groups[gsel][0]]
st.markdown("### %s" % ("Выбрано: %d деталей" % len(gids) if len(picked) > 1
else "%s%s" % (gsel[:26], " ×%d" % len(gids) if len(gids) > 1 else "")))
if len(picked) == 1:
st.caption(cur["status"] + (" · " + cur["why"] if cur.get("why") else ""))
todo = [g for g in picked if P[groups[g][0]]["status"] == "исходник"]
if todo:
n_todo = sum(len(groups[g]) for g in todo)
if st.button("Полный реверс (%d)" % n_todo, type="primary",
use_container_width=True):
bar = st.progress(0.0); okn = 0
for j, g in enumerate(todo):
p = P[groups[g][0]]
try:
mres = measure(p["path"])
rb = fold_apply2(fold_prepare2(p["path"]), {})
ckb = rb["check"]
dvb = deviations(read_step(p["path"]), read_step(rb["path"]), 4)
insb = float((dvb <= 0.05).sum()) / len(dvb) * 100
if ckb["ok"] and insb >= 99.9:
upd = dict(meas=mres, status="реверснута", recon=rb["path"],
metro=insb, check=ckb)
if p["role"] == "—":
upd["role"], upd["why"] = guess_role(p["path"])
for i in groups[g]: P[i].update(upd)
okn += len(groups[g])
S.wk_journal.append({"op": "reverse", "part": g,
"instances": len(groups[g]),
"metro": round(insb, 3)})
else:
for i in groups[g]:
P[i]["why"] = "приёмка %.1f%%, рёбер %d" % (insb, ckb["free_edges"])
except Exception as e:
for i in groups[g]: P[i]["why"] = "ошибка: %s" % str(e)[:40]
bar.progress((j + 1) / len(todo))
st.success("Реверснуто %d из %d" % (okn, n_todo))
st.rerun()
st.caption("Одинаковые детали считаются один раз")
done = [g for g in picked if P[groups[g][0]]["status"] != "исходник"]
if done and len(picked) == 1:
m = cur["meas"]
st.write("t = %.1f · гибов %d · отверстий %s · метрология %.2f %%"
% (m["thickness_from_bends"], len(m["bends"]),
m.get("holes_total", 0), cur.get("metro", 0)))
op = st.selectbox("Операция", ["— выбрать —", "Растянуть / поднять",
"Рассверлить отверстие", "Заглушить отверстие",
"Зеркало", "Экспорт DXF развёртки"])
if op == "Растянуть / поднять":
dz = st.number_input("На сколько, мм", -100.0, 200.0, 20.0, 1.0)
cutp = st.number_input("Плоскость разреза (0 = авто)", value=0.0, step=5.0)
if st.button("Применить (×%d)" % len(gids), type="primary", use_container_width=True):
with st.spinner("Растягиваю…"):
try:
_sh, _f, _i, _G, _v = probe(cur["path"])
big = max((x for x in _i if x["kind"] == "plane"), key=lambda x: x["area"])
n = unit(big["normal"]); z = np.array([0, 0, 1.0])
if abs(float(np.dot(n, z))) > 0.9: z = np.array([1.0, 0, 0])
rs = stretch_part(cur["path"], tuple(unit(np.cross(n, z))),
cutp if abs(cutp) > 1e-9 else None, dz)
if rs["check"]["ok"]:
for i in gids:
P[i]["path"] = rs["path"]; P[i]["status"] = "параметрическая"
S.wk_journal.append({"op": "stretch", "part": gsel, "mm": dz,
"sections": rs["sections"]})
st.rerun()
else:
st.error("Результат не замкнут — отклонено")
except Exception as e:
st.error("Не получилось: %s" % e)
elif op in ("Рассверлить отверстие", "Заглушить отверстие"):
items = [(d, i) for d, g_ in m["holes_by_diameter"].items() for i in g_["items"]]
if items:
k = st.selectbox("Отверстие", range(len(items)),
format_func=lambda i: "D%s @ (%s)" % (items[i][0],
", ".join("%.0f" % x for x in items[i][1]["center"])))
d0, it = items[k]
nd = st.number_input("Новый диаметр", 1.0, 60.0, float(d0) + 2, 0.5) \
if op.startswith("Рассв") else None
if st.button("Применить", type="primary", use_container_width=True):
shp = read_step(cur["path"])
res = drill(shp, it["center"], it["axis"], nd) if nd else \
plug(shp, it["center"], it["axis"], float(d0) + 0.2)
pth = _save_shape(res, "op.step"); ck = check_watertight(pth)
if ck["ok"]:
mm2 = measure(pth)
for i in gids: P[i].update(path=pth, status="параметрическая", meas=mm2)
S.wk_journal.append({"op": op, "part": gsel, "hole": d0, "new": nd})
st.rerun()
else:
st.error("Не замкнуто — отклонено")
else:
st.info("Отверстий нет")
elif op == "Зеркало":
if st.button("Создать зеркальную", type="primary", use_container_width=True):
res = mirror(read_step(cur["path"]), "xz")
pth = _save_shape(res, "mir.step")
P.append(dict(name=gsel + " (зерк.)", path=pth, status=cur["status"],
role=cur["role"], log=[], meas=cur["meas"], vol=cur["vol"]))
S.wk_journal.append({"op": "mirror", "part": gsel}); st.rerun()
elif op == "Экспорт DXF развёртки":
try:
dd = export_unfold_dxf(m, os.path.join(tempfile.mkdtemp(), "u.dxf"))
st.success("Заготовка %.1f × %.1f мм" % (dd["flat_length"], dd["width"]))
st.download_button("Скачать DXF", open(dd["path"], "rb").read(),
file_name="%s.dxf" % gsel, use_container_width=True)
except Exception:
st.warning("Одним листом не раскладывается")
st.download_button("Скачать STEP", open(cur["path"], "rb").read(),
file_name="%s.step" % gsel, use_container_width=True)
# ---------- ИЗДЕЛИЕ ----------
legs = [p for p in P if p["role"] == "нога" and p["status"] != "исходник"]
st.divider()
st.markdown("### Изделие")
pin_txt = st.text_input("Точки крепления (из 3D: «Копировать точки»)", "",
placeholder="x,y,z;x,y,z", key="wk_pins")
if pin_txt.strip():
pts = []
for grp in pin_txt.split(";"):
c = [t for t in grp.replace(" ", "").split(",") if t]
if len(c) == 3:
try: pts.append(tuple(float(t) for t in c))
except ValueError: pass
if pts: S.wk_pins = pts; st.caption("зафиксировано точек: %d" % len(pts))
if legs:
st.caption("%d ног готовы к перегенерации" % len(legs))
dzp = st.number_input("Поднять платформу, мм", -50.0, 100.0, 20.0, 1.0)
if st.button("Поднять все ноги", type="primary", use_container_width=True):
done_n = 0; cache = {}
with st.spinner("Перегенерирую ноги…"):
for lg in legs:
kk = _base(lg["name"])
try:
if kk in cache:
lg["path"] = cache[kk]; lg["status"] = "параметрическая"; done_n += 1; continue
_sh, _f, _i, _G, _v = probe(lg["path"])
big = max((x for x in _i if x["kind"] == "plane"), key=lambda x: x["area"])
n = unit(big["normal"]); z = np.array([0, 0, 1.0])
if abs(float(np.dot(n, z))) > 0.9: z = np.array([1.0, 0, 0])
dvec = tuple(unit(np.cross(n, z))); cutv = None
if S.get("wk_pins"):
cutv = max(float(np.dot(np.array(p_), np.array(dvec))) for p_ in S.wk_pins) + 5.0
rs = stretch_part(lg["path"], dvec, cutv, dzp)
if rs["check"]["ok"]:
lg["path"] = rs["path"]; lg["status"] = "параметрическая"
cache[kk] = rs["path"]; done_n += 1
except Exception:
pass
S.wk_journal.append({"op": "raise_platform", "mm": dzp, "legs": done_n}); st.rerun()
else:
st.caption("Назначьте роль «нога» и реверсните — появится подъём платформы")
# ---------- СЦЕНА НА ВСЮ ШИРИНУ ----------
COL = {"исходник": 0x8fa8bf, "реверснута": 0x35d07f, "параметрическая": 0x35d07f}
vis_idx = [i for g in gkeys if S.wk_vis.get(g, True) for i in groups[g]]
hl_set = set()
for g in gkeys:
if S.wk_pick.get(g):
for i in groups[g]: hl_set.add(i)
hl_local = {k for k, i in enumerate(vis_idx) if i in hl_set}
@st.cache_resource(show_spinner=False)
def _wk_pay(paths_key, paths, names, cols, hls):
return assembly_payload([(n, read_step(p)) for n, p in zip(names, paths)],
1.0, list(cols), set(hls))
key = "|".join("%s#%s" % (P[i]["path"], P[i]["status"]) for i in vis_idx) + "|hl:" + ",".join(map(str, sorted(hl_local)))
pay = _wk_pay(key, [P[i]["path"] for i in vis_idx], [P[i]["name"] for i in vis_idx],
tuple(COL[P[i]["status"]] for i in vis_idx), tuple(sorted(hl_local)))
st.caption("○ серые — исходники · ● зелёные — реверснуты · оранжевые — выбраны в дереве · "
"двойной клик по детали показывает её имя")
components.html(viewer3_html(pay, height=740, show_list=False), height=900)
# ---------- ЖУРНАЛ ----------
st.divider()
j1, j2 = st.columns([3, 1])
with j1:
st.markdown("**Журнал проекта** (истина — параметры, не геометрия)")
st.code(json.dumps(S.wk_journal, ensure_ascii=False, indent=1) or "[]", language="json")
with j2:
st.download_button("project.json", json.dumps(
{"parts": [{k: v for k, v in p.items() if k in ("name", "status", "role", "log")}
for p in P], "journal": S.wk_journal}, ensure_ascii=False, indent=2),
file_name="project.json", use_container_width=True)
if st.button("Пакет производства (ZIP)", type="primary", use_container_width=True):
import zipfile, io, csv
buf = io.BytesIO()
bom_rows = []
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
done_g = set()
for i, p in enumerate(P):
g = _base(p["name"])
if g in done_g: continue
done_g.add(g)
qty = sum(1 for q in P if _base(q["name"]) == g)
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in g)[:60]
# STEP каждой уникальной детали
zf.write(p["path"], "step/%s.step" % safe)
row = {"деталь": g, "кол-во": qty, "роль": p["role"], "статус": p["status"],
"толщина": "", "гибов": "", "отверстий": "", "развёртка": ""}
m = p.get("meas")
if m:
row.update({"толщина": m["thickness_from_bends"],
"гибов": len(m["bends"]), "отверстий": m.get("holes_total", 0)})
# DXF развёртки, если раскладывается одним листом
try:
dd = export_unfold_dxf(m, os.path.join(tempfile.mkdtemp(), "u.dxf"))
zf.write(dd["path"], "dxf/%s.dxf" % safe)
row["развёртка"] = "%.1f x %.1f" % (dd["flat_length"], dd["width"])
except Exception:
row["развёртка"] = "через портал (пространственная)"
zf.writestr("passport/%s.json" % safe,
json.dumps(m, ensure_ascii=False, indent=1))
bom_rows.append(row)
# BOM
sio = io.StringIO()
wcsv = csv.DictWriter(sio, fieldnames=list(bom_rows[0].keys()) if bom_rows else ["деталь"],
delimiter=";")
wcsv.writeheader(); [wcsv.writerow(r) for r in bom_rows]
zf.writestr("BOM.csv", "\ufeff" + sio.getvalue())
zf.writestr("project.json", json.dumps(
{"parts": [{k: v for k, v in p.items() if k in ("name", "status", "role")}
for p in P], "journal": S.wk_journal,
"pins": S.get("wk_pins", [])}, ensure_ascii=False, indent=2))
zf.writestr("README.txt",
"RECADA — пакет производства\n\n"
"step/ уникальные детали (STEP)\n"
"dxf/ развёртки листовых деталей с линиями гиба\n"
"passport/ обмер: толщина, гибы, отверстия (JSON)\n"
"BOM.csv спецификация: деталь; кол-во; роль; статус; t; гибы; отверстия\n"
"project.json журнал операций — истина проекта\n")
st.download_button("Скачать пакет.zip", buf.getvalue(), file_name="airholder_package.zip",
use_container_width=True)
st.caption("%d уникальных деталей · BOM · DXF · паспорта" % len(bom_rows))
if st.button("Экспорт всех STEP", use_container_width=True):
from OCP.TopoDS import TopoDS_Compound as _C
from OCP.BRep import BRep_Builder as _B
comp = _C(); bb = _B(); bb.MakeCompound(comp)
for p in P: bb.Add(comp, read_step(p["path"]))
po = _save_shape(comp, "project.step")
st.download_button("Скачать project.step", open(po, "rb").read(),
file_name="project.step", use_container_width=True)
if st.button("Очистить", use_container_width=True):
S.wk_parts = []; S.wk_journal = []; S.wk_loaded = None; st.rerun()
# ---------------- ИЗДЕЛИЕ ----------------
with tab_prod:
import streamlit.components.v1 as components
st.write("Редактор изделия: продуктовые параметры порождают геометрию. "
"Истина — product.json, детали и проверки пересчитываются из него.")
pc1, pc2, pc3 = st.columns(3)
with pc1:
veh = st.selectbox("Автомобиль", ["ford_ranger_p703_dc", "defender_110", "vw_t6_swb"])
pl_len = st.number_input("Длина платформы, мм", 800, 3000, 1330, 10)
pl_wid = st.number_input("Ширина платформы, мм", 800, 1800, 1240, 10)
with pc2:
h_roof = st.slider("Высота ног над крышей, мм", 10, 120, 30, 1)
n_cb = st.slider("Поперечин", 2, 10, 6, 1)
with pc3:
leg_t = st.select_slider("Толщина ноги, мм", options=[2.0, 2.5, 3.0], value=3.0)
n_mnt = st.slider("Точек крепления на сторону", 2, 5, 3, 1)
prm = dict(vehicle=veh, length=pl_len, width=pl_wid, height=h_roof,
crossbars=n_cb, leg_t=leg_t, mounts_per_side=n_mnt)
with st.spinner("Пересобираю изделие из параметров…"):
try:
parts_p, checks_p = build_product(prm)
except Exception as e:
st.error("Сборка не удалась: %s" % e); parts_p = None
if parts_p:
ok_all = all(str(v) != "НЕТ" for v in checks_p.values())
cA, cB = st.columns([2, 3])
with cA:
st.subheader("Проверки")
for k, v in checks_p.items():
(st.success if str(v) != "НЕТ" else st.error)("%s: %s" % (k, v))
st.download_button("product.json",
json.dumps(prm, ensure_ascii=False, indent=2),
file_name="product.json")
if st.button("Выгрузить сборку STEP"):
from OCP.TopoDS import TopoDS_Compound as _C
from OCP.BRep import BRep_Builder as _B
from OCP.STEPControl import STEPControl_Writer as _W, STEPControl_AsIs as _AI
comp = _C(); bb = _B(); bb.MakeCompound(comp)
for _, sh in parts_p: bb.Add(comp, sh)
po = os.path.join(tempfile.mkdtemp(), "airholder.step")
w = _W(); w.Transfer(comp, _AI); w.Write(po)
st.download_button("Скачать airholder.step", open(po, "rb").read(),
file_name="airholder_%s.step" % veh)
st.caption("Нога — гнутая деталь: вертикаль = высота, полка 60 мм. "
"Меняете высоту — ноги перегенерируются, а не сдвигаются.")
with cB:
with st.spinner("Триангуляция…"):
pay = assembly_payload(parts_p, 1.0)
components.html(viewer3_html(pay, height=470), height=670)
# ---------------- ПРОСМОТР 3D ----------------
with tab_ov:
import streamlit.components.v1 as components
st.write("Свой просмотрщик на three.js: детали кликабельны, изоляция, разлёт, "
"прозрачность, сечение, измерение расстояний. Всё управление наше.")
@st.cache_resource(show_spinner=False)
def _v3_prep(path, key, defl):
raw = []
try:
named = read_assembly(path)
if named:
for nm, sh in named:
for j, s in enumerate(split_solids(sh), 1):
raw.append((nm if j == 1 else "%s [%d]" % (nm, j), s))
except Exception:
raw = []
if len(raw) <= 1:
raw = [("деталь %d" % (i + 1), s)
for i, s in enumerate(split_solids(read_step(path)))]
parts, junk = [], 0
for nm, s in raw:
try:
stt = part_stats(s)
except Exception:
junk += 1; continue
if abs(stt["volume"]) < 50.0 or stt["faces"] < 4:
junk += 1; continue
parts.append((nm, s, abs(stt["volume"])))
parts.sort(key=lambda p: -p[2])
parts = [(nm, s) for nm, s, _ in parts]
return assembly_payload(parts, defl), parts, junk
up3 = st.file_uploader("STEP / STP", type=["step", "stp"], key="v3")
defl = st.select_slider("Точность сетки", options=[0.3, 0.5, 0.8, 1.5, 3.0], value=0.8,
help="меньше — точнее и тяжелее; для больших сборок берите 1.5–3.0")
if up3:
p3 = save_upload(up3)
with st.spinner("Читаю и триангулирую… (первый раз дольше, потом из кэша)"):
payload, parts3, junk = _v3_prep(p3, "%s:%d" % (up3.name, up3.size), defl)
st.success("Деталей: %d · отсеяно мусора: %d · треугольников: %s"
% (len(parts3), junk, "{:,}".format(sum(p["nt"] for p in payload)).replace(",", " ")))
components.html(viewer3_html(payload, height=560), height=760)
st.divider()
st.subheader("Реверс выбранной детали")
st.caption("Номер детали виден при клике в окне и в списке справа")
names3 = [p["n"] for p in payload]
k3 = st.selectbox("Деталь", range(len(parts3)),
format_func=lambda i: "%d · %s" % (i, names3[i] if i < len(names3) else ""))
shape_k = parts3[k3][1]
from OCP.STEPControl import STEPControl_Writer as _W, STEPControl_AsIs as _AI
pk = os.path.join(tempfile.mkdtemp(), "part_%d.step" % k3)
_w = _W(); _w.Transfer(shape_k, _AI); _w.Write(pk)
b1, b2, b3 = st.columns(3)
do_probe = b1.button("Обмерить", type="primary")
do_rev = b2.button("Реконструировать")
b3.download_button("Скачать STEP", open(pk, "rb").read(),
file_name="part_%d.step" % k3)
if do_probe:
with st.spinner("Обмеряю: граф граней, гибы, отверстия…"):
try:
rr = measure(pk)
except Exception as e:
st.error("Обмер не удался: %s" % e); rr = None
if rr:
c1, c2, c3, c4 = st.columns(4)
c1.metric("Толщина, мм", rr["thickness_from_bends"])
c2.metric("Гибов", len(rr["bends"]))
c3.metric("Отверстий", rr.get("holes_total", 0))
c4.metric("Объём, мм3", "%.0f" % rr["volume_mm3"])
if rr["bends"]:
st.dataframe([{"R внутр": b["r_inner"], "R внеш": b["r_outer"],
"поворот°": b["angle_deg"], "двугранный°": b["dihedral_deg"],
"длина, мм": b["length"],
"разгрузка": "да" if b["relief"] else "—"}
for b in rr["bends"]], use_container_width=True)
else:
st.info("Гибов не найдено — деталь не листовая "
"(профиль, литьё, крепёж) либо иной толщины")
rows3 = [{"D, мм": d, "центр": ", ".join("%.1f" % x for x in it["center"])}
for d, g in rr["holes_by_diameter"].items() for it in g["items"]]
if rows3:
st.dataframe(rows3, use_container_width=True)
st.download_button("measurements.json",
json.dumps(rr, ensure_ascii=False, indent=2),
file_name="measurements_%d.json" % k3)
try:
_d = export_unfold_dxf(rr, os.path.join(tempfile.mkdtemp(), "u.dxf"))
st.success("Развёртка: %.1f × %.1f мм" % (_d["flat_length"], _d["width"]))
st.download_button("Развёртка DXF", open(_d["path"], "rb").read(),
file_name="unfold_%d.dxf" % k3)
except Exception as e:
st.caption("Карта развёртки одним листом недоступна: %s" % e)
if do_rev:
with st.spinner("Расщепление оболочек, кинематика, сборка тела…"):
try:
ctx3 = fold_prepare2(pk)
rf = fold_apply2(ctx3, {})
except Exception as e:
st.error("Реконструкция не удалась: %s" % e); rf = None
if rf:
ck = rf["check"]
m1, m2, m3 = st.columns(3)
m1.metric("Объём, мм3", "%.1f" % ck["volume"])
m2.metric("Свободных рёбер", ck["free_edges"])
m3.metric("Шарниров", len(rf["joints"]))
(st.success if ck["ok"] else st.error)(
"ГОДЕН ДЛЯ ПРОИЗВОДСТВА" if ck["ok"] else "Не замкнуто")
try:
dv = deviations(read_step(pk), read_step(rf["path"]), 6)
ins = float((dv <= 0.05).sum()) / len(dv) * 100
st.write("Метрология: %.3f %% точек в допуске 0.05 мм" % ins)
except Exception:
pass
st.download_button("Скачать реконструкцию STEP",
open(rf["path"], "rb").read(),
file_name="recon_%d.step" % k3)
# ---------------- СБОРКА ----------------
with tab_asm:
import streamlit.components.v1 as components
st.write("Загрузите сборку (STEP с несколькими деталями). Инструмент разберёт её "
"на детали, покажет в 3D и даст выбрать нужную для реверса.")
@st.cache_resource(show_spinner=False)
def _asm_prep(path, key):
raw = []
try:
named = read_assembly(path) # имя + геометрия из одного источника
if named:
for nm, sh in named:
sols = split_solids(sh)
if len(sols) == 1:
raw.append((nm, sols[0]))
else:
for j, s in enumerate(sols, 1):
raw.append(("%s [%d]" % (nm, j), s))
except Exception:
raw = []
if len(raw) <= 1: # запасной путь без имён
shp = read_step(path)
raw = [("деталь %d" % (i + 1), s) for i, s in enumerate(split_solids(shp))]
parts, junk = [], 0
for nm, s in raw:
try:
stt = part_stats(s)
except Exception:
junk += 1; continue
if abs(stt["volume"]) < 50.0 or stt["faces"] < 4:
junk += 1; continue
parts.append((nm, s, abs(stt["volume"])))
parts.sort(key=lambda p: -p[2])
parts = [(nm, s) for nm, s, _ in parts]
V, T, ID = [], [], []
for k, (nm, s) in enumerate(parts):
try:
v, t, f = mesh_by_face(s)
except Exception:
continue
if len(v) == 0:
continue
base = len(V)
V.extend(v.tolist()); T.extend((t + base).tolist()); ID.extend([k] * len(t))
return parts, np.array(V), np.array(T, int), np.array(ID, int), junk
upa = st.file_uploader("STEP-сборка", type=["step", "stp"], key="asm")
if upa:
pa = save_upload(upa)
with st.spinner("Разбираю сборку…"):
parts, V, T, ID, junk = _asm_prep(pa, "%s:%d" % (upa.name, upa.size))
st.success("Деталей в сборке: %d · отсеяно мусора (обрезки, вспомогательные поверхности): %d" % (len(parts), junk))
rows, labels = [], {}
for k, (nm, s) in enumerate(parts):
st_ = part_stats(s)
rows.append({"№": k, "деталь": nm[:32], "объём, мм3": round(abs(st_["volume"]), 1),
"граней": st_["faces"],
"габарит": "%.0f x %.0f x %.0f" % st_["bbox"]})
labels[k] = "%s · %.0f мм3" % (nm[:20], abs(st_["volume"]))
c1, c2 = st.columns([3, 2])
with c2:
st.subheader("Детали")
st.dataframe(rows, use_container_width=True, height=260)
sel = st.selectbox("Выбрать для работы", range(len(parts)),
format_func=lambda i: "%d · %s" % (i, parts[i][0][:28]))
if st.button("Выделить деталь в STEP", type="primary"):
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
outp = os.path.join(tempfile.mkdtemp(), "part_%d.step" % sel)
w = STEPControl_Writer(); w.Transfer(parts[sel][1], STEPControl_AsIs)
w.Write(outp)
ch = check_watertight(outp)
st.write("объём %.1f · своб.рёбер %d · солидов %d"
% (ch["volume"], ch["free_edges"], ch["solids"]))
st.download_button("Скачать деталь STEP", open(outp, "rb").read(),
file_name="part_%d.step" % sel)
st.caption("Дальше: загрузите этот файл на вкладку «Обмер детали» "
"или «Параметрическая модель»")
with c1:
st.caption("Оранжевым — выбранная деталь · клик по детали показывает её номер")
components.html(viewer_select_html(V, T, ID, labels, highlight=sel, height=520),
height=550)
# ---------------- 3D ПРОСМОТР И ИЗМЕНЕНИЕ ----------------
with tab_view:
import streamlit.components.v1 as components
st.write("Кликните по грани в 3D — она подсветится и покажет номер. "
"Выберите опорную грань в списке справа: её плоскость задаёт направление растяжения.")
@st.cache_resource(show_spinner=False)
def _prep_view(path, key):
shape, faces, info, G, vol = probe(path)
v, t, f = mesh_by_face(shape)
return dict(shape=shape, info=info, v=v, t=t, f=f, vol=vol)
upv = st.file_uploader("STEP-файл", type=["step", "stp"], key="view")
if upv:
pv = save_upload(upv)
with st.spinner("Чтение и триангуляция…"):
D = _prep_view(pv, "%s:%d" % (upv.name, upv.size))
info = D["info"]
labels = {i["id"]: "%s, %.0f мм2" % (i["kind"], i["area"]) for i in info}
planes = sorted([i for i in info if i["kind"] == "plane"],
key=lambda x: -x["area"])[:15]
opts = ["#%d · плоскость · %.0f мм2 · нормаль [%s]"
% (p["id"], p["area"], ", ".join("%.2f" % q for q in unit(p["normal"])))
for p in planes]
c1, c2 = st.columns([3, 2])
with c2:
st.subheader("Опорная грань")
sel = st.selectbox("Крупнейшие плоскости", opts, index=0,
label_visibility="collapsed")
fid = int(sel.split("·")[0].strip().lstrip("#"))
sel_face = next(p for p in planes if p["id"] == fid)
n = unit(sel_face["normal"])
st.caption("Нормаль [%s] — растяжение пойдёт В плоскости этой грани"
% ", ".join("%.3f" % q for q in n))
st.subheader("Изменение")
inplane = st.radio("Направление", ["в плоскости грани (вдоль)",
"в плоскости грани (поперёк)",
"по нормали к грани"], index=0)
amount = st.slider("На сколько, мм", -30.0, 30.0, 10.0, 0.5)
cutpos = st.number_input("Плоскость разреза (координата вдоль направления)",
value=0.0, step=5.0, help="0 = центр габарита")
go = st.button("Построить", type="primary")
with c1:
st.caption("Оранжевым — выбранная грань")
components.html(viewer_select_html(D["v"], D["t"], D["f"], labels,
highlight=fid, height=470), height=500)
if go:
z = np.array([0.0, 0.0, 1.0])
if abs(float(np.dot(n, z))) > 0.9:
z = np.array([1.0, 0.0, 0.0])
d_along = unit(np.cross(n, z))
d_cross = unit(np.cross(n, d_along))
axis = {"в плоскости грани (вдоль)": tuple(d_along),
"в плоскости грани (поперёк)": tuple(d_cross),
"по нормали к грани": tuple(n)}[inplane]
st.caption("Вектор растяжения [%s]" % ", ".join("%.3f" % q for q in axis))
with st.spinner("Режу, сдвигаю, вставляю призму сечения…"):
try:
r = stretch_part(pv, axis, (cutpos if abs(cutpos) > 1e-9 else None), amount)
except Exception as e:
st.error("Не получилось: %s" % e); r = None
if r:
ch = r["check"]
k1, k2, k3 = st.columns(3)
k1.metric("Объём, мм3", "%.1f" % ch["volume"])
k2.metric("Сечений", r["sections"])
k3.metric("Свободных рёбер", ch["free_edges"])
(st.success if ch["ok"] else st.error)(
"ГОДЕН ДЛЯ ПРОИЗВОДСТВА" if ch["ok"] else "Не замкнуто — поставщик отклонит")
try:
sh2 = read_step(r["path"])
v1, t1, f1 = mesh_by_face(sh2)
st.caption("Результат")
components.html(viewer_select_html(v1, t1, f1, {}, height=470,
bg="#111a14"), height=500)
except Exception as e:
st.caption("Просмотр результата недоступен: %s" % e)
st.download_button("Скачать результат STEP", open(r["path"], "rb").read(),
file_name="recada_modified.step")
# ---------------- ОПЕРАЦИИ ----------------
with tab_ops:
import streamlit.components.v1 as components
st.write("Операции над деталью: зеркало, сверление, заглушка, обрезка, скругление. "
"Каждый результат проверяется на замкнутость.")
@st.cache_resource(show_spinner=False)
def _ops_prep(path, key):
return read_step(path), measure(path)
upo = st.file_uploader("STEP-файл", type=["step", "stp"], key="ops")
if upo:
po = save_upload(upo)
with st.spinner("Чтение и обмер…"):
shp, mres = _ops_prep(po, "%s:%d" % (upo.name, upo.size))
op = st.selectbox("Операция", [
"Зеркало (левая/правая деталь)",
"Рассверлить отверстие",
"Просверлить новое отверстие",
"Заглушить отверстие",
"Обрезать плоскостью",
"Скругление рёбер",
])
res_shape, note = None, ""
if op == "Зеркало (левая/правая деталь)":
pl = st.selectbox("Плоскость зеркала", ["yz", "xz", "xy"], index=1)
if st.button("Выполнить", type="primary"):
res_shape = mirror(shp, pl); note = "зеркало по %s" % pl
elif op in ("Рассверлить отверстие", "Заглушить отверстие"):
items = [(d, i) for d, g in mres["holes_by_diameter"].items() for i in g["items"]]
lbl = ["D%s @ (%s)" % (d, ", ".join("%.1f" % x for x in i["center"])) for d, i in items]
if lbl:
k = st.selectbox("Отверстие", range(len(lbl)), format_func=lambda i: lbl[i])
d0, it = items[k]
if op == "Рассверлить отверстие":
nd = st.number_input("Новый диаметр, мм", 1.0, 60.0, float(d0) + 2.0, 0.5)
if st.button("Выполнить", type="primary"):
res_shape = drill(shp, it["center"], it["axis"], nd)
note = "D%s -> D%.1f" % (d0, nd)
else:
if st.button("Выполнить", type="primary"):
res_shape = plug(shp, it["center"], it["axis"], float(d0) + 0.2)
note = "заглушено D%s" % d0
else:
st.info("Отверстий не найдено")
elif op == "Просверлить новое отверстие":
c1, c2, c3 = st.columns(3)
cx = c1.number_input("X", value=0.0, step=1.0)
cy = c2.number_input("Y", value=0.0, step=1.0)
cz = c3.number_input("Z", value=0.0, step=1.0)
a1, a2, a3 = st.columns(3)
ax = a1.number_input("ось X", value=0.0, step=0.1)
ay = a2.number_input("ось Y", value=1.0, step=0.1)
az = a3.number_input("ось Z", value=0.0, step=0.1)
dia = st.number_input("Диаметр, мм", 1.0, 60.0, 8.5, 0.5)
if st.button("Выполнить", type="primary"):
res_shape = drill(shp, (cx, cy, cz), (ax, ay, az), dia)
note = "D%.1f в (%.1f, %.1f, %.1f)" % (dia, cx, cy, cz)
elif op == "Обрезать плоскостью":
c1, c2 = st.columns(2)
axn = c1.selectbox("Нормаль плоскости", ["X", "Y", "Z"], index=1)
pos = c2.number_input("Координата", value=-60.0, step=5.0)
keep = st.radio("Оставить", ["меньшую сторону", "большую сторону"], horizontal=True)
if st.button("Выполнить", type="primary"):
n = {"X": (1, 0, 0), "Y": (0, 1, 0), "Z": (0, 0, 1)}[axn]
p = tuple(np.array(n) * pos)
res_shape = trim(shp, p, n,
"negative" if keep == "меньшую сторону" else "positive")
note = "обрезано по %s=%.1f" % (axn, pos)
elif op == "Скругление рёбер":
r = st.number_input("Радиус, мм", 0.2, 20.0, 1.0, 0.2)
lmin = st.number_input("Только рёбра длиннее, мм", 0.0, 200.0, 5.0, 1.0)
mode = st.radio("Тип", ["скругление", "фаска"], horizontal=True)
if st.button("Выполнить", type="primary"):
res_shape, cnt = round_edges(shp, r, lmin, 1e9,
"fillet" if mode == "скругление" else "chamfer")
note = "%s R%.1f на %d рёбрах" % (mode, r, cnt)
if res_shape is not None:
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
outp = os.path.join(tempfile.mkdtemp(), "op_result.step")
w = STEPControl_Writer(); w.Transfer(res_shape, STEPControl_AsIs); w.Write(outp)
ch = check_watertight(outp)
k1, k2, k3 = st.columns(3)
k1.metric("Объём, мм3", "%.1f" % ch["volume"])
k2.metric("Свободных рёбер", ch["free_edges"])
k3.metric("Солидов", ch["solids"])
(st.success if ch["ok"] else st.error)(
("ГОДЕН ДЛЯ ПРОИЗВОДСТВА · " if ch["ok"] else "Не замкнуто · ") + note)
try:
v1, t1, f1 = mesh_by_face(read_step(outp))
components.html(viewer_select_html(v1, t1, f1, {}, height=460, bg="#111a14"),
height=490)
except Exception as e:
st.caption("Просмотр недоступен: %s" % e)
st.download_button("Скачать STEP", open(outp, "rb").read(),
file_name="recada_op.step")
# ---------------- ОБМЕР ----------------
with tab_probe:
up = st.file_uploader("STEP-файл детали", type=["step", "stp", "STEP", "STP"], key="probe")
if up:
path = save_upload(up)
with st.spinner("Обмеряю: чтение B-Rep, граф смежности граней, гибы, отверстия…"):
res = measure(path)
c1, c2, c3, c4 = st.columns(4)
c1.metric("Толщина, мм", res["thickness_from_bends"])
c2.metric("Гибов", len(res["bends"]))
c3.metric("Отверстий", res.get("holes_total", 0))
c4.metric("Объём, мм³", f'{res["volume_mm3"]:.0f}')
st.write(f'**Габарит:** {res["bbox"]["dx"]} × {res["bbox"]["dy"]} × {res["bbox"]["dz"]} мм '
f'· **граней:** {res["faces_total"]} {res["faces_by_kind"]}')
st.subheader("Гибы")
st.dataframe([{
"R внутр": b["r_inner"], "R внеш": b["r_outer"],
"поворот°": b["angle_deg"], "двугранный°": b["dihedral_deg"],
"длина, мм": b["length"], "разгрузка": "да" if b["relief"] else "—",
"ось": ", ".join(f"{x:.3f}" for x in b["axis"]),
} for b in res["bends"]], width='stretch')
st.info("Поворот — на сколько отогнулась полка. Двугранный = 180° − поворот — "
"угол между полками. Путать их нельзя: старый Unfolder V1 показывал 45° и 80.6° "
"там, где истина 135° и 99.39°.")
st.subheader("Отверстия")
rows = []
for d, g in res["holes_by_diameter"].items():
for it in g["items"]:
rows.append({"D, мм": d,
"центр": ", ".join(f"{x:.1f}" for x in it["center"]),
"ось": ", ".join(f"{x:.2f}" for x in it["axis"])})
st.dataframe(rows or [{"—": "не найдено"}], width='stretch')
st.subheader("Развёртка (припуски на гибы)")
t = res["thickness_from_bends"]
tot, ur = 0, []
for b in res["bends"]:
ba = bend_allowance(b["angle_deg"], b["r_inner"], t)
tot += ba
ur.append({"поворот°": b["angle_deg"], "R": b["r_inner"],
"K-фактор": k_factor(b["r_inner"], t), "BA, мм": round(ba, 3)})
st.dataframe(ur, width='stretch')
st.write(f"**Суммарный припуск на гибы: {tot:.2f} мм** "
"(прямые участки берутся из скелета детали)")
try:
_d = export_unfold_dxf(res, os.path.join(tempfile.mkdtemp(), "unfold.dxf"))
st.success("Развёртка: %.2f × %.2f мм" % (_d["flat_length"], _d["width"]))
st.download_button("Скачать развёртку DXF", open(_d["path"], "rb").read(),
file_name="unfold.dxf", mime="application/dxf")
except Exception as _e:
st.caption("Карта развёртки одним листом недоступна: %s" % _e)
st.download_button("Скачать measurements.json",
json.dumps(res, ensure_ascii=False, indent=2),
file_name="measurements.json", mime="application/json")
# ---------------- ПАРАМЕТРИЧЕСКАЯ МОДЕЛЬ ----------------
with tab_param:
st.write("Оригами-кинематика: пластины оболочки на шарнирах по линиям гиба. "
"Меняете углы — поддеревья поворачиваются, деталь перестраивается.")
@st.cache_resource(show_spinner=False)
def _prep(path, key):
return fold_prepare(path)
upp = st.file_uploader("STEP-файл детали", type=["step", "stp"], key="param")
if upp:
pth = save_upload(upp)
with st.spinner("Обмер, расщепление, поиск шарниров… (1–3 мин, дальше мгновенно)"):
ctx = _prep(pth, "%s:%d" % (upp.name, upp.size))
js = ctx["joints"]
st.success("Пластин %d · шарниров %d · толщина %.2f мм · эталонный объём %.1f мм3"
% (len(ctx["side"]), len(js), ctx["res"]["thickness_from_bends"], ctx["vol"]))
st.subheader("Углы гибов")
deltas = {}
cols = st.columns(2)
for k, j in enumerate(js):
with cols[k % 2]:
st.markdown("**%s** — поворот %.2f° (двугранный %.2f°), длина %.1f мм%s"
% (j["name"], j["angle"], j["dihedral"], j["length"],
", разгрузка" if j["relief"] else ""))
deltas[j["name"]] = st.slider(%s" % j["name"], -30.0, 30.0, 0.0, 0.5,
key="d_" + j["name"], label_visibility="collapsed")
st.caption("поддерево: %d пластин" % len(j["subtree"]))
th = st.number_input("Толщина листа, мм", 0.5, 10.0,
float(ctx["res"]["thickness_from_bends"]), 0.1)
if st.button("Собрать деталь", type="primary"):
with st.spinner("Складываю и наращиваю…"):
r = fold_apply(ctx, deltas, thickness=th)
act = list(r["applied"])
if act:
st.write("Применено: " + ", ".join("%s %+.1f°" % (n, d) for n, d, s in act))
else:
st.write("Углы не изменены — контрольная пересборка.")
if r["is_solid"]:
c1, c2 = st.columns(2)
c1.metric("Объём, мм3", "%.1f" % r["volume"])
c2.metric("Δ к эталону, %", "%.2f" % ((r["volume"]-r["ref_volume"])/r["ref_volume"]*100))
if not act:
dev = deviations(read_step(pth), read_step(r["path"]), 6)
inside = float((dev <= 0.05).sum())/len(dev)*100
(st.success if inside >= 99.9 else st.error)(
"Метрология: %.3f %% точек в допуске — %s"
% (inside, "ПРИНЯТО" if inside >= 99.9 else "НЕ ПРИНЯТО"))
w = check_watertight(r["path"])
if w["ok"]:
st.success("Целостность: замкнуто, солид — годен для отправки поставщику")
else:
st.error("Целостность: свободных рёбер %d (настоящих дыр %d), солидов %d — "
"портал поставщика такой файл отклонит. Метрология этого не ловит: "
"точки лежат на поверхности верно, но деталь не замкнута."
% (w["free_edges"], w["free_real"], w["solids"]))
st.download_button("Скачать деталь STEP", open(r["path"], "rb").read(),
file_name="recada_folded.step")
try:
import streamlit.components.v1 as _c
_v, _t = mesh_arrays(read_step(r["path"]))
st.caption("Мышь: вращать · колесо: приблизить · правая кнопка: сдвинуть")
_c.html(viewer_html(_v, _t), height=540)
except Exception as _e:
st.caption("Просмотр недоступен: %s" % _e)
else:
st.warning("Тело не построилось — сохранена оболочка. Причина: поверхность гиба "
"переносится жёстко и не перестраивается под новый угол, поэтому в стыке "
"появляется разрыв. Оболочку можно открыть во FreeCAD и оценить форму.")
st.download_button("Скачать оболочку STEP", open(r["path"], "rb").read(),
file_name="recada_shell.step")
# ---------------- ГЕНЕРАЦИЯ КОДА ----------------
with tab_code:
st.write("Загрузите STEP — инструмент обмерит деталь, подберёт неизвестные параметры "
"замкнутой петлёй (генерация → метрология → правка) и выдаст параметрический "
"Python-код, воспроизводящий деталь.")
upc = st.file_uploader("STEP-файл детали", type=["step", "stp"], key="code")
do_fit = st.checkbox("Подбирать неизвестные параметры (петля с метрологией)", value=True)
if upc and st.button("Сгенерировать код", type="primary"):
pth = save_upload(upc)
with st.spinner("Обмеряю…"):
res = measure(pth)
groups = _axis_groups(res["bends"])
st.write(f'толщина **{res["thickness_from_bends"]} мм** · гибов **{len(res["bends"])}** · '
f'отверстий **{res.get("holes_total", 0)}** · направлений осей гибов **{len(groups)}**')
fitted = None
if do_fit and len(groups) == 1 and res["bends"]:
with st.spinner("Подбираю параметры (петля генерация → метрология)…"):
try:
fitted = fit(pth, verbose=False)
except Exception as e:
st.warning(f"Подбор не сошёлся: {e}")
elif do_fit and len(groups) > 1:
st.info("Оси гибов не параллельны — деталь пространственная. "
"Запускаю реконструкцию через оболочку (щуп + наращивание на толщину).")
try:
with st.spinner("Расщепляю на оболочки и наращиваю…"):
rb = rebuild_spatial(pth)
dev = deviations(read_step(pth), read_step(rb["path"]), 6)
inside = float((dev <= 0.05).sum()) / len(dev) * 100
c1, c2, c3 = st.columns(3)
c1.metric("Точек в допуске", "%.3f %%" % inside)
c2.metric("Макс. отклонение, мм", "%.4f" % dev.max())
c3.metric("Δ объёма, %", "%.3f" % ((rb["volume"]-rb["ref_volume"])/rb["ref_volume"]*100))
(st.success if inside >= 99.9 else st.error)(
("ПРИНЯТО" if inside >= 99.9 else "НЕ ПРИНЯТО")
+ " · оболочка %s: %d/%d граней, торцов %d"
% (rb["side"], rb["faces_A"], rb["faces_B"], rb["faces_thin"]))
st.download_button("Скачать реконструкцию STEP", open(rb["path"], "rb").read(),
file_name="rebuilt.step")
st.caption("Толщина — настоящий параметр: та же форма пересобирается в 2.0 мм "
"командой python recada.py rebuild деталь.step 2.0")
except Exception as e:
st.warning("Реконструкция не удалась: %s" % e)
code = make_generator(res)
if fitted:
vals = fitted["x"]
lines = code.split("\n")
out = []
fi = 0
for ln in lines:
if ln.startswith("FLAT") and fi < len(vals) - 1:
name = ln.split("=")[0].strip()
out.append(f"{name} = {vals[fi]:.3f} # подобрано петлёй (RMS {fitted['rms']:.5f} мм)")
fi += 1
elif ln.startswith("WIDTH"):
out.append(f"WIDTH = {vals[-1]:.3f} # подобрано петлёй")
else:
out.append(ln)
code = "\n".join(out)
c1, c2 = st.columns(2)
c1.metric("Невязка RMS, мм", f'{fitted["rms"]:.5f}')
c2.metric("Подобрано параметров", len(vals))
dev = deviations(_rs(pth), _rs(fitted["fitted_step"]), 6)
inside = float((dev <= 0.05).sum()) / len(dev) * 100
(st.success if inside >= 99.9 else st.error)(
f"Метрология: {inside:.3f} % точек в допуске 0.05 мм — "
+ ("ПРИНЯТО" if inside >= 99.9 else "НЕ ПРИНЯТО"))
st.subheader("Код для FreeCAD")
_sides = None
try:
if len(groups) > 1:
_sh, _fc, _inf, _G, _v = probe(pth)
_A, _B, _thin = split_shells(_sh, _fc, _G, res["thickness_from_bends"])
_sides = sorted(_A if len(_A) >= len(_B) else _B)
except Exception:
pass
if len(groups) > 1:
with st.spinner("Готовлю самодостаточный код (скелет вшивается в текст)…"):
try:
fc_code = make_freecad_code_selfcontained(pth)
except Exception as _e:
st.warning("Не удалось вшить скелет: %s" % _e)
fc_code = make_freecad_code(res, step_path=upc.name, sides=_sides)
else:
fc_code = make_freecad_code(res, step_path=upc.name, sides=_sides)
if len(fc_code) > 6000:
head = fc_code.split("SKELETON = (")[0] + "SKELETON = ( # ...вшитая геометрия, %d символов...\n)" % len(fc_code)
st.code(head, language="python")
st.info("Скелет детали вшит в код (%d КБ) — показана только шапка. "
"Скачайте файл кнопкой ниже: он самодостаточен, внешние файлы не нужны."
% (len(fc_code)//1024))
else:
st.code(fc_code, language="python")
st.download_button("Скачать код для FreeCAD", fc_code, file_name="recada_freecad.py")
st.caption("Скопируйте код (кнопка в правом верхнем углу блока) и вставьте в "
"консоль FreeCAD: View → Panels → Python console. "
"В пространственном варианте подставьте полный путь к STEP в переменную STEP.")
with st.expander("Черновик build123d (для конвейера, не для FreeCAD)"):
st.code(code, language="python")
st.download_button("Скачать generator.py", code, file_name="generator.py")
st.download_button("Скачать params.json",
json.dumps(make_params(res), ensure_ascii=False, indent=2),
file_name="params.json")
st.caption("Проверьте семантику: что параметр (меняется от машины), что правило "
"(R = 1×t, D8.5 под М8), что артефакт производства (не воспроизводится).")
# ---------------- МЕТРОЛОГИЯ ----------------
with tab_metro:
st.write("Сравнение двух STEP по контрольным точкам поверхности "
"(point-sampling + distToShape). Булевы операции не используются.")
a = st.file_uploader("Эталон (оригинал)", type=["step", "stp"], key="ref")
b = st.file_uploader("Кандидат (сгенерированный)", type=["step", "stp"], key="cand")
tol = st.slider("Допуск, мм", 0.01, 0.50, 0.05, 0.01)
per_face = st.slider("Точек на грань", 3, 20, 6)
if a and b and st.button("Сравнить", type="primary"):
pa, pb = save_upload(a), save_upload(b)
with st.spinner("Считаю отклонения…"):
import numpy as np
ref, cand = read_step(pa), read_step(pb)
dev = deviations(ref, cand, per_face)
vr, vc = volume(ref), volume(cand)
inside = float((dev <= tol).sum()) / len(dev) * 100
c1, c2, c3 = st.columns(3)
c1.metric("Точек в допуске", f"{inside:.3f} %")
c2.metric("Макс. отклонение, мм", f"{dev.max():.4f}")
c3.metric("Δ объёма, %", f"{(vc - vr) / vr * 100:.4f}")
ok = inside >= 99.9 and abs(vc - vr) / vr * 100 < 1
(st.success if ok else st.error)("ПРИНЯТО" if ok else "НЕ ПРИНЯТО (порог 99.9 %)")
st.caption(f"точек: {len(dev)} · среднее {dev.mean():.5f} мм · "
f"95-й процентиль {np.percentile(dev, 95):.5f} мм")
st.warning("Объём — не критерий: сдвиг детали на 0.3 мм даёт совпадение объёма "
"0.0000 % при 74 % точек в допуске.")
# ---------------- СПРАВКА ----------------
with tab_help:
st.markdown("""
### Что делает инструмент
1. Читает STEP, обходит B-Rep, строит **граф смежности граней** (networkx).
2. **Толщина** — по соосным цилиндрам (Rвнеш − Rвнут), контроль по парам плоскостей.
3. **Гибы** — пары соосных цилиндров с ΔR = t: радиусы, поворот, двугранный угол, ось, длина.
4. **Разгрузки** — гиб с числом контуров > 1 или рёбер > 4 считается прерванным вырезом.
5. **Отверстия** — частичные круглые рёбра сшиваются в окружности, пара окружностей = отверстие.
6. **Развёртка** — аналитически: BA = φ·(R + K·t), K по таблице R/t.
7. **Приёмка** — point-sampling: точки на эталоне → расстояние до кандидата.
### Проверено
- Реальная деталь Defender LRDE-2BR1: t = 3.00 мм, 7 гибов R3/R6, 7 отверстий.
- Замкнутый цикл: параметры → тело → обмер вернул те же числа.
- Метрология: копия 100 %, полка +2 мм — 78.6 %, сдвиг 0.3 мм — 74 %.
""")
elif __name__ == "__main__":
_cli()