""" 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 """
Разлёт Прозрачность Сечение
детали появятся после загрузки…
загрузка модели…
""".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"""
Разлёт Прозр. Сечение
загрузка…
""".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"""
кликните по грани
""" 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"""
""" 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()