loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/step.py
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""
|
|
2
|
+
STEP export: the part as a B-rep a CAD package can work with.
|
|
3
|
+
|
|
4
|
+
The part's exact pieces stay exact: every box face is a plane, every bore,
|
|
5
|
+
boss and bearing seat a true cylinder, every sphere a sphere, so a CAD user
|
|
6
|
+
can dimension, mate and drill against them. Those pieces come from the CSG
|
|
7
|
+
plan a part was built with (sharp.py for field graphs, reconstruct.py for
|
|
8
|
+
studies): the same exact shapes manifold3d used for the STL, rebuilt in
|
|
9
|
+
OpenCascade, and the organic leaves as faceted solids (one planar face per
|
|
10
|
+
triangle, decimated to a face budget first). OpenCascade's booleans then cut
|
|
11
|
+
the exact shapes from the facets, so the seam where a bore meets the organic
|
|
12
|
+
body is an exact intersection curve on the cylinder, and coplanar facets
|
|
13
|
+
merge into single faces.
|
|
14
|
+
|
|
15
|
+
A mesh with no plan (any STL) exports fully faceted.
|
|
16
|
+
|
|
17
|
+
Needs OpenCascade's Python bindings (`pip install "loadpath[step]"`, which is
|
|
18
|
+
cadquery-ocp).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
__all__ = ["attach_plan", "available", "export_step", "plan_of", "read_step", "shape_to_trimesh"]
|
|
27
|
+
|
|
28
|
+
DEFAULT_FACES = 20_000 # organic facets in the B-rep: ~14 MB of STEP, ~20 s
|
|
29
|
+
FUZZY = 1e-4 # boolean fuzz (mm): facets snapped onto exact faces
|
|
30
|
+
POINT_DECIMALS = 5 # mm places for written points (10 nm)
|
|
31
|
+
DEFAULT_PATCHES = 2000 # surface="smooth": organic triangles to start the patch layout from (refined where it misses)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def available():
|
|
35
|
+
try:
|
|
36
|
+
import OCP # noqa: F401
|
|
37
|
+
return True
|
|
38
|
+
except ImportError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _need():
|
|
43
|
+
if not available():
|
|
44
|
+
raise RuntimeError('STEP export needs OpenCascade: pip install "loadpath[step]" (cadquery-ocp)')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def attach_plan(mesh, plan):
|
|
48
|
+
"""Remember the CSG plan a mesh was built from (an attribute, not
|
|
49
|
+
metadata: metadata is copied and serialized)."""
|
|
50
|
+
mesh._loadpath_csg = plan
|
|
51
|
+
return mesh
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def plan_of(mesh):
|
|
55
|
+
return getattr(mesh, "_loadpath_csg", None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- OpenCascade shapes ------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _pnt(v):
|
|
61
|
+
from OCP.gp import gp_Pnt
|
|
62
|
+
return gp_Pnt(*(float(x) for x in v))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _faceted(mesh):
|
|
66
|
+
"""A closed trimesh as an OpenCascade solid, one planar face per
|
|
67
|
+
triangle, sharing edges (BRepBuilderAPI_MakeShapeOnMesh)."""
|
|
68
|
+
from OCP.BRep import BRep_Builder
|
|
69
|
+
from OCP.BRepBuilderAPI import (
|
|
70
|
+
BRepBuilderAPI_MakeShapeOnMesh,
|
|
71
|
+
BRepBuilderAPI_MakeSolid,
|
|
72
|
+
)
|
|
73
|
+
from OCP.Poly import Poly_Triangle, Poly_Triangulation
|
|
74
|
+
from OCP.TopAbs import TopAbs_FACE
|
|
75
|
+
from OCP.TopExp import TopExp_Explorer
|
|
76
|
+
from OCP.TopoDS import TopoDS_Shell
|
|
77
|
+
|
|
78
|
+
V = np.asarray(mesh.vertices, float)
|
|
79
|
+
F = np.asarray(mesh.faces, np.int64)
|
|
80
|
+
tri = Poly_Triangulation(len(V), len(F), False)
|
|
81
|
+
for i, v in enumerate(V):
|
|
82
|
+
tri.SetNode(i + 1, _pnt(v))
|
|
83
|
+
for i, f in enumerate(F):
|
|
84
|
+
tri.SetTriangle(i + 1, Poly_Triangle(int(f[0]) + 1, int(f[1]) + 1, int(f[2]) + 1))
|
|
85
|
+
mk = BRepBuilderAPI_MakeShapeOnMesh(tri)
|
|
86
|
+
mk.Build()
|
|
87
|
+
b = BRep_Builder()
|
|
88
|
+
shell = TopoDS_Shell()
|
|
89
|
+
b.MakeShell(shell)
|
|
90
|
+
ex = TopExp_Explorer(mk.Shape(), TopAbs_FACE)
|
|
91
|
+
while ex.More():
|
|
92
|
+
b.Add(shell, ex.Current())
|
|
93
|
+
ex.Next()
|
|
94
|
+
shell.Closed(True)
|
|
95
|
+
return BRepBuilderAPI_MakeSolid(shell).Solid()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _exact(shape, faces):
|
|
99
|
+
"""An exact shapes.Shape as an OpenCascade solid."""
|
|
100
|
+
from OCP.BRepPrimAPI import (
|
|
101
|
+
BRepPrimAPI_MakeBox,
|
|
102
|
+
BRepPrimAPI_MakeCylinder,
|
|
103
|
+
BRepPrimAPI_MakeSphere,
|
|
104
|
+
)
|
|
105
|
+
from OCP.gp import gp_Ax2, gp_Dir
|
|
106
|
+
|
|
107
|
+
from .shapes import (
|
|
108
|
+
Box,
|
|
109
|
+
Cylinder,
|
|
110
|
+
MeshShape,
|
|
111
|
+
Shell,
|
|
112
|
+
Sphere,
|
|
113
|
+
Union,
|
|
114
|
+
trimesh_from_manifold,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if isinstance(shape, Box):
|
|
118
|
+
return BRepPrimAPI_MakeBox(_pnt(shape.lo), _pnt(shape.hi)).Shape()
|
|
119
|
+
if isinstance(shape, Cylinder):
|
|
120
|
+
ax = gp_Ax2(_pnt(shape.p0), gp_Dir(*(float(x) for x in shape.axis)))
|
|
121
|
+
outer = BRepPrimAPI_MakeCylinder(ax, shape.r, shape.length).Shape()
|
|
122
|
+
if shape.r_inner > 0:
|
|
123
|
+
a = shape.axis
|
|
124
|
+
ax_i = gp_Ax2(_pnt(shape.p0 - a), gp_Dir(*(float(x) for x in a)))
|
|
125
|
+
inner = BRepPrimAPI_MakeCylinder(ax_i, shape.r_inner, shape.length + 2.0).Shape()
|
|
126
|
+
outer = _bool("subtract", [outer, inner])
|
|
127
|
+
return outer
|
|
128
|
+
if isinstance(shape, Sphere):
|
|
129
|
+
return BRepPrimAPI_MakeSphere(_pnt(shape.center), shape.r).Shape()
|
|
130
|
+
if isinstance(shape, Union):
|
|
131
|
+
return _bool("union", [_exact(s, faces) for s in shape.shapes])
|
|
132
|
+
if isinstance(shape, Shell):
|
|
133
|
+
man = _bool("subtract", [_exact(shape.outer, faces), _exact(shape.inner, faces)])
|
|
134
|
+
return man if shape.clip is None else _bool("intersect", [man, _exact(shape.clip, faces)])
|
|
135
|
+
if isinstance(shape, MeshShape):
|
|
136
|
+
return _faceted(_decimate(shape.mesh, faces))
|
|
137
|
+
return _faceted(_decimate(trimesh_from_manifold(shape.to_manifold()), faces))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _bool(op, shapes):
|
|
141
|
+
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
|
142
|
+
|
|
143
|
+
if len(shapes) == 1:
|
|
144
|
+
return shapes[0]
|
|
145
|
+
cls = {"union": BRepAlgoAPI_Fuse, "intersect": BRepAlgoAPI_Common, "subtract": BRepAlgoAPI_Cut}[op]
|
|
146
|
+
if op == "intersect":
|
|
147
|
+
out = shapes[0]
|
|
148
|
+
for s in shapes[1:]:
|
|
149
|
+
out = _bool1(cls, [out], [s])
|
|
150
|
+
return out
|
|
151
|
+
return _bool1(cls, shapes[:1], shapes[1:])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _bool1(cls, args, tools, fuzzy=FUZZY):
|
|
155
|
+
from OCP.OCP.collections import List_TopoDS_Shape
|
|
156
|
+
|
|
157
|
+
a, t = List_TopoDS_Shape(), List_TopoDS_Shape()
|
|
158
|
+
for s in args:
|
|
159
|
+
a.Append(s)
|
|
160
|
+
for s in tools:
|
|
161
|
+
t.Append(s)
|
|
162
|
+
op = cls()
|
|
163
|
+
op.SetArguments(a)
|
|
164
|
+
op.SetTools(t)
|
|
165
|
+
op.SetFuzzyValue(fuzzy)
|
|
166
|
+
op.SetRunParallel(True)
|
|
167
|
+
op.SetNonDestructive(True)
|
|
168
|
+
op.Build()
|
|
169
|
+
if not op.IsDone():
|
|
170
|
+
raise RuntimeError(f"OpenCascade {cls.__name__} failed")
|
|
171
|
+
return op.Shape()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _closest(mesh, P, chunk=20_000):
|
|
175
|
+
import trimesh
|
|
176
|
+
|
|
177
|
+
out = np.empty_like(P)
|
|
178
|
+
for i in range(0, len(P), chunk):
|
|
179
|
+
out[i:i + chunk] = trimesh.proximity.closest_point(mesh, P[i:i + chunk])[0]
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _sharpness(V, F):
|
|
184
|
+
"""Per vertex: 1 - the least cosine between its faces' normals and its
|
|
185
|
+
own normal (0 on a flat patch, ~1 on a right-angle crease)."""
|
|
186
|
+
n = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]])
|
|
187
|
+
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
|
|
188
|
+
vn = np.zeros_like(V)
|
|
189
|
+
for c in range(3):
|
|
190
|
+
np.add.at(vn, F[:, c], n)
|
|
191
|
+
vn /= np.maximum(np.linalg.norm(vn, axis=1, keepdims=True), 1e-300)
|
|
192
|
+
k = np.zeros(len(V))
|
|
193
|
+
for c in range(3):
|
|
194
|
+
np.maximum.at(k, F[:, c], 1 - np.einsum("ij,ij->i", n, vn[F[:, c]]))
|
|
195
|
+
return k
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _collapse(V, F, target, locked=None, turn=0.9):
|
|
199
|
+
"""A closed mesh (V, F) coarsened to about `target` faces, shortest
|
|
200
|
+
edges first, keeping its topology: decimate.py's passes (the link
|
|
201
|
+
condition, no face turning by more than acos(`turn`)) with no field to
|
|
202
|
+
hold to. Each collapse keeps the sharper end where it is, so creases
|
|
203
|
+
and seams stay put and every vertex is one of the original ones.
|
|
204
|
+
Vertices flagged in `locked` never move. Returns the vertices, faces and
|
|
205
|
+
lock flags of the result."""
|
|
206
|
+
from .decimate import decimate
|
|
207
|
+
|
|
208
|
+
V = np.asarray(V, float)
|
|
209
|
+
F = np.asarray(F, np.int64)
|
|
210
|
+
k = _sharpness(V, F)
|
|
211
|
+
|
|
212
|
+
def place(W, a, b):
|
|
213
|
+
pick = k[a] >= k[b]
|
|
214
|
+
k[a] = np.maximum(k[a], k[b])
|
|
215
|
+
return np.where(pick[:, None], W[a], W[b])
|
|
216
|
+
|
|
217
|
+
lock = np.zeros(len(V), bool) if locked is None else np.asarray(locked, bool)
|
|
218
|
+
V2, F2, _, kept = decimate(V, F, None, 0.0, 1.0, locked=lock, max_passes=300, min_gain=0.002,
|
|
219
|
+
target=int(target), place=place, turn=turn)
|
|
220
|
+
return V2, F2, lock[kept]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _fast(mesh, faces):
|
|
224
|
+
"""fast_simplification's quadric decimation, without the zero-area
|
|
225
|
+
faces (a repeated vertex) it leaves behind, which make an otherwise
|
|
226
|
+
closed result read as torn."""
|
|
227
|
+
import trimesh
|
|
228
|
+
|
|
229
|
+
c = mesh.simplify_quadric_decimation(face_count=int(faces))
|
|
230
|
+
F = np.asarray(c.faces)
|
|
231
|
+
ok = (F[:, 0] != F[:, 1]) & (F[:, 1] != F[:, 2]) & (F[:, 0] != F[:, 2])
|
|
232
|
+
if ok.all():
|
|
233
|
+
return c
|
|
234
|
+
out = trimesh.Trimesh(c.vertices, F[ok], process=False)
|
|
235
|
+
out.remove_unreferenced_vertices()
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _decimate(mesh, faces):
|
|
240
|
+
"""`mesh` cut down to about `faces` triangles, still closed. A lattice
|
|
241
|
+
does not decimate (its walls collapse first): past 4x the budget that is
|
|
242
|
+
an error, not a STEP file of millions of faces."""
|
|
243
|
+
if len(mesh.faces) <= faces:
|
|
244
|
+
return mesh
|
|
245
|
+
try:
|
|
246
|
+
out = _fast(mesh, faces)
|
|
247
|
+
if out.is_watertight and abs(out.volume) > 0.9 * abs(mesh.volume):
|
|
248
|
+
return out
|
|
249
|
+
except Exception: # noqa: BLE001 - decimation is best-effort
|
|
250
|
+
pass
|
|
251
|
+
import trimesh
|
|
252
|
+
|
|
253
|
+
if len(mesh.faces) <= 20 * faces and mesh.is_watertight and mesh.is_winding_consistent: # tore: coarsen carefully
|
|
254
|
+
V, F, _ = _collapse(mesh.vertices, mesh.faces, int(faces))
|
|
255
|
+
out = trimesh.Trimesh(V, F, process=False)
|
|
256
|
+
if len(out.faces) <= 1.5 * faces and out.is_watertight:
|
|
257
|
+
return out
|
|
258
|
+
if len(mesh.faces) > 4 * faces:
|
|
259
|
+
raise ValueError(f"an organic region of {len(mesh.faces)} triangles does not decimate to {faces} and stay "
|
|
260
|
+
"closed (a lattice?): raise the STEP face budget, or hand over the STL for it")
|
|
261
|
+
return mesh
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _organic_budget(plan, faces):
|
|
265
|
+
from .sharp import Organic, _leaves
|
|
266
|
+
|
|
267
|
+
org = [lf for lf in _leaves(plan) if isinstance(lf, Organic) and lf.mesh is not None]
|
|
268
|
+
for lf in org:
|
|
269
|
+
if not hasattr(lf.mesh, "faces"): # a manifold (reconstruct.py)
|
|
270
|
+
from .shapes import trimesh_from_manifold
|
|
271
|
+
|
|
272
|
+
lf.mesh = trimesh_from_manifold(lf.mesh)
|
|
273
|
+
total = sum(len(lf.mesh.faces) for lf in org) or 1
|
|
274
|
+
return {id(lf): max(500, int(faces * len(lf.mesh.faces) / total)) for lf in org}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _pieces(shape, faces):
|
|
278
|
+
"""An exact shape as separate OpenCascade solids (a union's members):
|
|
279
|
+
booleans take them as separate tools, which survives members that share
|
|
280
|
+
faces (a motor box against its driver's box) where a pre-fused union
|
|
281
|
+
can come out invalid."""
|
|
282
|
+
from .shapes import Union
|
|
283
|
+
|
|
284
|
+
if isinstance(shape, Union):
|
|
285
|
+
return [p for s in shape.shapes for p in _pieces(s, faces)]
|
|
286
|
+
return [_exact(shape, faces)]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _on_float32(mesh):
|
|
290
|
+
"""The mesh with its vertices on float32's grid. manifold3d's results
|
|
291
|
+
come back in double precision now (shapes.trimesh_from_manifold), and a
|
|
292
|
+
study part's organic leaf, decimated from those, leaves OpenCascade's
|
|
293
|
+
booleans against the exact planes with no clean result (the lug's
|
|
294
|
+
STEP came out with no solid); rounded as manifold3d's float32 output
|
|
295
|
+
used to round them, they fuse. Moves a vertex ~1e-5 mm at most."""
|
|
296
|
+
import trimesh
|
|
297
|
+
|
|
298
|
+
V = np.asarray(mesh.vertices, np.float32).astype(float)
|
|
299
|
+
if np.array_equal(V, mesh.vertices):
|
|
300
|
+
return mesh
|
|
301
|
+
return trimesh.Trimesh(V, np.asarray(mesh.faces), process=False)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build(plan, budget, faces, notes):
|
|
305
|
+
"""(OpenCascade solids, the same thing as a manifold3d Manifold). The
|
|
306
|
+
manifold is the referee: each OpenCascade boolean is tried a few ways
|
|
307
|
+
(fuzz, tools separate or fused) until it is valid and its volume agrees
|
|
308
|
+
with manifold3d's."""
|
|
309
|
+
import manifold3d as m3d
|
|
310
|
+
|
|
311
|
+
from .shapes import manifold_from_trimesh
|
|
312
|
+
from .sharp import Exact, Organic, exact_manifold
|
|
313
|
+
|
|
314
|
+
if isinstance(plan, Exact):
|
|
315
|
+
return _pieces(plan.shape, faces), exact_manifold(plan.shape)
|
|
316
|
+
if isinstance(plan, Organic):
|
|
317
|
+
m = _on_float32(plan.mesh)
|
|
318
|
+
d = _decimate(m, budget.get(id(plan), faces))
|
|
319
|
+
if len(d.faces) < len(m.faces):
|
|
320
|
+
notes.append({"leaf": ",".join(plan.names) or "organic", "faces": len(m.faces),
|
|
321
|
+
"stepFaces": len(d.faces), "deviation": _deviation(m, d)})
|
|
322
|
+
return [_faceted(d)], manifold_from_trimesh(d)
|
|
323
|
+
kids = [_build(k, budget, faces, notes) for k in plan.kids]
|
|
324
|
+
kind = {"union": m3d.OpType.Add, "intersect": m3d.OpType.Intersect, "subtract": m3d.OpType.Subtract}[plan.op]
|
|
325
|
+
want = m3d.Manifold.batch_boolean([k[1] for k in kids], kind)
|
|
326
|
+
if plan.op == "union":
|
|
327
|
+
first, tools = kids[0][0][:1], kids[0][0][1:] + [s for k in kids[1:] for s in k[0]]
|
|
328
|
+
else:
|
|
329
|
+
first = [_fuse_all(kids[0][0])]
|
|
330
|
+
tools = [s for k in kids[1:] for s in k[0]]
|
|
331
|
+
if plan.op == "intersect":
|
|
332
|
+
tools = [_fuse_all(tools)]
|
|
333
|
+
shape = _referee(plan.op, first, tools, want.volume(), notes)
|
|
334
|
+
return [shape], want
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fuse_all(shapes):
|
|
338
|
+
return shapes[0] if len(shapes) == 1 else _bool1(_cls("union"), shapes[:1], shapes[1:])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _cls(op):
|
|
342
|
+
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
|
343
|
+
|
|
344
|
+
return {"union": BRepAlgoAPI_Fuse, "intersect": BRepAlgoAPI_Common, "subtract": BRepAlgoAPI_Cut}[op]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _referee(op, first, tools, want, notes):
|
|
348
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
349
|
+
|
|
350
|
+
tries = [(FUZZY, False), (1e-6, False), (FUZZY, True), (0.0, False), (1e-3, False)]
|
|
351
|
+
best = None
|
|
352
|
+
for fuzzy, fused in tries:
|
|
353
|
+
t = [_fuse_all(tools)] if fused and len(tools) > 1 else tools
|
|
354
|
+
try:
|
|
355
|
+
r = _bool1(_cls(op), first, t, fuzzy)
|
|
356
|
+
except RuntimeError:
|
|
357
|
+
continue
|
|
358
|
+
ok = bool(BRepCheck_Analyzer(r).IsValid())
|
|
359
|
+
err = abs(_volume(r) - want) / max(abs(want), 1e-9)
|
|
360
|
+
if ok and err < 2e-3:
|
|
361
|
+
return r
|
|
362
|
+
score = (not ok, err)
|
|
363
|
+
if best is None or score < best[0]:
|
|
364
|
+
best = (score, r)
|
|
365
|
+
if best is None:
|
|
366
|
+
raise RuntimeError(f"OpenCascade could not {op} the part")
|
|
367
|
+
notes.append({"warning": f"{op}: no clean OpenCascade result; kept the closest "
|
|
368
|
+
f"(valid {not best[0][0]}, volume off {best[0][1]:.2%})"})
|
|
369
|
+
return best[1]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _deviation(full, coarse, n=4000):
|
|
373
|
+
"""Distance (mm) from a sample of the full mesh's surface to the
|
|
374
|
+
decimated one: what the faceting costs."""
|
|
375
|
+
import trimesh
|
|
376
|
+
|
|
377
|
+
P, _ = trimesh.sample.sample_surface(full, n, seed=2)
|
|
378
|
+
_, d, _ = trimesh.proximity.closest_point(coarse, P)
|
|
379
|
+
return {"mean": round(float(d.mean()), 4), "p99": round(float(np.percentile(d, 99)), 4)}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _keep_bodies(shape, keep_with=None):
|
|
383
|
+
"""The largest solid and any solid touching `keep_with` (preserved
|
|
384
|
+
geometry): what reconstruct keeps, fragments dropped."""
|
|
385
|
+
from OCP.BRep import BRep_Builder
|
|
386
|
+
from OCP.TopAbs import TopAbs_SOLID
|
|
387
|
+
from OCP.TopExp import TopExp_Explorer
|
|
388
|
+
from OCP.TopoDS import TopoDS_Compound
|
|
389
|
+
|
|
390
|
+
solids = []
|
|
391
|
+
ex = TopExp_Explorer(shape, TopAbs_SOLID)
|
|
392
|
+
while ex.More():
|
|
393
|
+
solids.append(ex.Current())
|
|
394
|
+
ex.Next()
|
|
395
|
+
if len(solids) <= 1:
|
|
396
|
+
return shape, 0
|
|
397
|
+
vols = [_volume(s) for s in solids]
|
|
398
|
+
order = np.argsort(vols)[::-1]
|
|
399
|
+
kept = [solids[order[0]]]
|
|
400
|
+
for i in order[1:]:
|
|
401
|
+
if keep_with is not None and _volume(_bool("intersect", [solids[i], keep_with])) > 1e-6:
|
|
402
|
+
kept.append(solids[i])
|
|
403
|
+
b = BRep_Builder()
|
|
404
|
+
comp = TopoDS_Compound()
|
|
405
|
+
b.MakeCompound(comp)
|
|
406
|
+
for s in kept:
|
|
407
|
+
b.Add(comp, s)
|
|
408
|
+
return comp, len(solids) - len(kept)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _volume(shape):
|
|
412
|
+
from OCP.BRepGProp import BRepGProp
|
|
413
|
+
from OCP.GProp import GProp_GProps
|
|
414
|
+
|
|
415
|
+
p = GProp_GProps()
|
|
416
|
+
BRepGProp.VolumeProperties_s(shape, p)
|
|
417
|
+
return float(p.Mass())
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _faces_report(shape, exact_planes):
|
|
421
|
+
"""Faces of the B-rep by surface type, and the share of its area on
|
|
422
|
+
exact geometry (cylinders, spheres, and planes of the exact shapes)."""
|
|
423
|
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
424
|
+
from OCP.BRepGProp import BRepGProp
|
|
425
|
+
from OCP.GeomAbs import (
|
|
426
|
+
GeomAbs_BezierSurface,
|
|
427
|
+
GeomAbs_BSplineSurface,
|
|
428
|
+
GeomAbs_Cylinder,
|
|
429
|
+
GeomAbs_Plane,
|
|
430
|
+
GeomAbs_Sphere,
|
|
431
|
+
)
|
|
432
|
+
from OCP.GProp import GProp_GProps
|
|
433
|
+
from OCP.TopAbs import TopAbs_FACE
|
|
434
|
+
from OCP.TopExp import TopExp_Explorer
|
|
435
|
+
from OCP.TopoDS import TopoDS
|
|
436
|
+
|
|
437
|
+
kinds = {}
|
|
438
|
+
area = {"exact": 0.0, "faceted": 0.0, "smooth": 0.0}
|
|
439
|
+
ex = TopExp_Explorer(shape, TopAbs_FACE)
|
|
440
|
+
while ex.More():
|
|
441
|
+
f = TopoDS.Face(ex.Current())
|
|
442
|
+
s = BRepAdaptor_Surface(f)
|
|
443
|
+
t = s.GetType()
|
|
444
|
+
p = GProp_GProps()
|
|
445
|
+
BRepGProp.SurfaceProperties_s(f, p)
|
|
446
|
+
a = float(p.Mass())
|
|
447
|
+
if t == GeomAbs_Plane:
|
|
448
|
+
pl = s.Plane()
|
|
449
|
+
n = pl.Axis().Direction()
|
|
450
|
+
o = pl.Location()
|
|
451
|
+
nv = np.array([n.X(), n.Y(), n.Z()])
|
|
452
|
+
ov = np.array([o.X(), o.Y(), o.Z()])
|
|
453
|
+
exact = any(abs(abs(nv @ en) - 1) < 1e-7 and abs((ov - ep) @ en) < 1e-4 for ep, en in exact_planes)
|
|
454
|
+
key = "plane" if exact else "facet"
|
|
455
|
+
elif t == GeomAbs_Cylinder:
|
|
456
|
+
key, exact = "cylinder", True
|
|
457
|
+
elif t == GeomAbs_Sphere:
|
|
458
|
+
key, exact = "sphere", True
|
|
459
|
+
elif t in (GeomAbs_BSplineSurface, GeomAbs_BezierSurface):
|
|
460
|
+
key, exact = "bspline", None
|
|
461
|
+
else:
|
|
462
|
+
key, exact = "other", True
|
|
463
|
+
kinds[key] = kinds.get(key, 0) + 1
|
|
464
|
+
area["smooth" if exact is None else "exact" if exact else "faceted"] += a
|
|
465
|
+
ex.Next()
|
|
466
|
+
total = sum(area.values()) or 1.0
|
|
467
|
+
return {"faces": kinds, "exactArea": round(area["exact"], 1), "facetedArea": round(area["faceted"], 1),
|
|
468
|
+
"smoothArea": round(area["smooth"], 1), "exactAreaFraction": round(area["exact"] / total, 4)}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _planes(shape, out):
|
|
472
|
+
from .shapes import Box, Cylinder, Shell, Union
|
|
473
|
+
|
|
474
|
+
if isinstance(shape, Box):
|
|
475
|
+
for a in range(3):
|
|
476
|
+
n = np.zeros(3)
|
|
477
|
+
n[a] = 1.0
|
|
478
|
+
out += [(shape.lo.copy(), n), (shape.hi.copy(), n.copy())]
|
|
479
|
+
elif isinstance(shape, Cylinder):
|
|
480
|
+
out += [(shape.p0.copy(), shape.axis.copy()), (shape.p1.copy(), shape.axis.copy())]
|
|
481
|
+
elif isinstance(shape, Union):
|
|
482
|
+
for s in shape.shapes:
|
|
483
|
+
_planes(s, out)
|
|
484
|
+
elif isinstance(shape, Shell):
|
|
485
|
+
for s in (shape.outer, shape.inner, shape.clip):
|
|
486
|
+
if s is not None:
|
|
487
|
+
_planes(s, out)
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _bezier_faces(shape):
|
|
492
|
+
"""A compound of the shape's Bezier and B-spline faces."""
|
|
493
|
+
from OCP.BRep import BRep_Builder
|
|
494
|
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
495
|
+
from OCP.GeomAbs import GeomAbs_BezierSurface, GeomAbs_BSplineSurface
|
|
496
|
+
from OCP.TopAbs import TopAbs_FACE
|
|
497
|
+
from OCP.TopExp import TopExp_Explorer
|
|
498
|
+
from OCP.TopoDS import TopoDS, TopoDS_Compound
|
|
499
|
+
|
|
500
|
+
bb = BRep_Builder()
|
|
501
|
+
out = TopoDS_Compound()
|
|
502
|
+
bb.MakeCompound(out)
|
|
503
|
+
ex = TopExp_Explorer(shape, TopAbs_FACE)
|
|
504
|
+
while ex.More():
|
|
505
|
+
if BRepAdaptor_Surface(TopoDS.Face(ex.Current())).GetType() in (GeomAbs_BezierSurface, GeomAbs_BSplineSurface):
|
|
506
|
+
bb.Add(out, ex.Current())
|
|
507
|
+
ex.Next()
|
|
508
|
+
return out
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _smooth_shape(part, plan, patches, notes, degree=None):
|
|
512
|
+
"""surface="smooth": the finished mesh as a B-rep of smooth patches with
|
|
513
|
+
its exact faces exact (brep.py), or None (and a note) when that does not
|
|
514
|
+
come out a valid solid within 0.2% of the mesh's volume."""
|
|
515
|
+
if not hasattr(part, "faces"):
|
|
516
|
+
notes.append({"warning": "smooth surfaces need the part's mesh, not only its plan; faceted instead"})
|
|
517
|
+
return None
|
|
518
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
519
|
+
|
|
520
|
+
from .brep import smooth_brep
|
|
521
|
+
from .sharp import mesh_distance
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
solid, rep = smooth_brep(part, plan, patches, **({"order": int(degree)} if degree else {}))
|
|
525
|
+
vol = _volume(solid)
|
|
526
|
+
if not BRepCheck_Analyzer(solid).IsValid() or abs(vol - abs(part.volume)) > 2e-3 * abs(part.volume):
|
|
527
|
+
raise RuntimeError(f"invalid or {abs(vol / abs(part.volume) - 1):.2%} off in volume")
|
|
528
|
+
except Exception as e: # noqa: BLE001 - facets always work
|
|
529
|
+
notes.append({"warning": f"smooth surfaces failed ({e}); faceted instead"})
|
|
530
|
+
return None
|
|
531
|
+
# the patches against the mesh; the exact faces are the truth there (a
|
|
532
|
+
# mesh's facets sag off a big bore by more than the patches miss)
|
|
533
|
+
back = shape_to_trimesh(_bezier_faces(solid), deflection=0.005, angle=0.05)
|
|
534
|
+
if len(back.faces): # a part of exact faces only has no patches to measure
|
|
535
|
+
d = mesh_distance(part, back.sample(20000), cap=1.0)
|
|
536
|
+
rep["deviation"] = {"mean": round(float(d.mean()), 4), "p99": round(float(np.percentile(d, 99)), 4),
|
|
537
|
+
"max": round(float(d.max()), 4)}
|
|
538
|
+
notes.append({"leaf": "smooth", **rep})
|
|
539
|
+
return solid
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def export_step(part, path, faces=DEFAULT_FACES, name=None, unify=True, check=True, surface="facets",
|
|
543
|
+
patches=DEFAULT_PATCHES, degree=None):
|
|
544
|
+
"""Write `part` (a trimesh from a field graph or a study, or a CSG plan)
|
|
545
|
+
to a STEP file at `path`. `faces` is the budget of organic facets; the
|
|
546
|
+
exact shapes cost a handful of faces each. Returns a report: faces by
|
|
547
|
+
type, the share of the area that is exact geometry, solids, volume (and
|
|
548
|
+
the STL's, to compare), what decimating the organic surface cost, and
|
|
549
|
+
the file's size."""
|
|
550
|
+
_need()
|
|
551
|
+
import time
|
|
552
|
+
|
|
553
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
554
|
+
|
|
555
|
+
from .sharp import Exact, Op, Organic, _leaves
|
|
556
|
+
|
|
557
|
+
t0 = time.time()
|
|
558
|
+
plan = part if isinstance(part, (Op, Exact, Organic)) else plan_of(part)
|
|
559
|
+
mesh_volume = None if isinstance(part, (Op, Exact, Organic)) else float(abs(part.volume))
|
|
560
|
+
notes = []
|
|
561
|
+
keep_with = None
|
|
562
|
+
mesh_plan = plan
|
|
563
|
+
if plan is None:
|
|
564
|
+
plan = Organic(None, ["mesh"], mesh=part)
|
|
565
|
+
keep = getattr(plan, "keep_with", None)
|
|
566
|
+
budget = _organic_budget(plan, faces)
|
|
567
|
+
shape = None
|
|
568
|
+
if surface == "smooth":
|
|
569
|
+
shape = _smooth_shape(part, mesh_plan if mesh_volume is not None else None, patches, notes, degree)
|
|
570
|
+
if shape is None:
|
|
571
|
+
shapes, _ = _build(plan, budget, faces, notes)
|
|
572
|
+
shape = _fuse_all(shapes) if len(shapes) > 1 else shapes[0]
|
|
573
|
+
dropped = 0
|
|
574
|
+
if keep is not None:
|
|
575
|
+
keep_with = _exact(keep, faces)
|
|
576
|
+
shape, dropped = _keep_bodies(shape, keep_with)
|
|
577
|
+
if unify:
|
|
578
|
+
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|
579
|
+
|
|
580
|
+
u = ShapeUpgrade_UnifySameDomain(shape, True, True, False)
|
|
581
|
+
u.SetLinearTolerance(1e-5)
|
|
582
|
+
u.Build()
|
|
583
|
+
shape = u.Shape()
|
|
584
|
+
else:
|
|
585
|
+
dropped = 0
|
|
586
|
+
valid = bool(BRepCheck_Analyzer(shape).IsValid()) if check else None
|
|
587
|
+
if check and not valid:
|
|
588
|
+
from OCP.ShapeFix import ShapeFix_Shape
|
|
589
|
+
|
|
590
|
+
fx = ShapeFix_Shape(shape)
|
|
591
|
+
fx.Perform()
|
|
592
|
+
shape = fx.Shape()
|
|
593
|
+
valid = bool(BRepCheck_Analyzer(shape).IsValid())
|
|
594
|
+
planes = []
|
|
595
|
+
for lf in _leaves(plan):
|
|
596
|
+
if isinstance(lf, Exact):
|
|
597
|
+
_planes(lf.shape, planes)
|
|
598
|
+
rep = _faces_report(shape, planes)
|
|
599
|
+
wrote = write_step(shape, path, name=name, verify=bool(check and valid))
|
|
600
|
+
from OCP.TopAbs import TopAbs_SOLID
|
|
601
|
+
from OCP.TopExp import TopExp_Explorer
|
|
602
|
+
|
|
603
|
+
n = 0
|
|
604
|
+
ex = TopExp_Explorer(shape, TopAbs_SOLID)
|
|
605
|
+
while ex.More():
|
|
606
|
+
n += 1
|
|
607
|
+
ex.Next()
|
|
608
|
+
rep.update({"path": str(path), "bytes": os.path.getsize(path), "solids": n, "valid": valid,
|
|
609
|
+
"volume": round(_volume(shape), 2), "seconds": round(time.time() - t0, 1)})
|
|
610
|
+
if "pcurves" in wrote:
|
|
611
|
+
rep["pcurves"] = wrote["pcurves"]
|
|
612
|
+
rep["readBackValid"] = wrote["readBackValid"]
|
|
613
|
+
rep["valid"] = bool(valid and wrote["readBackValid"])
|
|
614
|
+
if mesh_volume is not None:
|
|
615
|
+
rep["meshVolume"] = round(mesh_volume, 2)
|
|
616
|
+
if dropped:
|
|
617
|
+
rep["droppedFragments"] = dropped
|
|
618
|
+
if notes:
|
|
619
|
+
rep["decimated"] = notes
|
|
620
|
+
rep["shape"] = shape
|
|
621
|
+
return rep
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def write_step(shape, path, name=None, pcurves=False, verify=False):
|
|
625
|
+
"""Write an OpenCascade shape to STEP (mm, AP214, points compacted),
|
|
626
|
+
without the faces' 2-D curves unless `pcurves` (readers rebuild them,
|
|
627
|
+
and they are half the file). `verify` reads the file back and, if the
|
|
628
|
+
shape no longer passes OpenCascade's check (a reader projecting an edge
|
|
629
|
+
onto a sliver patch can fail where the writer's own 2-D curve was fine),
|
|
630
|
+
writes it again with the 2-D curves. Returns {path, bytes, writeSeconds}
|
|
631
|
+
(and with verify: pcurves, readBackValid)."""
|
|
632
|
+
import time
|
|
633
|
+
|
|
634
|
+
from OCP.Interface import Interface_Static
|
|
635
|
+
from OCP.STEPControl import (
|
|
636
|
+
STEPControl_AsIs,
|
|
637
|
+
STEPControl_Controller,
|
|
638
|
+
STEPControl_Writer,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
t0 = time.time()
|
|
642
|
+
STEPControl_Controller.Init_s() # registers the write.* parameters before they are set
|
|
643
|
+
Interface_Static.SetCVal_s("write.step.unit", "MM")
|
|
644
|
+
Interface_Static.SetCVal_s("write.step.schema", "AP214IS")
|
|
645
|
+
Interface_Static.SetIVal_s("write.surfacecurve.mode", 1 if pcurves else 0)
|
|
646
|
+
if name:
|
|
647
|
+
Interface_Static.SetCVal_s("write.step.product.name", str(name))
|
|
648
|
+
w = STEPControl_Writer()
|
|
649
|
+
w.Transfer(shape, STEPControl_AsIs)
|
|
650
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
651
|
+
if int(w.Write(str(path))) != 1:
|
|
652
|
+
raise RuntimeError(f"OpenCascade could not write {path}")
|
|
653
|
+
_compact(path)
|
|
654
|
+
out = {"path": str(path), "bytes": os.path.getsize(path), "writeSeconds": round(time.time() - t0, 1)}
|
|
655
|
+
if verify:
|
|
656
|
+
ok = shape_valid(read_step(path))
|
|
657
|
+
if not ok and not pcurves:
|
|
658
|
+
out = write_step(shape, path, name=name, pcurves=True, verify=True)
|
|
659
|
+
out["writeSeconds"] = round(time.time() - t0, 1)
|
|
660
|
+
return out
|
|
661
|
+
out.update({"pcurves": bool(pcurves), "readBackValid": bool(ok)})
|
|
662
|
+
return out
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
BIG_SHELL = 8000 # faces: past this a whole-solid check is checked shell by shell
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def shape_valid(shape, geom=True):
|
|
669
|
+
"""OpenCascade's check (BRepCheck). A solid with an inner shell of more
|
|
670
|
+
than BIG_SHELL faces is checked shell by shell (each as a solid of its
|
|
671
|
+
own): the whole-solid check classifies every shell against every other
|
|
672
|
+
and went past 12 GB on an arm link's 38k-patch void."""
|
|
673
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
|
|
674
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
675
|
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
|
676
|
+
from OCP.TopExp import TopExp_Explorer
|
|
677
|
+
from OCP.TopoDS import TopoDS
|
|
678
|
+
|
|
679
|
+
def nfaces(x):
|
|
680
|
+
ex, n = TopExp_Explorer(x, TopAbs_FACE), 0
|
|
681
|
+
while ex.More():
|
|
682
|
+
n += 1
|
|
683
|
+
ex.Next()
|
|
684
|
+
return n
|
|
685
|
+
|
|
686
|
+
shells = []
|
|
687
|
+
ex = TopExp_Explorer(shape, TopAbs_SHELL)
|
|
688
|
+
while ex.More():
|
|
689
|
+
shells.append(TopoDS.Shell(ex.Current()))
|
|
690
|
+
ex.Next()
|
|
691
|
+
if len(shells) < 2 or max(nfaces(x) for x in shells) <= BIG_SHELL:
|
|
692
|
+
return bool(BRepCheck_Analyzer(shape, geom).IsValid())
|
|
693
|
+
return all(BRepCheck_Analyzer(BRepBuilderAPI_MakeSolid(x).Solid(), geom).IsValid() for x in shells)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _compact(path, decimals=POINT_DECIMALS):
|
|
697
|
+
"""Rewrite a STEP file smaller: points to `decimals` places (mm; 10 nm
|
|
698
|
+
is a thousandth of the sewing tolerance) and each distinct point once.
|
|
699
|
+
Three quarters of a smooth STEP file is points, and a patch's edge poles
|
|
700
|
+
are the same points as its edge curve's and its neighbour's."""
|
|
701
|
+
import re
|
|
702
|
+
|
|
703
|
+
with open(path) as fh:
|
|
704
|
+
text = fh.read()
|
|
705
|
+
head, sep, rest = text.partition("DATA;")
|
|
706
|
+
data, sep2, tail = rest.partition("ENDSEC;")
|
|
707
|
+
if not sep or not sep2:
|
|
708
|
+
return
|
|
709
|
+
ents = re.split(r"\n(?=#\d+\s*=)", data)
|
|
710
|
+
num = re.compile(r"-?\d+\.\d*(?:E[-+]?\d+)?")
|
|
711
|
+
point = re.compile(r"#(\d+)\s*=\s*CARTESIAN_POINT\('([^']*)',\((.*)\)\)\s*;\s*$", re.DOTALL)
|
|
712
|
+
|
|
713
|
+
def short(x):
|
|
714
|
+
t = f"{float(x):.{decimals}f}".rstrip("0")
|
|
715
|
+
return "0." if t in ("-0.", "0.") else t
|
|
716
|
+
|
|
717
|
+
first, alias, out = {}, {}, []
|
|
718
|
+
for e in ents:
|
|
719
|
+
mo = point.match(e.strip())
|
|
720
|
+
if mo:
|
|
721
|
+
coords = ",".join(short(x) for x in num.findall(mo.group(3)))
|
|
722
|
+
key = (mo.group(2), coords)
|
|
723
|
+
if key in first:
|
|
724
|
+
alias[mo.group(1)] = first[key]
|
|
725
|
+
continue
|
|
726
|
+
first[key] = mo.group(1)
|
|
727
|
+
out.append(f"#{mo.group(1)} = CARTESIAN_POINT('{mo.group(2)}',({coords}));")
|
|
728
|
+
else:
|
|
729
|
+
out.append(e)
|
|
730
|
+
if alias:
|
|
731
|
+
ref = re.compile(r"#(\d+)")
|
|
732
|
+
body = []
|
|
733
|
+
for e in out:
|
|
734
|
+
lhs, eq, rhs = e.partition("=")
|
|
735
|
+
body.append(lhs + eq + ref.sub(lambda m: "#" + alias.get(m.group(1), m.group(1)), rhs) if eq else e)
|
|
736
|
+
out = body
|
|
737
|
+
with open(path, "w") as fh:
|
|
738
|
+
fh.write(head + sep + "\n".join(x.strip("\n") for x in out) + "\n" + sep2 + tail)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def read_step(path):
|
|
742
|
+
"""The shape in a STEP file (OpenCascade TopoDS_Shape)."""
|
|
743
|
+
_need()
|
|
744
|
+
from OCP.IFSelect import IFSelect_RetDone
|
|
745
|
+
from OCP.STEPControl import STEPControl_Reader
|
|
746
|
+
|
|
747
|
+
r = STEPControl_Reader()
|
|
748
|
+
if r.ReadFile(str(path)) != IFSelect_RetDone:
|
|
749
|
+
raise ValueError(f"could not read STEP file {path}")
|
|
750
|
+
r.TransferRoots()
|
|
751
|
+
return r.OneShape()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def shape_to_trimesh(shape, deflection=0.002, angle=0.05, chunk=2000):
|
|
755
|
+
"""Triangulate an OpenCascade shape finely (chord `deflection` mm) into a
|
|
756
|
+
trimesh: how to measure a B-rep with the mesh tools."""
|
|
757
|
+
import trimesh
|
|
758
|
+
from OCP.BRep import BRep_Builder, BRep_Tool
|
|
759
|
+
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
760
|
+
from OCP.BRepTools import BRepTools
|
|
761
|
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
|
|
762
|
+
from OCP.TopExp import TopExp_Explorer
|
|
763
|
+
from OCP.TopLoc import TopLoc_Location
|
|
764
|
+
from OCP.TopoDS import TopoDS, TopoDS_Compound
|
|
765
|
+
|
|
766
|
+
# in chunks of faces, each meshed (in parallel), read into numpy and its
|
|
767
|
+
# triangulation dropped: one mesh of a whole lattice part (46k patches,
|
|
768
|
+
# tens of millions of nodes) held at once went past 13 GB
|
|
769
|
+
faces = []
|
|
770
|
+
ex = TopExp_Explorer(shape, TopAbs_FACE)
|
|
771
|
+
while ex.More():
|
|
772
|
+
faces.append(TopoDS.Face(ex.Current()))
|
|
773
|
+
ex.Next()
|
|
774
|
+
V, F = [], []
|
|
775
|
+
off = 0
|
|
776
|
+
bb = BRep_Builder()
|
|
777
|
+
for c0 in range(0, len(faces), chunk):
|
|
778
|
+
comp = TopoDS_Compound()
|
|
779
|
+
bb.MakeCompound(comp)
|
|
780
|
+
for f in faces[c0:c0 + chunk]:
|
|
781
|
+
bb.Add(comp, f)
|
|
782
|
+
BRepMesh_IncrementalMesh(comp, deflection, False, angle, True)
|
|
783
|
+
for f in faces[c0:c0 + chunk]:
|
|
784
|
+
loc = TopLoc_Location()
|
|
785
|
+
tri = BRep_Tool.Triangulation_s(f, loc)
|
|
786
|
+
if tri is None:
|
|
787
|
+
continue
|
|
788
|
+
tr = loc.Transformation()
|
|
789
|
+
nn, nt = tri.NbNodes(), tri.NbTriangles()
|
|
790
|
+
v = np.empty((nn, 3))
|
|
791
|
+
for i in range(nn):
|
|
792
|
+
p = tri.Node(i + 1).Transformed(tr)
|
|
793
|
+
v[i] = p.X(), p.Y(), p.Z()
|
|
794
|
+
t = np.empty((nt, 3), np.int64)
|
|
795
|
+
for i in range(nt):
|
|
796
|
+
t[i] = tri.Triangle(i + 1).Get()
|
|
797
|
+
if f.Orientation() == TopAbs_REVERSED:
|
|
798
|
+
t = t[:, [0, 2, 1]]
|
|
799
|
+
V.append(v)
|
|
800
|
+
F.append(t - 1 + off)
|
|
801
|
+
off += nn
|
|
802
|
+
BRepTools.Clean_s(comp)
|
|
803
|
+
m = trimesh.Trimesh(np.concatenate(V) if V else np.zeros((0, 3)),
|
|
804
|
+
np.concatenate(F) if F else np.zeros((0, 3), np.int64), process=True)
|
|
805
|
+
m.merge_vertices(digits_vertex=6)
|
|
806
|
+
return m
|