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