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/brep.py
ADDED
|
@@ -0,0 +1,1470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smooth B-rep from a finished part: the organic body as large Bezier
|
|
3
|
+
patches, the bores, bosses and flat faces as true cylinders and planes.
|
|
4
|
+
|
|
5
|
+
step.py's default STEP keeps the exact shapes exact and facets the rest.
|
|
6
|
+
Facets are fine to print from and to mate against, but a CAD user cannot
|
|
7
|
+
fillet, offset or shell them. This module works on the finished mesh, after
|
|
8
|
+
the booleans, so no OpenCascade boolean ever has to cut a smooth surface:
|
|
9
|
+
|
|
10
|
+
1. label: every triangle lying on one of the plan's exact surfaces (a box
|
|
11
|
+
face, a bore, a boss, a cap) gets that surface's label; the organic rest
|
|
12
|
+
splits into smooth regions at creases; slivers join their surroundings;
|
|
13
|
+
2. coarsen: decimate.py's passes, no field, with a rule that keeps the
|
|
14
|
+
seams (a vertex only collapses into a neighbour carrying all its labels,
|
|
15
|
+
seam vertices only along their seam, corners never) and relaxation
|
|
16
|
+
rounds between them. Organic triangles stay well shaped and do not lean
|
|
17
|
+
far off the surface, so a patch never wraps a tight bend;
|
|
18
|
+
3. edges: a coarse edge on a seam follows the mesh's seam path, one inside
|
|
19
|
+
the organic body is its chord draped evenly over the surface; both
|
|
20
|
+
become Bezier curves of the patch degree (5 by default);
|
|
21
|
+
4. faces: organic triangles pair up into quads, one patch each (the odd
|
|
22
|
+
ones out split into three quads), its sides those curves and its inside
|
|
23
|
+
interpolating points pulled onto the mesh along the patch normal; each
|
|
24
|
+
connected exact region is one face on its surface, bounded by its seams;
|
|
25
|
+
5. sew, make the solid.
|
|
26
|
+
|
|
27
|
+
The caller (step.export_step, surface="smooth") checks the result (valid,
|
|
28
|
+
volume against the mesh) and falls back to facets if it is not right.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
__all__ = ["coarsen", "label", "smooth_brep", "surfaces"]
|
|
35
|
+
|
|
36
|
+
TOL = 0.005 # mm: a vertex this close to an exact surface lies on it
|
|
37
|
+
PATCHES = 2000 # organic triangles to start the layout from (two make one patch; refined where it misses)
|
|
38
|
+
RAY_MAX = 3.0 # mm: farthest a patch point is pulled along its normal onto the surface
|
|
39
|
+
CREASE = 40.0 # degrees: organic faces meeting sharper than this are separate smooth regions
|
|
40
|
+
SEW = 0.01 # mm: sewing tolerance (exact faces' seams sit within TOL of the patches')
|
|
41
|
+
ASPECT = 0.1 # least 2*area / longest edge^2 of a patch (equilateral 0.87)
|
|
42
|
+
LEAN = 0.5 # least cosine between a patch's plane and the surface normal at its corners
|
|
43
|
+
REFINE = 0.07 # mm: a patch or edge missing the mesh by more is split
|
|
44
|
+
REFINE_ROUNDS = 8
|
|
45
|
+
MIN_SPLIT = 0.3 # mm: shortest edge that splits
|
|
46
|
+
ISLAND = 1.0 # mm^2: label regions smaller than this, inside one other region, join it
|
|
47
|
+
TURN = 0.5 # least cosine between a coarsened face's normal and its old one
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Surf:
|
|
51
|
+
"""An exact surface piece: plane (point, normal, in-bounds test),
|
|
52
|
+
cylinder (p0, axis, r, axial range) or sphere (center, r)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, kind, **kw):
|
|
55
|
+
self.kind = kind
|
|
56
|
+
self.__dict__.update(kw)
|
|
57
|
+
|
|
58
|
+
def key(self):
|
|
59
|
+
if self.kind == "plane":
|
|
60
|
+
n = self.n if self.n[np.argmax(np.abs(self.n))] > 0 else -self.n
|
|
61
|
+
return ("plane", *np.round(n, 6), round(float(self.p @ n), 5))
|
|
62
|
+
if self.kind == "cylinder":
|
|
63
|
+
a = self.axis if self.axis[np.argmax(np.abs(self.axis))] > 0 else -self.axis
|
|
64
|
+
q = self.p0 - (self.p0 @ a) * a
|
|
65
|
+
return ("cylinder", *np.round(a, 6), *np.round(q, 5), round(self.r, 5))
|
|
66
|
+
return ("sphere", *np.round(self.c, 5), round(self.r, 5))
|
|
67
|
+
|
|
68
|
+
def dist(self, P):
|
|
69
|
+
if self.kind == "plane":
|
|
70
|
+
return np.abs((P - self.p) @ self.n)
|
|
71
|
+
if self.kind == "cylinder":
|
|
72
|
+
d = P - self.p0
|
|
73
|
+
t = d @ self.axis
|
|
74
|
+
return np.abs(np.linalg.norm(d - t[:, None] * self.axis, axis=1) - self.r)
|
|
75
|
+
return np.abs(np.linalg.norm(P - self.c, axis=1) - self.r)
|
|
76
|
+
|
|
77
|
+
def normal(self, P):
|
|
78
|
+
if self.kind == "plane":
|
|
79
|
+
return np.broadcast_to(self.n, P.shape)
|
|
80
|
+
if self.kind == "cylinder":
|
|
81
|
+
d = P - self.p0
|
|
82
|
+
d = d - (d @ self.axis)[:, None] * self.axis
|
|
83
|
+
return d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12)
|
|
84
|
+
d = P - self.c
|
|
85
|
+
return d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12)
|
|
86
|
+
|
|
87
|
+
def inside(self, P, tol=0.02):
|
|
88
|
+
if self.kind == "plane" and self.box is not None:
|
|
89
|
+
lo, hi = self.box
|
|
90
|
+
return np.all((P >= lo - tol) & (P <= hi + tol), axis=1)
|
|
91
|
+
if self.kind == "plane" and self.disk is not None:
|
|
92
|
+
c, r0, r1 = self.disk
|
|
93
|
+
d = np.linalg.norm(P - c - ((P - c) @ self.n)[:, None] * self.n, axis=1)
|
|
94
|
+
return (d >= r0 - tol) & (d <= r1 + tol)
|
|
95
|
+
if self.kind == "cylinder":
|
|
96
|
+
t = (P - self.p0) @ self.axis
|
|
97
|
+
return (t >= -tol) & (t <= self.length + tol)
|
|
98
|
+
return np.ones(len(P), bool)
|
|
99
|
+
|
|
100
|
+
def geom(self):
|
|
101
|
+
from OCP.Geom import Geom_CylindricalSurface, Geom_Plane, Geom_SphericalSurface
|
|
102
|
+
from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
|
|
103
|
+
|
|
104
|
+
if self.kind == "plane":
|
|
105
|
+
return Geom_Plane(gp_Pnt(*map(float, self.p)), gp_Dir(*map(float, self.n)))
|
|
106
|
+
if self.kind == "cylinder":
|
|
107
|
+
return Geom_CylindricalSurface(gp_Ax3(gp_Pnt(*map(float, self.p0)), gp_Dir(*map(float, self.axis))),
|
|
108
|
+
float(self.r))
|
|
109
|
+
return Geom_SphericalSurface(gp_Ax3(gp_Pnt(*map(float, self.c)), gp_Dir(0, 0, 1)), float(self.r))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _closest(mesh, P, edge=0.5, k=16):
|
|
114
|
+
"""Closest points on a mesh to P and the normals there: trimesh's
|
|
115
|
+
closest_point gathers candidates by triangle bounding boxes, and an
|
|
116
|
+
exact boolean's slivers (a bore wall the length of the bore) make every
|
|
117
|
+
point a candidate of all of them, minutes on a part; here the mesh is
|
|
118
|
+
split to `edge` and each point tries the k nearest centroids."""
|
|
119
|
+
import trimesh
|
|
120
|
+
from scipy.spatial import cKDTree
|
|
121
|
+
|
|
122
|
+
from .fields import _closest_on_triangles
|
|
123
|
+
|
|
124
|
+
cache = getattr(mesh, "_loadpath_closest", None)
|
|
125
|
+
if cache is None or cache[0] != edge:
|
|
126
|
+
v, f = trimesh.remesh.subdivide_to_size(np.asarray(mesh.vertices, float),
|
|
127
|
+
np.asarray(mesh.faces, np.int64), max_edge=edge, max_iter=16)
|
|
128
|
+
tri = v[f]
|
|
129
|
+
nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
130
|
+
nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-300)
|
|
131
|
+
cache = (edge, tri, nrm, cKDTree(tri.mean(axis=1)))
|
|
132
|
+
mesh._loadpath_closest = cache
|
|
133
|
+
_, tri, nrm, tree = cache
|
|
134
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
135
|
+
k = min(k, len(tri))
|
|
136
|
+
out, nout = np.empty_like(P), np.empty_like(P)
|
|
137
|
+
for j in range(0, len(P), 100_000):
|
|
138
|
+
q = P[j:j + 100_000]
|
|
139
|
+
cand = tree.query(q, k=k)[1].reshape(len(q), k)
|
|
140
|
+
T = tri[cand]
|
|
141
|
+
Q = np.repeat(q[:, None], k, axis=1).reshape(-1, 3)
|
|
142
|
+
C = _closest_on_triangles(Q, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
|
|
143
|
+
T[:, :, 2].reshape(-1, 3)).reshape(len(q), k, 3)
|
|
144
|
+
b = np.argmin(np.sum((C - q[:, None]) ** 2, axis=2), axis=1)
|
|
145
|
+
r = np.arange(len(q))
|
|
146
|
+
out[j:j + len(q)] = C[r, b]
|
|
147
|
+
nout[j:j + len(q)] = nrm[cand[r, b]]
|
|
148
|
+
return out, nout
|
|
149
|
+
|
|
150
|
+
def _shape_surfaces(shape, out):
|
|
151
|
+
from .shapes import Box, Cylinder, Shell, Sphere, Union
|
|
152
|
+
|
|
153
|
+
if isinstance(shape, Box):
|
|
154
|
+
for a in range(3):
|
|
155
|
+
n = np.zeros(3)
|
|
156
|
+
n[a] = 1.0
|
|
157
|
+
for at in (shape.lo, shape.hi):
|
|
158
|
+
lo, hi = shape.lo.copy(), shape.hi.copy()
|
|
159
|
+
lo[a] = hi[a] = at[a]
|
|
160
|
+
out.append(Surf("plane", p=at.copy(), n=n.copy(), box=(lo, hi), disk=None))
|
|
161
|
+
elif isinstance(shape, Cylinder):
|
|
162
|
+
out.append(Surf("cylinder", p0=shape.p0.copy(), axis=shape.axis.copy(), r=shape.r, length=shape.length))
|
|
163
|
+
if shape.r_inner > 0:
|
|
164
|
+
out.append(Surf("cylinder", p0=shape.p0.copy(), axis=shape.axis.copy(), r=shape.r_inner,
|
|
165
|
+
length=shape.length))
|
|
166
|
+
for c in (shape.p0, shape.p1):
|
|
167
|
+
out.append(Surf("plane", p=c.copy(), n=shape.axis.copy(), box=None, disk=(c.copy(), shape.r_inner, shape.r)))
|
|
168
|
+
elif isinstance(shape, Sphere):
|
|
169
|
+
out.append(Surf("sphere", c=np.asarray(shape.center, float), r=float(shape.r)))
|
|
170
|
+
elif isinstance(shape, Union):
|
|
171
|
+
for s in shape.shapes:
|
|
172
|
+
_shape_surfaces(s, out)
|
|
173
|
+
elif isinstance(shape, Shell):
|
|
174
|
+
for s in (shape.outer, shape.inner, shape.clip):
|
|
175
|
+
if s is not None:
|
|
176
|
+
_shape_surfaces(s, out)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def surfaces(plan):
|
|
181
|
+
"""The exact surfaces of a CSG plan's exact leaves, one per distinct
|
|
182
|
+
plane, cylinder or sphere (pieces of one plane share it)."""
|
|
183
|
+
from .sharp import Exact, _leaves
|
|
184
|
+
|
|
185
|
+
out = []
|
|
186
|
+
for lf in _leaves(plan):
|
|
187
|
+
if isinstance(lf, Exact):
|
|
188
|
+
_shape_surfaces(lf.shape, out)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def label(mesh, surfs, tol=TOL):
|
|
193
|
+
"""Per face: 0 for organic, k + 1 for a face lying on surfs[k] (all three
|
|
194
|
+
corners within `tol`, inside its bounds, normal along the surface's).
|
|
195
|
+
Surfaces with the same geometry share a label."""
|
|
196
|
+
V = np.asarray(mesh.vertices, float)
|
|
197
|
+
F = np.asarray(mesh.faces)
|
|
198
|
+
C = V[F].mean(axis=1)
|
|
199
|
+
fn = np.asarray(mesh.face_normals)
|
|
200
|
+
lab = np.zeros(len(F), np.int64)
|
|
201
|
+
best = np.full(len(F), np.inf)
|
|
202
|
+
keys = {}
|
|
203
|
+
for k, s in enumerate(surfs):
|
|
204
|
+
lk = keys.setdefault(s.key(), k + 1)
|
|
205
|
+
dv = s.dist(V)
|
|
206
|
+
ok = (dv < tol) & s.inside(V)
|
|
207
|
+
on = ok[F].all(axis=1)
|
|
208
|
+
if not on.any():
|
|
209
|
+
continue
|
|
210
|
+
i = np.flatnonzero(on)
|
|
211
|
+
para = np.abs(np.einsum("ij,ij->i", fn[i], s.normal(C[i]))) > 0.995
|
|
212
|
+
i = i[para]
|
|
213
|
+
d = dv[F[i]].sum(axis=1)
|
|
214
|
+
better = d < best[i]
|
|
215
|
+
lab[i[better]] = lk
|
|
216
|
+
best[i[better]] = d[better]
|
|
217
|
+
return lab
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def creases(mesh, lab, O, angle=CREASE):
|
|
221
|
+
"""Organic faces (label 0) split into smooth regions at creases (faces
|
|
222
|
+
meeting at more than `angle` degrees), labelled O, O + 1, ...: a patch
|
|
223
|
+
must not round over a sharp edge of the field."""
|
|
224
|
+
import scipy.sparse as sp
|
|
225
|
+
from scipy.sparse.csgraph import connected_components
|
|
226
|
+
|
|
227
|
+
org = lab == 0
|
|
228
|
+
A = np.asarray(mesh.face_adjacency)
|
|
229
|
+
keep = org[A[:, 0]] & org[A[:, 1]] & (np.asarray(mesh.face_adjacency_angles) < np.radians(angle))
|
|
230
|
+
n = len(lab)
|
|
231
|
+
G = sp.coo_matrix((np.ones(keep.sum()), (A[keep, 0], A[keep, 1])), shape=(n, n))
|
|
232
|
+
_, comp = connected_components(G, directed=False)
|
|
233
|
+
out = lab.copy()
|
|
234
|
+
_, ids = np.unique(comp[org], return_inverse=True)
|
|
235
|
+
out[org] = O + ids
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def peninsulas(mesh, lab, O, rounds=4):
|
|
240
|
+
"""Exact faces poking into the organic body (two of their three
|
|
241
|
+
neighbours organic, of one region) turn organic: a seam that juts out
|
|
242
|
+
and back makes a face boundary cross itself."""
|
|
243
|
+
A = np.asarray(mesh.face_adjacency)
|
|
244
|
+
lab = lab.copy()
|
|
245
|
+
for _ in range(rounds):
|
|
246
|
+
nb = [[] for _ in range(len(lab))]
|
|
247
|
+
for x, y in A:
|
|
248
|
+
nb[x].append(y)
|
|
249
|
+
nb[y].append(x)
|
|
250
|
+
flip = []
|
|
251
|
+
for f in np.flatnonzero(lab < O):
|
|
252
|
+
other = [lab[g] for g in nb[f] if lab[g] != lab[f]]
|
|
253
|
+
if len(other) >= 2 and other[0] >= O and all(o == other[0] for o in other):
|
|
254
|
+
flip.append((f, other[0]))
|
|
255
|
+
if not flip:
|
|
256
|
+
break
|
|
257
|
+
for f, l in flip:
|
|
258
|
+
lab[f] = l
|
|
259
|
+
return lab
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def islands(mesh, lab, area=ISLAND, surfs=None, tol=0.02):
|
|
263
|
+
"""Small label regions (under `area` mm^2) wholly surrounded by one other
|
|
264
|
+
label take that label: a sliver of organic faces inside a flat face (its
|
|
265
|
+
normals too noisy for label()) would cut a slit into the plane."""
|
|
266
|
+
import scipy.sparse as sp
|
|
267
|
+
from scipy.sparse.csgraph import connected_components
|
|
268
|
+
|
|
269
|
+
A = np.asarray(mesh.face_adjacency)
|
|
270
|
+
fa = np.asarray(mesh.area_faces)
|
|
271
|
+
n = len(lab)
|
|
272
|
+
for _ in range(3):
|
|
273
|
+
same = lab[A[:, 0]] == lab[A[:, 1]]
|
|
274
|
+
G = sp.coo_matrix((np.ones(same.sum()), (A[same, 0], A[same, 1])), shape=(n, n))
|
|
275
|
+
nc, comp = connected_components(G, directed=False)
|
|
276
|
+
ca = np.bincount(comp, fa, minlength=nc)
|
|
277
|
+
small = ca < area
|
|
278
|
+
if not small.any():
|
|
279
|
+
break
|
|
280
|
+
d = ~same
|
|
281
|
+
a, b = A[d, 0], A[d, 1]
|
|
282
|
+
# neighbour labels of each small component
|
|
283
|
+
cs = np.r_[comp[a], comp[b]]
|
|
284
|
+
nb = np.r_[lab[b], lab[a]]
|
|
285
|
+
m = small[cs]
|
|
286
|
+
cs, nb = cs[m], nb[m]
|
|
287
|
+
if not len(cs):
|
|
288
|
+
break
|
|
289
|
+
K = int(lab.max()) + 1
|
|
290
|
+
u = np.unique(cs.astype(np.int64) * K + nb)
|
|
291
|
+
uc, ul = u // K, u % K
|
|
292
|
+
cnt = np.bincount(uc, minlength=nc)
|
|
293
|
+
one = np.full(nc, -1)
|
|
294
|
+
one[uc[cnt[uc] == 1]] = ul[cnt[uc] == 1]
|
|
295
|
+
flip = small & (one >= 0)
|
|
296
|
+
if surfs: # onto an exact surface only if the island lies on it
|
|
297
|
+
V = np.asarray(mesh.vertices, float)
|
|
298
|
+
F = np.asarray(mesh.faces)
|
|
299
|
+
for c in np.flatnonzero(flip):
|
|
300
|
+
t = int(one[c])
|
|
301
|
+
if t in surfs:
|
|
302
|
+
P = V[F[comp == c].ravel()]
|
|
303
|
+
if surfs[t].dist(P).max() > tol:
|
|
304
|
+
flip[c] = False
|
|
305
|
+
if not flip.any():
|
|
306
|
+
break
|
|
307
|
+
f = flip[comp]
|
|
308
|
+
lab = lab.copy()
|
|
309
|
+
lab[f] = one[comp[f]]
|
|
310
|
+
return lab
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _vertex_sets(F, lab, n):
|
|
314
|
+
K = int(lab.max()) + 1 if len(lab) else 1
|
|
315
|
+
pairs = np.unique(F.ravel().astype(np.int64) * K + np.repeat(lab, 3))
|
|
316
|
+
v, l = pairs // K, pairs % K
|
|
317
|
+
nl = np.bincount(v, minlength=n)
|
|
318
|
+
h = np.zeros(n)
|
|
319
|
+
rnd = np.random.default_rng(7).random(K) + 1.0
|
|
320
|
+
np.add.at(h, v, rnd[l])
|
|
321
|
+
one = np.full(n, -1)
|
|
322
|
+
one[v[nl[v] == 1]] = l[nl[v] == 1]
|
|
323
|
+
return K, pairs, nl, h, one
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _edge_faces(F):
|
|
327
|
+
"""(edges a<b, face on each side): closed manifold, so each edge has two."""
|
|
328
|
+
a = np.r_[F[:, 0], F[:, 1], F[:, 2]]
|
|
329
|
+
b = np.r_[F[:, 1], F[:, 2], F[:, 0]]
|
|
330
|
+
f = np.tile(np.arange(len(F)), 3)
|
|
331
|
+
lo, hi = np.minimum(a, b), np.maximum(a, b)
|
|
332
|
+
key = lo.astype(np.int64) * (int(F.max()) + 1) + hi
|
|
333
|
+
o = np.argsort(key, kind="stable")
|
|
334
|
+
key, f = key[o], f[o]
|
|
335
|
+
first = np.r_[True, key[1:] != key[:-1]]
|
|
336
|
+
i = np.flatnonzero(first)
|
|
337
|
+
j = i + 1
|
|
338
|
+
j = np.minimum(j, len(key) - 1)
|
|
339
|
+
E = np.stack([lo[o][i], hi[o][i]], axis=1)
|
|
340
|
+
return E, key[i], f[i], f[j]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def coarsen(mesh, lab, patches=PATCHES, curved=None, surfs=None, O=None, progress=None):
|
|
344
|
+
"""The labelled mesh coarsened until about `patches` organic triangles
|
|
345
|
+
remain and the exact regions are as coarse as their seams allow. Returns
|
|
346
|
+
(vertex indices into the mesh, faces, labels)."""
|
|
347
|
+
from .decimate import decimate
|
|
348
|
+
|
|
349
|
+
V = np.asarray(mesh.vertices, float)
|
|
350
|
+
F = np.asarray(mesh.faces, np.int64)
|
|
351
|
+
st = {}
|
|
352
|
+
rng = np.random.default_rng(0)
|
|
353
|
+
if O is None: # labels from O up are organic (0 alone, by default)
|
|
354
|
+
O = 0
|
|
355
|
+
if curved is None:
|
|
356
|
+
curved = np.zeros(int(lab.max()) + 1, bool)
|
|
357
|
+
|
|
358
|
+
def allow(E, Fc, lb):
|
|
359
|
+
K, pairs, nl, h, one = _vertex_sets(Fc, lb, int(Fc.max()) + 1)
|
|
360
|
+
a, b = E[:, 0], E[:, 1]
|
|
361
|
+
a_in_b = (nl[a] == 1) & np.isin(b * K + one[a], pairs)
|
|
362
|
+
b_in_a = (nl[b] == 1) & np.isin(a * K + one[b], pairs)
|
|
363
|
+
ok = a_in_b | b_in_a
|
|
364
|
+
# along a seam: both ends on the same two surfaces, the edge between them
|
|
365
|
+
both2 = (nl[a] == 2) & (nl[b] == 2) & (h[a] == h[b])
|
|
366
|
+
if both2.any():
|
|
367
|
+
_, key, f0, f1 = _edge_faces(Fc)
|
|
368
|
+
kq = np.minimum(a, b).astype(np.int64) * (int(Fc.max()) + 1) + np.maximum(a, b)
|
|
369
|
+
pos = np.clip(np.searchsorted(key, kq), 0, len(key) - 1)
|
|
370
|
+
seam = (key[pos] == kq) & (lb[f0[pos]] != lb[f1[pos]])
|
|
371
|
+
ok |= both2 & seam
|
|
372
|
+
# organic triangles: stop at the budget
|
|
373
|
+
org = (one[a] >= O) | (one[b] >= O)
|
|
374
|
+
n_org = int((lb >= O).sum())
|
|
375
|
+
room = max(0, (n_org - int(patches)) // 2)
|
|
376
|
+
oi = np.flatnonzero(ok & org)
|
|
377
|
+
if len(oi) > 4 * room: # near the budget: a spread-out few, not a cluster
|
|
378
|
+
ok[rng.choice(oi, len(oi) - 4 * room, replace=False)] = False
|
|
379
|
+
st.update(nl=nl, one=one, K=K, pairs=pairs)
|
|
380
|
+
return ok
|
|
381
|
+
|
|
382
|
+
def place(W, a, b):
|
|
383
|
+
nl, one, K, pairs = st["nl"], st["one"], st["K"], st["pairs"]
|
|
384
|
+
a_in_b = (nl[a] == 1) & np.isin(b * K + one[a], pairs)
|
|
385
|
+
return np.where(a_in_b[:, None], W[b], W[a])
|
|
386
|
+
|
|
387
|
+
from scipy.spatial import cKDTree
|
|
388
|
+
|
|
389
|
+
tree = cKDTree(V)
|
|
390
|
+
vn = np.asarray(mesh.vertex_normals)
|
|
391
|
+
|
|
392
|
+
def shape(T):
|
|
393
|
+
e = np.stack([T[:, 1] - T[:, 0], T[:, 2] - T[:, 1], T[:, 0] - T[:, 2]], axis=1)
|
|
394
|
+
n = np.cross(e[:, 0], -e[:, 2])
|
|
395
|
+
area2 = np.linalg.norm(n, axis=1)
|
|
396
|
+
q = area2 / np.maximum((e ** 2).sum(axis=2).max(axis=1), 1e-300)
|
|
397
|
+
n = n / np.maximum(area2[:, None], 1e-300)
|
|
398
|
+
_, i = tree.query(T.reshape(-1, 3))
|
|
399
|
+
lean = np.einsum("kj,kcj->kc", n, vn[i].reshape(-1, 3, 3)).min(axis=1)
|
|
400
|
+
return q, lean
|
|
401
|
+
|
|
402
|
+
def quality(Told, Tnew, lb):
|
|
403
|
+
"""No slivers, and no patch leaning far off the surface at its
|
|
404
|
+
corners (a big patch over a tight bend fills badly), unless it was
|
|
405
|
+
already that bad. Exact faces merge back whole: any shape will do."""
|
|
406
|
+
q0, l0 = shape(Told)
|
|
407
|
+
q1, l1 = shape(Tnew)
|
|
408
|
+
organic = ((q1 > ASPECT) | (q1 >= q0)) & ((l1 > LEAN) | (l1 >= l0))
|
|
409
|
+
bend = (l1 > LEAN) | (l1 >= l0) # a bore's triangle: under ~60 degrees of arc
|
|
410
|
+
return np.where(lb >= O, organic, np.where(curved[np.minimum(lb, len(curved) - 1)], bend, True))
|
|
411
|
+
|
|
412
|
+
import trimesh
|
|
413
|
+
|
|
414
|
+
org = trimesh.Trimesh(V, F[lab >= O], process=False) if (lab >= O).any() else None
|
|
415
|
+
W, Fc, lb = V, F, np.asarray(lab)
|
|
416
|
+
for _ in range(60):
|
|
417
|
+
before = len(Fc)
|
|
418
|
+
W, Fc, stats, _ = decimate(W, Fc, None, 0.0, 1.0, max_passes=6, min_gain=0.0, progress=progress,
|
|
419
|
+
place=place, turn=TURN, labels=lb, allow=allow, max_reject=40, quality=quality)
|
|
420
|
+
lb = stats["labels"]
|
|
421
|
+
if (lb >= O).sum() <= patches or len(Fc) > 0.995 * before:
|
|
422
|
+
break
|
|
423
|
+
W = _relax(W, Fc, lb, org, surfs or {}, O)
|
|
424
|
+
# which coarse vertices still sit on one of the mesh's vertices (seams, corners)
|
|
425
|
+
d, vid = tree.query(W)
|
|
426
|
+
vid[d > 1e-9] = -1
|
|
427
|
+
return W, vid, Fc, lb
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _relax(W, F, lb, org, surfs, O, rounds=2, step=0.5):
|
|
431
|
+
"""Even out the patch layout: each vertex inside one region moves halfway
|
|
432
|
+
to its neighbours' average, back onto its surface (the organic mesh, or
|
|
433
|
+
its exact surface). Seam and corner vertices stay put."""
|
|
434
|
+
import trimesh
|
|
435
|
+
|
|
436
|
+
n = len(W)
|
|
437
|
+
_, _, nl, _, one = _vertex_sets(F, lb, n)
|
|
438
|
+
free = (nl == 1)
|
|
439
|
+
E = np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]])
|
|
440
|
+
W = W.copy()
|
|
441
|
+
for _ in range(rounds):
|
|
442
|
+
S = np.zeros_like(W)
|
|
443
|
+
c = np.zeros(n)
|
|
444
|
+
np.add.at(S, E[:, 0], W[E[:, 1]])
|
|
445
|
+
np.add.at(c, E[:, 0], 1)
|
|
446
|
+
A = S / np.maximum(c, 1)[:, None]
|
|
447
|
+
T = W + step * (A - W)
|
|
448
|
+
o = free & (one >= O)
|
|
449
|
+
if org is not None and o.any():
|
|
450
|
+
cp, cn = _closest(org, T[o])
|
|
451
|
+
vn = trimesh.Trimesh(W, F, process=False).vertex_normals[o]
|
|
452
|
+
ok = np.einsum("ij,ij->i", cn, vn) > 0.5
|
|
453
|
+
idx = np.flatnonzero(o)
|
|
454
|
+
W[idx[ok]] = cp[ok]
|
|
455
|
+
for k, sf in surfs.items():
|
|
456
|
+
e = free & (one == k)
|
|
457
|
+
if e.any():
|
|
458
|
+
W[e] = _onto(sf, T[e])
|
|
459
|
+
return W
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _graph(mesh, mask_v=None, mask_e=None):
|
|
463
|
+
import scipy.sparse as sp
|
|
464
|
+
|
|
465
|
+
E = np.asarray(mesh.edges_unique)
|
|
466
|
+
w = np.linalg.norm(mesh.vertices[E[:, 0]] - mesh.vertices[E[:, 1]], axis=1)
|
|
467
|
+
keep = np.ones(len(E), bool)
|
|
468
|
+
if mask_v is not None:
|
|
469
|
+
keep &= mask_v[E[:, 0]] & mask_v[E[:, 1]]
|
|
470
|
+
if mask_e is not None:
|
|
471
|
+
keep &= mask_e
|
|
472
|
+
n = len(mesh.vertices)
|
|
473
|
+
return sp.coo_matrix((w[keep], (E[keep, 0], E[keep, 1])), shape=(n, n)).tocsr()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _path(G, a, b, limit):
|
|
477
|
+
from scipy.sparse.csgraph import dijkstra
|
|
478
|
+
|
|
479
|
+
d, pred = dijkstra(G, directed=False, indices=a, return_predecessors=True, limit=limit)
|
|
480
|
+
if not np.isfinite(d[b]):
|
|
481
|
+
return None
|
|
482
|
+
p = [b]
|
|
483
|
+
while p[-1] != a:
|
|
484
|
+
p.append(pred[p[-1]])
|
|
485
|
+
if p[-1] < 0:
|
|
486
|
+
return None
|
|
487
|
+
return p[::-1]
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _fit(P, tol):
|
|
491
|
+
from OCP.GeomAbs import GeomAbs_C2
|
|
492
|
+
from OCP.GeomAPI import GeomAPI_PointsToBSpline
|
|
493
|
+
from OCP.OCP.collections import Array1_gp_Pnt
|
|
494
|
+
|
|
495
|
+
from .step import _pnt
|
|
496
|
+
|
|
497
|
+
if len(P) > 40: # a long path: keep the ends, thin the middle
|
|
498
|
+
i = np.unique(np.r_[np.linspace(0, len(P) - 1, 40).round().astype(int)])
|
|
499
|
+
P = P[i]
|
|
500
|
+
if len(P) == 2:
|
|
501
|
+
P = np.vstack([P[0], P.mean(axis=0), P[1]])
|
|
502
|
+
arr = Array1_gp_Pnt(1, len(P))
|
|
503
|
+
for j, p in enumerate(P):
|
|
504
|
+
arr.SetValue(j + 1, _pnt(p))
|
|
505
|
+
return GeomAPI_PointsToBSpline(arr, 2, 5, GeomAbs_C2, tol).Curve()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def smooth_brep(mesh, plan, patches=PATCHES, order=3, progress=None):
|
|
509
|
+
"""The part as an OpenCascade solid: exact faces on the plan's planes,
|
|
510
|
+
cylinders and spheres, smooth B-spline patches elsewhere. Returns
|
|
511
|
+
(solid, report). Raises if the construction fails."""
|
|
512
|
+
import time
|
|
513
|
+
|
|
514
|
+
import trimesh
|
|
515
|
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
516
|
+
from OCP.BRepBuilderAPI import (
|
|
517
|
+
BRepBuilderAPI_MakeEdge,
|
|
518
|
+
BRepBuilderAPI_MakeFace,
|
|
519
|
+
BRepBuilderAPI_MakeSolid,
|
|
520
|
+
BRepBuilderAPI_Sewing,
|
|
521
|
+
)
|
|
522
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
523
|
+
from OCP.GeomAbs import GeomAbs_BezierSurface
|
|
524
|
+
from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Solid
|
|
525
|
+
from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|
526
|
+
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
|
|
527
|
+
from OCP.TopExp import TopExp_Explorer
|
|
528
|
+
from OCP.TopoDS import TopoDS
|
|
529
|
+
|
|
530
|
+
from .step import _pnt, _volume
|
|
531
|
+
|
|
532
|
+
t0 = time.time()
|
|
533
|
+
surfs = surfaces(plan) if plan is not None else []
|
|
534
|
+
lab = label(mesh, surfs)
|
|
535
|
+
keys = {}
|
|
536
|
+
geo = {}
|
|
537
|
+
for k, s in enumerate(surfs):
|
|
538
|
+
lk = keys.setdefault(s.key(), k + 1)
|
|
539
|
+
geo.setdefault(lk, s)
|
|
540
|
+
curved = np.zeros(int(lab.max()) + 1, bool)
|
|
541
|
+
for lk, sf in geo.items():
|
|
542
|
+
if lk < len(curved):
|
|
543
|
+
curved[lk] = sf.kind != "plane"
|
|
544
|
+
O = (max(geo) if geo else 0) + 1
|
|
545
|
+
lab = islands(mesh, peninsulas(mesh, creases(mesh, lab, O), O))
|
|
546
|
+
CV, vid, F, flab = coarsen(mesh, lab, patches, curved, geo, O)
|
|
547
|
+
# a coarse patch can end up an island inside another face (a sliver of the
|
|
548
|
+
# body on a flat face): it joins that face
|
|
549
|
+
flab = islands(trimesh.Trimesh(CV, F, process=False), flab, ISLAND, geo)
|
|
550
|
+
V = np.asarray(mesh.vertices, float)
|
|
551
|
+
t1 = time.time()
|
|
552
|
+
|
|
553
|
+
# fine-mesh graphs for the paths
|
|
554
|
+
fl = lab
|
|
555
|
+
Ef, _, f0, f1 = _edge_faces(np.asarray(mesh.faces, np.int64))
|
|
556
|
+
seam_e = fl[f0] != fl[f1]
|
|
557
|
+
import scipy.sparse as sp
|
|
558
|
+
|
|
559
|
+
CE, ckey, cf0, cf1 = _edge_faces(F)
|
|
560
|
+
N = int(order) + 1 # points per patch side
|
|
561
|
+
# the whole surface is the target: a patch near a seam lands on the true
|
|
562
|
+
# surface either side, and a demoted exact region (below) is patches too
|
|
563
|
+
org_mesh = trimesh.Trimesh(V, np.asarray(mesh.faces), process=False)
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
from trimesh.ray.ray_pyembree import RayMeshIntersector # embreex: ~100x faster
|
|
567
|
+
except ImportError:
|
|
568
|
+
from trimesh.ray.ray_triangle import RayMeshIntersector
|
|
569
|
+
|
|
570
|
+
rays = RayMeshIntersector(org_mesh)
|
|
571
|
+
|
|
572
|
+
def pull(P, nrm, reach=None):
|
|
573
|
+
"""Points onto the organic surface along the patch normal (the nearer
|
|
574
|
+
hit either way, where the surface faces the same way); else the
|
|
575
|
+
closest point, where its normal agrees; else left where they are."""
|
|
576
|
+
if not len(P):
|
|
577
|
+
return P
|
|
578
|
+
best = np.full(len(P), np.inf)
|
|
579
|
+
out = P.copy()
|
|
580
|
+
lim = np.full(len(P), RAY_MAX) if reach is None else np.minimum(reach, RAY_MAX)
|
|
581
|
+
for sgn in (1.0, -1.0):
|
|
582
|
+
loc, ri, ti = rays.intersects_location(P, sgn * nrm, multiple_hits=False)
|
|
583
|
+
if not len(ri):
|
|
584
|
+
continue
|
|
585
|
+
d = np.linalg.norm(loc - P[ri], axis=1)
|
|
586
|
+
ok = (np.einsum("ij,ij->i", org_mesh.face_normals[ti], nrm[ri]) > 0.3) & (d < lim[ri]) & (d < best[ri])
|
|
587
|
+
out[ri[ok]] = loc[ok]
|
|
588
|
+
best[ri[ok]] = d[ok]
|
|
589
|
+
miss = ~np.isfinite(best)
|
|
590
|
+
if miss.any():
|
|
591
|
+
cp, cn = _closest(org_mesh, P[miss])
|
|
592
|
+
idx = np.flatnonzero(miss)
|
|
593
|
+
# where it faces the same way; else (a layout triangle wrapped
|
|
594
|
+
# round a thin wall's edge, its normal side-on to the surface)
|
|
595
|
+
# wherever it is within reach: a point left off the surface
|
|
596
|
+
# drags its whole patch off
|
|
597
|
+
ok = (np.einsum("ij,ij->i", cn, nrm[miss]) > 0.3) | (np.linalg.norm(cp - P[miss], axis=1) < lim[miss])
|
|
598
|
+
out[idx[ok]] = cp[ok]
|
|
599
|
+
return out
|
|
600
|
+
|
|
601
|
+
def resample(P, n):
|
|
602
|
+
d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))]
|
|
603
|
+
if d[-1] <= 0:
|
|
604
|
+
return np.repeat(P[:1], n, axis=0)
|
|
605
|
+
t = np.linspace(0, d[-1], n)
|
|
606
|
+
return np.stack([np.interp(t, d, P[:, k]) for k in range(3)], axis=1)
|
|
607
|
+
|
|
608
|
+
def drape(P, nrm, rounds=4):
|
|
609
|
+
"""Polylines (m, n, 3) between pairs of surface points onto the
|
|
610
|
+
surface, evenly spaced: pulling a chord along one direction bunches
|
|
611
|
+
its points up at a bend, and the gaps between them cut the corner."""
|
|
612
|
+
P = np.array(P, float)
|
|
613
|
+
m, n = P.shape[:2]
|
|
614
|
+
R = np.repeat(np.asarray(nrm, float)[:, None], n - 2, axis=1).reshape(-1, 3)
|
|
615
|
+
# the surface under a chord is never further from it than half its
|
|
616
|
+
# length: a hit beyond is another sheet (a thin wall's far side)
|
|
617
|
+
reach = np.repeat(0.5 * np.linalg.norm(P[:, -1] - P[:, 0], axis=1) + TOL, n - 2)
|
|
618
|
+
for k in range(rounds + 1):
|
|
619
|
+
P[:, 1:-1] = pull(P[:, 1:-1].reshape(-1, 3), R, reach).reshape(m, n - 2, 3)
|
|
620
|
+
if k < rounds:
|
|
621
|
+
P = np.stack([resample(x, n) for x in P])
|
|
622
|
+
return P
|
|
623
|
+
|
|
624
|
+
def build(attempt, refine=False):
|
|
625
|
+
"""Edges and faces from the coarse layout: (sewing, counts), or
|
|
626
|
+
(None, regions to demote) when an exact region needs patches."""
|
|
627
|
+
# every coarse edge: a polyline over the surface
|
|
628
|
+
poly, split, fl_edge = {}, {}, {}
|
|
629
|
+
cfn = np.cross(CV[F[:, 1]] - CV[F[:, 0]], CV[F[:, 2]] - CV[F[:, 0]])
|
|
630
|
+
cfn /= np.maximum(np.linalg.norm(cfn, axis=1, keepdims=True), 1e-300)
|
|
631
|
+
# seam paths over the seam vertices alone (a small graph: fast Dijkstra)
|
|
632
|
+
sv = np.unique(Ef[seam_e])
|
|
633
|
+
loc = np.full(len(V), -1)
|
|
634
|
+
loc[sv] = np.arange(len(sv))
|
|
635
|
+
w = np.linalg.norm(V[Ef[seam_e, 0]] - V[Ef[seam_e, 1]], axis=1)
|
|
636
|
+
Gs = sp.coo_matrix((w, (loc[Ef[seam_e, 0]], loc[Ef[seam_e, 1]])), shape=(len(sv), len(sv))).tocsr()
|
|
637
|
+
inner, t17 = [], np.linspace(0, 1, 17)[:, None]
|
|
638
|
+
for (a, b), la, lb, f0e, f1e in zip(CE, flab[cf0], flab[cf1], cf0, cf1):
|
|
639
|
+
a, b = int(a), int(b)
|
|
640
|
+
pa, pb = CV[a], CV[b]
|
|
641
|
+
L = float(np.linalg.norm(pa - pb))
|
|
642
|
+
if la == lb and la < O:
|
|
643
|
+
t = np.linspace(0, 1, 9)[:, None]
|
|
644
|
+
P = _onto(geo[int(la)], pa * (1 - t) + pb * t)
|
|
645
|
+
P[0], P[-1] = pa, pb
|
|
646
|
+
elif la == lb:
|
|
647
|
+
# inside the organic body: the chord draped over the surface (the
|
|
648
|
+
# shortest path over the mesh's edges is a staircase, and two
|
|
649
|
+
# from one corner can share their first half); below, all at once
|
|
650
|
+
nn = cfn[f0e] + cfn[f1e]
|
|
651
|
+
inner.append(((a, b), pa * (1 - t17) + pb * t17, nn / max(np.linalg.norm(nn), 1e-300)))
|
|
652
|
+
P = None
|
|
653
|
+
else:
|
|
654
|
+
pth = _path(Gs, int(loc[vid[a]]), int(loc[vid[b]]), limit=4 * L + 1.0) if vid[a] >= 0 and vid[b] >= 0 else None
|
|
655
|
+
if pth is not None:
|
|
656
|
+
P = V[sv[pth]]
|
|
657
|
+
elif la >= O and lb >= O: # between two patched regions: drape it
|
|
658
|
+
nn = cfn[f0e] + cfn[f1e]
|
|
659
|
+
inner.append(((a, b), pa * (1 - t17) + pb * t17, nn / max(np.linalg.norm(nn), 1e-300)))
|
|
660
|
+
P = None
|
|
661
|
+
else:
|
|
662
|
+
raise RuntimeError(f"no surface path for a seam edge ({vid[a]}, {vid[b]}, labels {la}, {lb})")
|
|
663
|
+
poly[(a, b)] = P
|
|
664
|
+
split[(a, b)] = False # set below: next to a three-quad triangle, two half edges
|
|
665
|
+
fl_edge[(a, b)] = la >= O or lb >= O # organic on a side: a Bezier edge
|
|
666
|
+
if inner:
|
|
667
|
+
Pd = drape(np.stack([x[1] for x in inner]), np.stack([x[2] for x in inner]))
|
|
668
|
+
for (e, _, _), P in zip(inner, Pd):
|
|
669
|
+
poly[e] = P
|
|
670
|
+
|
|
671
|
+
D = N - 1 # patch and edge degree
|
|
672
|
+
Bm = _bernstein(D) # Bm[i, j]: j-th Bernstein at i / D
|
|
673
|
+
Bi = np.linalg.inv(Bm) # points at even parameters -> poles
|
|
674
|
+
Bint = np.linalg.inv(Bm[1:-1, 1:-1])
|
|
675
|
+
|
|
676
|
+
# organic triangles pair up into quads (one patch for two triangles);
|
|
677
|
+
# the rest split into three quads each
|
|
678
|
+
pair = _pairs(CV, F, flab, O)
|
|
679
|
+
single = np.ones(len(F), bool)
|
|
680
|
+
single[[i for i, j in pair]] = False
|
|
681
|
+
single[[j for i, j in pair]] = False
|
|
682
|
+
single &= flab >= O
|
|
683
|
+
for i in np.flatnonzero(single):
|
|
684
|
+
for k in range(3):
|
|
685
|
+
a, b = int(F[i, k]), int(F[i, (k + 1) % 3])
|
|
686
|
+
split[(min(a, b), max(a, b))] = True
|
|
687
|
+
|
|
688
|
+
poles = {}
|
|
689
|
+
|
|
690
|
+
def curve(a, b):
|
|
691
|
+
"""Poles of coarse edge (a, b), from a to b."""
|
|
692
|
+
e = (min(a, b), max(a, b))
|
|
693
|
+
if e not in poles:
|
|
694
|
+
poles[e] = Bi @ resample(poly[e], N)
|
|
695
|
+
return poles[e] if a < b else poles[e][::-1]
|
|
696
|
+
|
|
697
|
+
def casteljau(C):
|
|
698
|
+
"""Poles of the two halves (t <= 1/2, t >= 1/2) of a Bezier curve."""
|
|
699
|
+
L, R = [C[0]], [C[-1]]
|
|
700
|
+
W = C.copy()
|
|
701
|
+
for _ in range(D):
|
|
702
|
+
W = 0.5 * (W[:-1] + W[1:])
|
|
703
|
+
L.append(W[0])
|
|
704
|
+
R.append(W[-1])
|
|
705
|
+
return np.array(L), np.array(R[::-1])
|
|
706
|
+
|
|
707
|
+
def half(a, b):
|
|
708
|
+
"""Poles from corner a to the midpoint of edge (a, b)."""
|
|
709
|
+
return casteljau(curve(a, b))[0]
|
|
710
|
+
|
|
711
|
+
def value(C, t):
|
|
712
|
+
from math import comb
|
|
713
|
+
|
|
714
|
+
t = np.atleast_1d(t)
|
|
715
|
+
B = np.array([[comb(D, j) * x ** j * (1 - x) ** (D - j) for j in range(D + 1)] for x in t])
|
|
716
|
+
return B @ C
|
|
717
|
+
|
|
718
|
+
def make_curve(C):
|
|
719
|
+
from OCP.Geom import Geom_BezierCurve
|
|
720
|
+
from OCP.OCP.collections import Array1_gp_Pnt
|
|
721
|
+
|
|
722
|
+
arr = Array1_gp_Pnt(1, len(C))
|
|
723
|
+
for j, c in enumerate(C):
|
|
724
|
+
arr.SetValue(j + 1, _pnt(c))
|
|
725
|
+
return Geom_BezierCurve(arr)
|
|
726
|
+
|
|
727
|
+
def coons(bottom, top, left, right):
|
|
728
|
+
"""Points of the Coons blend of a patch's four sides (poles: bottom,
|
|
729
|
+
top along u; left, right along v; corners shared) at even parameters."""
|
|
730
|
+
sb, st_, sl, sr = (Bm @ x for x in (bottom, top, left, right))
|
|
731
|
+
U, W = np.meshgrid(u, u, indexing="ij")
|
|
732
|
+
X = ((1 - W)[..., None] * sb[:, None] + W[..., None] * st_[:, None]
|
|
733
|
+
+ (1 - U)[..., None] * sl[None] + U[..., None] * sr[None]
|
|
734
|
+
- ((1 - U) * (1 - W))[..., None] * sb[0] - (U * (1 - W))[..., None] * sb[-1]
|
|
735
|
+
- ((1 - U) * W)[..., None] * st_[0] - (U * W)[..., None] * st_[-1])
|
|
736
|
+
return X
|
|
737
|
+
|
|
738
|
+
def poles_of(bottom, top, left, right, X):
|
|
739
|
+
"""Poles of the Bezier patch with those sides through points X
|
|
740
|
+
(N, N, 3): its inside poles solved for, so it passes through them."""
|
|
741
|
+
C = np.zeros((N, N, 3))
|
|
742
|
+
C[:, 0], C[:, -1], C[0, :], C[-1, :] = bottom, top, left, right
|
|
743
|
+
Cb = C.copy()
|
|
744
|
+
Cb[1:-1, 1:-1] = 0.0
|
|
745
|
+
R = X[1:-1, 1:-1] - np.einsum("ia,abk,jb->ijk", Bm[1:-1], Cb, Bm[1:-1])
|
|
746
|
+
C[1:-1, 1:-1] = np.einsum("ia,abk,jb->ijk", Bint, R, Bint)
|
|
747
|
+
return C
|
|
748
|
+
|
|
749
|
+
def patch(C):
|
|
750
|
+
from OCP.Geom import Geom_BezierSurface
|
|
751
|
+
from OCP.OCP.collections import Array2_gp_Pnt
|
|
752
|
+
|
|
753
|
+
arr = Array2_gp_Pnt(1, N, 1, N)
|
|
754
|
+
for i in range(N):
|
|
755
|
+
for j in range(N):
|
|
756
|
+
arr.SetValue(i + 1, j + 1, _pnt(C[i, j]))
|
|
757
|
+
return BRepBuilderAPI_MakeFace(Geom_BezierSurface(arr), 1e-7).Face()
|
|
758
|
+
|
|
759
|
+
edge_cache = {}
|
|
760
|
+
surf_geom = {}
|
|
761
|
+
|
|
762
|
+
def edge(key, C=None, P=None):
|
|
763
|
+
if key not in edge_cache:
|
|
764
|
+
edge_cache[key] = BRepBuilderAPI_MakeEdge(make_curve(C) if C is not None else _fit(P, 1e-5)).Edge()
|
|
765
|
+
return edge_cache[key]
|
|
766
|
+
|
|
767
|
+
sew = BRepBuilderAPI_Sewing(SEW)
|
|
768
|
+
u = np.linspace(0, 1, N)
|
|
769
|
+
nq = 0
|
|
770
|
+
def side(a, b):
|
|
771
|
+
"""[(edge, reversed?)] along coarse edge a -> b."""
|
|
772
|
+
e = (min(a, b), max(a, b))
|
|
773
|
+
if split[e]:
|
|
774
|
+
return [(edge(("h", a, e), half(a, b)), False), (edge(("h", b, e), half(b, a)), True)]
|
|
775
|
+
if fl_edge[e]:
|
|
776
|
+
return [(edge(("e", e), curve(e[0], e[1])), a > b)]
|
|
777
|
+
return [(edge(("p", e), P=poly[e]), a > b)]
|
|
778
|
+
|
|
779
|
+
# each connected exact region is one face, bounded by its seams
|
|
780
|
+
import scipy.sparse as sp2
|
|
781
|
+
from scipy.sparse.csgraph import connected_components as _cc
|
|
782
|
+
|
|
783
|
+
n_region = n_fallback = 0
|
|
784
|
+
why, demote = [], []
|
|
785
|
+
exact = flab < O
|
|
786
|
+
same = exact[cf0] & (flab[cf0] == flab[cf1])
|
|
787
|
+
G = sp2.coo_matrix((np.ones(same.sum()), (cf0[same], cf1[same])), shape=(len(F), len(F)))
|
|
788
|
+
_, comp = _cc(G, directed=False)
|
|
789
|
+
comp = np.where(exact, comp, -1)
|
|
790
|
+
for c in np.unique(comp[exact]):
|
|
791
|
+
tris = np.flatnonzero(comp == c)
|
|
792
|
+
lf = int(flab[tris[0]])
|
|
793
|
+
if lf not in surf_geom:
|
|
794
|
+
surf_geom[lf] = geo[lf].geom()
|
|
795
|
+
inside = {int(t) for t in tris}
|
|
796
|
+
out_edges = {}
|
|
797
|
+
for t in tris:
|
|
798
|
+
f = [int(x) for x in F[t]]
|
|
799
|
+
for k in range(3):
|
|
800
|
+
a, b = f[k], f[(k + 1) % 3]
|
|
801
|
+
e = (min(a, b), max(a, b))
|
|
802
|
+
pos = int(np.searchsorted(ckey, e[0] * (int(F.max()) + 1) + e[1]))
|
|
803
|
+
other = int(cf1[pos]) if int(cf0[pos]) == t else int(cf0[pos])
|
|
804
|
+
if other not in inside:
|
|
805
|
+
out_edges.setdefault(a, []).append(b)
|
|
806
|
+
# a seam path running through another corner of this boundary
|
|
807
|
+
# doubles back: those corners' triangles go to patches first
|
|
808
|
+
bv = sorted(set(out_edges) | {b for bs in out_edges.values() for b in bs})
|
|
809
|
+
spikes = set()
|
|
810
|
+
if attempt < 4:
|
|
811
|
+
from scipy.spatial import cKDTree as _KD
|
|
812
|
+
|
|
813
|
+
kd = _KD(CV[bv])
|
|
814
|
+
for a, bs in out_edges.items():
|
|
815
|
+
for b in bs:
|
|
816
|
+
Pp = poly[(min(a, b), max(a, b))]
|
|
817
|
+
if Pp is None or len(Pp) < 3:
|
|
818
|
+
continue
|
|
819
|
+
for hit in kd.query_ball_point(Pp[1:-1], 1e-6):
|
|
820
|
+
spikes.update(bv[h] for h in hit if bv[h] not in (a, b))
|
|
821
|
+
loops = []
|
|
822
|
+
while out_edges:
|
|
823
|
+
a0 = next(iter(out_edges))
|
|
824
|
+
loop, a = [], a0
|
|
825
|
+
while True:
|
|
826
|
+
b = out_edges[a].pop()
|
|
827
|
+
if not out_edges[a]:
|
|
828
|
+
del out_edges[a]
|
|
829
|
+
loop += side(a, b)
|
|
830
|
+
a = b
|
|
831
|
+
if a == a0 or a not in out_edges:
|
|
832
|
+
break
|
|
833
|
+
loops.append(loop)
|
|
834
|
+
T = CV[F[tris]]
|
|
835
|
+
tn = np.cross(T[:, 1] - T[:, 0], T[:, 2] - T[:, 0])
|
|
836
|
+
flip = float(np.einsum("ij,ij->", tn, geo[lf].normal(T.mean(axis=1)))) < 0
|
|
837
|
+
try:
|
|
838
|
+
sew.Add(_region_face(geo[lf], surf_geom[lf], loops, flip))
|
|
839
|
+
n_region += 1
|
|
840
|
+
continue
|
|
841
|
+
except Exception as e: # noqa: BLE001 - patches or one face per triangle still work
|
|
842
|
+
why.append(f"{geo[lf].kind} with {len(loops)} loops: {e}"[:200])
|
|
843
|
+
if attempt < 4:
|
|
844
|
+
near = np.isin(F[tris], list(spikes)).any(axis=1) if spikes else np.zeros(len(tris), bool)
|
|
845
|
+
demote.append(tris[near] if near.any() and attempt == 0 else tris)
|
|
846
|
+
continue
|
|
847
|
+
n_fallback += 1
|
|
848
|
+
for t in tris: # one face per triangle, merged later
|
|
849
|
+
f = [int(x) for x in F[t]]
|
|
850
|
+
loop = side(f[0], f[1]) + side(f[1], f[2]) + side(f[2], f[0])
|
|
851
|
+
sew.Add(_exact_face(geo[lf], surf_geom[lf], loop, CV[f].mean(axis=0)))
|
|
852
|
+
if demote:
|
|
853
|
+
# a region whose boundary will not make one valid face (a seam
|
|
854
|
+
# that doubles back on itself): smooth patches there instead,
|
|
855
|
+
# flat on its plane, and build again
|
|
856
|
+
return None, demote
|
|
857
|
+
todo, owner = [], [] # (sides, normal) of every patch, its triangles
|
|
858
|
+
# pairs: one patch over the quad a, c, b, d (shared edge a-b, c and d the far corners)
|
|
859
|
+
for i, j in pair:
|
|
860
|
+
fi, fj = [int(x) for x in F[i]], [int(x) for x in F[j]]
|
|
861
|
+
a, b = sorted(set(fi) & set(fj))
|
|
862
|
+
c = (set(fi) - {a, b}).pop()
|
|
863
|
+
d = (set(fj) - {a, b}).pop()
|
|
864
|
+
n = cfn[i] + cfn[j]
|
|
865
|
+
n /= max(np.linalg.norm(n), 1e-300)
|
|
866
|
+
# corners 00 = a, 10 = c, 01 = d, 11 = b
|
|
867
|
+
todo.append(((curve(a, c), curve(d, b), curve(a, d), curve(c, b)), n))
|
|
868
|
+
owner.append((i, j))
|
|
869
|
+
nq += 1
|
|
870
|
+
# odd triangles: three patches each, centroid to edge midpoints;
|
|
871
|
+
# the centroids and spokes of all of them pulled in one go
|
|
872
|
+
odd = []
|
|
873
|
+
for i in np.flatnonzero(single):
|
|
874
|
+
f = [int(x) for x in F[i]]
|
|
875
|
+
H = {(x, y): half(x, y) for x in f for y in f if x != y}
|
|
876
|
+
Mid = {(min(x, y), max(x, y)): H[(x, y)][-1] for (x, y) in H}
|
|
877
|
+
odd.append((i, f, H, Mid, np.mean([*CV[f], *Mid.values()], axis=0)))
|
|
878
|
+
if odd:
|
|
879
|
+
Gc = pull(np.stack([o[4] for o in odd]), cfn[[o[0] for o in odd]],
|
|
880
|
+
np.array([np.ptp(CV[o[1]], axis=0).max() for o in odd]) + TOL)
|
|
881
|
+
t = np.linspace(0, 1, 13)[:, None]
|
|
882
|
+
lines = np.stack([Gc[q] * (1 - t) + M * t for q, o in enumerate(odd) for M in o[3].values()])
|
|
883
|
+
lines = drape(lines, np.repeat(cfn[[o[0] for o in odd]], 3, axis=0))
|
|
884
|
+
for q, (i, f, H, Mid, _) in enumerate(odd):
|
|
885
|
+
n = cfn[i]
|
|
886
|
+
spoke = {e: Bi @ resample(lines[3 * q + r], N) for r, e in enumerate(Mid)}
|
|
887
|
+
for k in range(3):
|
|
888
|
+
c, j, i2 = f[k], f[(k + 1) % 3], f[(k + 2) % 3]
|
|
889
|
+
e1, e2 = (min(c, j), max(c, j)), (min(c, i2), max(c, i2))
|
|
890
|
+
# corners 00 = c, 10 = M(c, j), 01 = M(c, i), 11 = G
|
|
891
|
+
todo.append(((H[(c, j)], spoke[e2][::-1], H[(c, i2)], spoke[e1][::-1]), n))
|
|
892
|
+
owner.append((i,))
|
|
893
|
+
nq += 1
|
|
894
|
+
# every patch's inside onto the surface in one go
|
|
895
|
+
if todo:
|
|
896
|
+
X = np.stack([coons(*sd) for sd, _ in todo])
|
|
897
|
+
inner = X[:, 1:-1, 1:-1].reshape(-1, 3)
|
|
898
|
+
nrm = np.repeat(np.stack([n for _, n in todo]), (N - 2) ** 2, axis=0)
|
|
899
|
+
# a point of the inside is never further off the surface than
|
|
900
|
+
# its patch is across: a hit further than that is another sheet
|
|
901
|
+
size = np.linalg.norm(X[:, 0, 0] - X[:, -1, -1], axis=1) + np.linalg.norm(X[:, 0, -1] - X[:, -1, 0], axis=1)
|
|
902
|
+
reach = np.repeat(0.5 * size, (N - 2) ** 2)
|
|
903
|
+
X[:, 1:-1, 1:-1] = pull(inner, nrm, reach).reshape(len(todo), N - 2, N - 2, 3)
|
|
904
|
+
Cs = [poles_of(*sd, x) for (sd, _), x in zip(todo, X)]
|
|
905
|
+
if refine:
|
|
906
|
+
split = deviant(Cs, owner, edge_cache, poly)
|
|
907
|
+
if split:
|
|
908
|
+
return "split", (split, poly)
|
|
909
|
+
for C in Cs:
|
|
910
|
+
sew.Add(patch(C))
|
|
911
|
+
sew.Perform()
|
|
912
|
+
return sew, (nq, n_region, n_fallback, why)
|
|
913
|
+
|
|
914
|
+
def close(sew):
|
|
915
|
+
"""The sewn faces as a solid; exact regions that fell back to one
|
|
916
|
+
face per triangle merge back (patches stay patches)."""
|
|
917
|
+
ex = TopExp_Explorer(sew.SewedShape(), TopAbs_SHELL)
|
|
918
|
+
shells = []
|
|
919
|
+
while ex.More():
|
|
920
|
+
shells.append(TopoDS.Shell(ex.Current()))
|
|
921
|
+
ex.Next()
|
|
922
|
+
if not shells or sew.NbFreeEdges():
|
|
923
|
+
raise RuntimeError(f"patches did not close ({len(shells)} shells, {sew.NbFreeEdges()} free edges)")
|
|
924
|
+
# more than one shell: sealed voids inside (a sandwich's skin round
|
|
925
|
+
# its lattice), the biggest is the outside
|
|
926
|
+
size = [abs(_volume(BRepBuilderAPI_MakeSolid(sh).Solid())) for sh in shells]
|
|
927
|
+
order = np.argsort(size)[::-1]
|
|
928
|
+
mk = BRepBuilderAPI_MakeSolid(shells[int(order[0])])
|
|
929
|
+
for k in order[1:]:
|
|
930
|
+
mk.Add(shells[int(k)])
|
|
931
|
+
fx = ShapeFix_Solid(mk.Solid())
|
|
932
|
+
fx.Perform()
|
|
933
|
+
solid = fx.Solid()
|
|
934
|
+
if _volume(solid) < 0:
|
|
935
|
+
solid.Reverse()
|
|
936
|
+
u = ShapeUpgrade_UnifySameDomain(solid, False, True, False)
|
|
937
|
+
fe = TopExp_Explorer(solid, TopAbs_FACE)
|
|
938
|
+
while fe.More():
|
|
939
|
+
if BRepAdaptor_Surface(TopoDS.Face(fe.Current())).GetType() == GeomAbs_BezierSurface:
|
|
940
|
+
ee = TopExp_Explorer(fe.Current(), TopAbs_EDGE)
|
|
941
|
+
while ee.More():
|
|
942
|
+
u.KeepShape(ee.Current())
|
|
943
|
+
ee.Next()
|
|
944
|
+
fe.Next()
|
|
945
|
+
u.Build()
|
|
946
|
+
merged = u.Shape()
|
|
947
|
+
if not BRepCheck_Analyzer(merged).IsValid():
|
|
948
|
+
sf = ShapeFix_Shape(merged)
|
|
949
|
+
sf.Perform()
|
|
950
|
+
merged = sf.Shape()
|
|
951
|
+
return merged if BRepCheck_Analyzer(merged).IsValid() or not BRepCheck_Analyzer(solid).IsValid() else solid
|
|
952
|
+
|
|
953
|
+
def bad_regions(solid):
|
|
954
|
+
"""Coarse triangles of the exact regions behind the solid's invalid
|
|
955
|
+
faces (sewing can still tangle a boundary a lone face had right)."""
|
|
956
|
+
from scipy.spatial import cKDTree
|
|
957
|
+
|
|
958
|
+
ex_t = np.flatnonzero(flab < O)
|
|
959
|
+
if not len(ex_t):
|
|
960
|
+
return []
|
|
961
|
+
kd = cKDTree(CV[F[ex_t]].mean(axis=1))
|
|
962
|
+
out = []
|
|
963
|
+
fe = TopExp_Explorer(solid, TopAbs_FACE)
|
|
964
|
+
while fe.More():
|
|
965
|
+
f = TopoDS.Face(fe.Current())
|
|
966
|
+
if BRepAdaptor_Surface(f).GetType() != GeomAbs_BezierSurface and not BRepCheck_Analyzer(f).IsValid():
|
|
967
|
+
tm = shape_to_trimesh(f, deflection=0.05, angle=0.3)
|
|
968
|
+
if len(tm.faces):
|
|
969
|
+
_, i = kd.query(tm.triangles_center[np.argmax(tm.area_faces)])
|
|
970
|
+
t = ex_t[i]
|
|
971
|
+
out.append(np.flatnonzero(comp_of(t)))
|
|
972
|
+
fe.Next()
|
|
973
|
+
return out
|
|
974
|
+
|
|
975
|
+
def comp_of(t):
|
|
976
|
+
"""Mask of the exact region (connected, one label) holding triangle t."""
|
|
977
|
+
import scipy.sparse as sp3
|
|
978
|
+
from scipy.sparse.csgraph import connected_components
|
|
979
|
+
|
|
980
|
+
same = (flab[cf0] == flab[cf1]) & (flab[cf0] == flab[t])
|
|
981
|
+
G = sp3.coo_matrix((np.ones(same.sum()), (cf0[same], cf1[same])), shape=(len(F), len(F)))
|
|
982
|
+
_, c = connected_components(G, directed=False)
|
|
983
|
+
return (c == c[t]) & (flab == flab[t])
|
|
984
|
+
|
|
985
|
+
from math import comb as _comb
|
|
986
|
+
|
|
987
|
+
from .sharp import mesh_distance
|
|
988
|
+
from .step import shape_to_trimesh
|
|
989
|
+
|
|
990
|
+
g9 = np.linspace(0, 1, 9)
|
|
991
|
+
B9 = np.array([[_comb(N - 1, j) * x ** j * (1 - x) ** (N - 1 - j) for j in range(N)] for x in g9])
|
|
992
|
+
|
|
993
|
+
vn = np.asarray(mesh.vertex_normals)
|
|
994
|
+
|
|
995
|
+
def bend(e):
|
|
996
|
+
"""How much the surface turns along coarse edge e, times its length:
|
|
997
|
+
the edge to split across a tight bend (the longest one would cut along it)."""
|
|
998
|
+
_, i = vtree.query(CV[list(e)])
|
|
999
|
+
c = float(np.clip(vn[i[0]] @ vn[i[1]], -1, 1))
|
|
1000
|
+
return (np.arccos(c) + 0.05) * float(np.linalg.norm(CV[e[0]] - CV[e[1]]))
|
|
1001
|
+
|
|
1002
|
+
worst = [0.0]
|
|
1003
|
+
|
|
1004
|
+
def deviant(Cs, owner, edges, poly):
|
|
1005
|
+
"""Coarse edges to split: those whose curve misses the mesh by more
|
|
1006
|
+
than REFINE, and the longest edge of each triangle under a patch that
|
|
1007
|
+
does. Edges shorter than MIN_SPLIT stay."""
|
|
1008
|
+
from OCP.BRep import BRep_Tool
|
|
1009
|
+
|
|
1010
|
+
S = np.einsum("ua,nabk,vb->nuvk", B9, np.stack(Cs), B9).reshape(len(Cs), -1, 3)
|
|
1011
|
+
dp = mesh_distance(mesh, S.reshape(-1, 3), cap=2.0).reshape(len(Cs), -1).max(axis=1)
|
|
1012
|
+
keys = list(edges)
|
|
1013
|
+
P = []
|
|
1014
|
+
for k in keys:
|
|
1015
|
+
c = BRep_Tool.Curve_s(edges[k], 0.0, 1.0)
|
|
1016
|
+
t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
|
|
1017
|
+
P.append([[q.X(), q.Y(), q.Z()] for q in (c.Value(x) for x in t)])
|
|
1018
|
+
de = mesh_distance(mesh, np.array(P, float).reshape(-1, 3), cap=2.0).reshape(len(keys), -1).max(axis=1) if keys else []
|
|
1019
|
+
out = {k[-1] for k, d in zip(keys, de) if d > REFINE}
|
|
1020
|
+
worst[0] = float(dp.max()) if len(dp) else 0.0
|
|
1021
|
+
# and every edge whose draped path is off the surface (a coarse
|
|
1022
|
+
# triangle bridging a groove: its chord found no surface to fall on)
|
|
1023
|
+
pe = [e for e, P in poly.items() if P is not None]
|
|
1024
|
+
if pe:
|
|
1025
|
+
dl = mesh_distance(mesh, np.concatenate([poly[e] for e in pe]), cap=2.0)
|
|
1026
|
+
cut = np.cumsum([len(poly[e]) for e in pe])[:-1]
|
|
1027
|
+
out |= {e for e, d in zip(pe, np.split(dl, cut)) if d.max() > REFINE}
|
|
1028
|
+
for C_owner, d in zip(owner, dp):
|
|
1029
|
+
if d > REFINE:
|
|
1030
|
+
for t in C_owner:
|
|
1031
|
+
f = [int(x) for x in F[t]]
|
|
1032
|
+
es = [(min(f[k], f[(k + 1) % 3]), max(f[k], f[(k + 1) % 3])) for k in range(3)]
|
|
1033
|
+
out.add(max(es, key=bend))
|
|
1034
|
+
return {e for e in out if np.linalg.norm(CV[e[0]] - CV[e[1]]) > MIN_SPLIT and e in poly and poly[e] is not None}
|
|
1035
|
+
|
|
1036
|
+
from scipy.spatial import cKDTree as _KDT
|
|
1037
|
+
|
|
1038
|
+
vtree = _KDT(V)
|
|
1039
|
+
|
|
1040
|
+
def split_edges(split, poly):
|
|
1041
|
+
"""The coarse mesh with those edges cut at their middle on the
|
|
1042
|
+
surface (a seam's at its mesh vertex), triangles split to match."""
|
|
1043
|
+
nonlocal CV, vid, F, flab
|
|
1044
|
+
mid = {}
|
|
1045
|
+
kq = {int(k): (int(a), int(b)) for k, a, b in zip(ckey, cf0, cf1)}
|
|
1046
|
+
for e in split:
|
|
1047
|
+
P = poly[e]
|
|
1048
|
+
d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(P, axis=0), axis=1))]
|
|
1049
|
+
i = int(np.argmin(np.abs(d - 0.5 * d[-1])))
|
|
1050
|
+
g0, g1 = kq[e[0] * (int(F.max()) + 1) + e[1]]
|
|
1051
|
+
seam = flab[g0] != flab[g1]
|
|
1052
|
+
if seam and not 0 < i < len(P) - 1:
|
|
1053
|
+
continue # a seam splits only at one of its mesh vertices
|
|
1054
|
+
if 0 < i < len(P) - 1:
|
|
1055
|
+
q = P[i]
|
|
1056
|
+
else:
|
|
1057
|
+
w = 0.5 * d[-1]
|
|
1058
|
+
j = min(max(int(np.searchsorted(d, w)), 1), len(P) - 1)
|
|
1059
|
+
q = P[j - 1] + (P[j] - P[j - 1]) * (w - d[j - 1]) / max(d[j] - d[j - 1], 1e-300)
|
|
1060
|
+
dd, vi = vtree.query(q)
|
|
1061
|
+
mid[e] = (q, int(vi) if dd < 1e-9 else -1)
|
|
1062
|
+
idx = {}
|
|
1063
|
+
CVl, vidl = list(CV), list(vid)
|
|
1064
|
+
for e, (q, vi) in mid.items():
|
|
1065
|
+
idx[e] = len(CVl)
|
|
1066
|
+
CVl.append(q)
|
|
1067
|
+
vidl.append(vi)
|
|
1068
|
+
nF, nL = [], []
|
|
1069
|
+
for f, lf in zip(F, flab):
|
|
1070
|
+
v = [int(x) for x in f]
|
|
1071
|
+
m = [idx.get((min(v[k], v[(k + 1) % 3]), max(v[k], v[(k + 1) % 3]))) for k in range(3)]
|
|
1072
|
+
cnt = sum(x is not None for x in m)
|
|
1073
|
+
if cnt == 0:
|
|
1074
|
+
tri = [v]
|
|
1075
|
+
elif cnt == 3:
|
|
1076
|
+
tri = [[v[0], m[0], m[2]], [m[0], v[1], m[1]], [m[2], m[1], v[2]], [m[0], m[1], m[2]]]
|
|
1077
|
+
else:
|
|
1078
|
+
r = 0
|
|
1079
|
+
if cnt == 1:
|
|
1080
|
+
r = next(k for k in range(3) if m[k] is not None)
|
|
1081
|
+
else:
|
|
1082
|
+
r = (next(k for k in range(3) if m[k] is None) + 1) % 3
|
|
1083
|
+
v = v[r:] + v[:r]
|
|
1084
|
+
m = m[r:] + m[:r]
|
|
1085
|
+
if cnt == 1:
|
|
1086
|
+
tri = [[v[0], m[0], v[2]], [m[0], v[1], v[2]]]
|
|
1087
|
+
else: # m[0] on v0-v1, m[1] on v1-v2, v2-v0 whole
|
|
1088
|
+
tri = [[m[0], v[1], m[1]], [v[0], m[0], m[1]], [v[0], m[1], v[2]]]
|
|
1089
|
+
nF += tri
|
|
1090
|
+
nL += [int(lf)] * len(tri)
|
|
1091
|
+
CV, vid = np.array(CVl, float), np.array(vidl)
|
|
1092
|
+
F, flab = np.array(nF, np.int64), np.array(nL, np.int64)
|
|
1093
|
+
|
|
1094
|
+
def snap():
|
|
1095
|
+
"""A vertex inside the organic body that is off the mesh (one of an
|
|
1096
|
+
exact region's, slid along its surface's extension past the region,
|
|
1097
|
+
until the region became patches): onto the mesh, or every patch
|
|
1098
|
+
round it misses by as much and no splitting can help."""
|
|
1099
|
+
nonlocal CV, vid
|
|
1100
|
+
_, _, nl, _, one = _vertex_sets(F, flab, len(CV))
|
|
1101
|
+
o = np.flatnonzero((nl == 1) & (one >= O))
|
|
1102
|
+
if len(o):
|
|
1103
|
+
cp, _ = _closest(mesh, CV[o])
|
|
1104
|
+
far = np.flatnonzero(np.linalg.norm(cp - CV[o], axis=1) > TOL)
|
|
1105
|
+
if len(far):
|
|
1106
|
+
CV, vid = CV.copy(), vid.copy()
|
|
1107
|
+
CV[o[far]] = cp[far]
|
|
1108
|
+
vid[o[far]] = -1
|
|
1109
|
+
unfold()
|
|
1110
|
+
|
|
1111
|
+
def unfold(passes=4):
|
|
1112
|
+
"""Flip edges of organic coarse triangles that face against the
|
|
1113
|
+
surface under them (a collapse or a split folded the layout there):
|
|
1114
|
+
the pulls along a backwards normal miss, and the patch folds over
|
|
1115
|
+
its neighbours."""
|
|
1116
|
+
nonlocal F
|
|
1117
|
+
for _ in range(passes):
|
|
1118
|
+
org = np.flatnonzero(flab >= O)
|
|
1119
|
+
fn = lambda T: np.cross(T[..., 1, :] - T[..., 0, :], T[..., 2, :] - T[..., 0, :])
|
|
1120
|
+
T = CV[F[org]]
|
|
1121
|
+
n = fn(T)
|
|
1122
|
+
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
|
|
1123
|
+
_, sn = _closest(mesh, T.mean(axis=1))
|
|
1124
|
+
bad = org[np.einsum("ij,ij->i", n, sn) < 0.3]
|
|
1125
|
+
if not len(bad):
|
|
1126
|
+
return
|
|
1127
|
+
owner_e = {}
|
|
1128
|
+
for t, f in enumerate(F):
|
|
1129
|
+
for k in range(3):
|
|
1130
|
+
owner_e.setdefault((min(f[k], f[(k + 1) % 3]), max(f[k], f[(k + 1) % 3])), []).append(t)
|
|
1131
|
+
done, changed = set(), False
|
|
1132
|
+
for t in bad:
|
|
1133
|
+
if t in done:
|
|
1134
|
+
continue
|
|
1135
|
+
f = [int(x) for x in F[t]]
|
|
1136
|
+
best = None
|
|
1137
|
+
for k in range(3):
|
|
1138
|
+
a, b, c = f[k], f[(k + 1) % 3], f[(k + 2) % 3]
|
|
1139
|
+
ts = owner_e[(min(a, b), max(a, b))]
|
|
1140
|
+
if len(ts) != 2:
|
|
1141
|
+
continue
|
|
1142
|
+
u = ts[0] if ts[1] == t else ts[1]
|
|
1143
|
+
if u in done or flab[u] != flab[t]:
|
|
1144
|
+
continue
|
|
1145
|
+
d = next(int(x) for x in F[u] if x not in (a, b))
|
|
1146
|
+
if (min(c, d), max(c, d)) in owner_e:
|
|
1147
|
+
continue # the flipped edge exists already
|
|
1148
|
+
new = np.array([[c, a, d], [c, d, b]])
|
|
1149
|
+
Tn = CV[new]
|
|
1150
|
+
nn = fn(Tn)
|
|
1151
|
+
area = np.linalg.norm(nn, axis=1)
|
|
1152
|
+
if area.min() < 1e-9:
|
|
1153
|
+
continue
|
|
1154
|
+
_, s2 = _closest(mesh, Tn.mean(axis=1))
|
|
1155
|
+
q = np.einsum("ij,ij->i", nn / area[:, None], s2).min()
|
|
1156
|
+
if q > 0.5 and (best is None or q > best[0]):
|
|
1157
|
+
best = (q, u, new)
|
|
1158
|
+
if best is not None:
|
|
1159
|
+
_, u, new = best
|
|
1160
|
+
F = F.copy()
|
|
1161
|
+
F[t], F[u] = new[0], new[1]
|
|
1162
|
+
done |= {t, int(u)}
|
|
1163
|
+
changed = True
|
|
1164
|
+
if not changed:
|
|
1165
|
+
return
|
|
1166
|
+
|
|
1167
|
+
# refine while it helps: a split can also fold the layout somewhere
|
|
1168
|
+
# (a big triangle wrapped round a thin wall's edge), so the best layout
|
|
1169
|
+
# so far, by its worst patch, is kept and refining stops once two
|
|
1170
|
+
# rounds in a row are no better
|
|
1171
|
+
refined, best, worse = 0, None, 0
|
|
1172
|
+
for r in range(REFINE_ROUNDS + 1):
|
|
1173
|
+
snap()
|
|
1174
|
+
CE, ckey, cf0, cf1 = _edge_faces(F)
|
|
1175
|
+
sew, got = build(0, refine=True)
|
|
1176
|
+
if sew is None: # a region to patch first
|
|
1177
|
+
for tris in got:
|
|
1178
|
+
flab[tris] = int(flab.max()) + 1
|
|
1179
|
+
best, worse = None, 0
|
|
1180
|
+
continue
|
|
1181
|
+
if not isinstance(sew, str):
|
|
1182
|
+
best = None # nothing left to split: this one
|
|
1183
|
+
break
|
|
1184
|
+
split, poly_ = got
|
|
1185
|
+
if best is None or worst[0] < best[0]:
|
|
1186
|
+
best, worse = (worst[0], CV, vid, F, flab, refined), 0
|
|
1187
|
+
else:
|
|
1188
|
+
worse += 1
|
|
1189
|
+
if r == REFINE_ROUNDS or worse >= 2:
|
|
1190
|
+
break
|
|
1191
|
+
split_edges(split, poly_)
|
|
1192
|
+
refined += len(split)
|
|
1193
|
+
if best is not None:
|
|
1194
|
+
_, CV, vid, F, flab, refined = best
|
|
1195
|
+
CE, ckey, cf0, cf1 = _edge_faces(F)
|
|
1196
|
+
|
|
1197
|
+
demoted = 0
|
|
1198
|
+
for attempt in range(5):
|
|
1199
|
+
snap()
|
|
1200
|
+
CE, ckey, cf0, cf1 = _edge_faces(F)
|
|
1201
|
+
sew, got = build(attempt)
|
|
1202
|
+
if sew is None:
|
|
1203
|
+
for tris in got:
|
|
1204
|
+
flab[tris] = int(flab.max()) + 1
|
|
1205
|
+
demoted += len(got)
|
|
1206
|
+
continue
|
|
1207
|
+
solid = close(sew)
|
|
1208
|
+
bad = bad_regions(solid) if not BRepCheck_Analyzer(solid).IsValid() and attempt < 4 else []
|
|
1209
|
+
if not bad:
|
|
1210
|
+
break
|
|
1211
|
+
for tris in bad:
|
|
1212
|
+
flab[tris] = int(flab.max()) + 1
|
|
1213
|
+
demoted += len(bad)
|
|
1214
|
+
nq, n_region, n_fallback, why = got
|
|
1215
|
+
solid = _forward(solid)
|
|
1216
|
+
t3 = time.time()
|
|
1217
|
+
rep = {"patches": nq, "degree": N - 1, "exactFaces": n_region, "refinedEdges": refined, "exactDemoted": demoted, "exactFallbacks": n_fallback, "reasons": why[:5],
|
|
1218
|
+
"triangles": int((flab >= O).sum()), "exactTriangles": int((flab < O).sum()),
|
|
1219
|
+
"exactSurfaces": len({int(x) for x in np.unique(lab) if x < O}),
|
|
1220
|
+
"smoothRegions": len({int(x) for x in np.unique(lab) if x >= O}),
|
|
1221
|
+
"seconds": {"coarsen": round(t1 - t0, 1), "patches": round(t3 - t1, 1),
|
|
1222
|
+
"solid": round(time.time() - t3, 1)}}
|
|
1223
|
+
return solid, rep
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _forward(solid, tries=20):
|
|
1227
|
+
"""The solid rebuilt with every face carrying its own outward
|
|
1228
|
+
orientation: STEP keeps faces' orientations but not a reversed shell's
|
|
1229
|
+
or solid's, so an inside-out shell would read back inside out. Readers
|
|
1230
|
+
then check the orientation with OpenCascade's infinite-point test,
|
|
1231
|
+
which shoots from the first face and can misjudge a patch; the faces go
|
|
1232
|
+
in an order it gets right, or a reader would turn the part inside out."""
|
|
1233
|
+
from OCP.BRep import BRep_Builder
|
|
1234
|
+
from OCP.BRepClass3d import BRepClass3d_SolidClassifier
|
|
1235
|
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_OUT, TopAbs_SHELL
|
|
1236
|
+
from OCP.TopExp import TopExp_Explorer
|
|
1237
|
+
from OCP.TopoDS import TopoDS_Shell, TopoDS_Solid
|
|
1238
|
+
|
|
1239
|
+
from .step import _volume
|
|
1240
|
+
|
|
1241
|
+
shells = [] # faces of each shell, outside first
|
|
1242
|
+
es = TopExp_Explorer(solid, TopAbs_SHELL)
|
|
1243
|
+
while es.More():
|
|
1244
|
+
fs, ex = [], TopExp_Explorer(es.Current(), TopAbs_FACE)
|
|
1245
|
+
while ex.More():
|
|
1246
|
+
fs.append(ex.Current())
|
|
1247
|
+
ex.Next()
|
|
1248
|
+
shells.append(fs)
|
|
1249
|
+
es.Next()
|
|
1250
|
+
if not shells:
|
|
1251
|
+
return solid
|
|
1252
|
+
|
|
1253
|
+
def shell_of(fs):
|
|
1254
|
+
bb = BRep_Builder()
|
|
1255
|
+
shell = TopoDS_Shell()
|
|
1256
|
+
bb.MakeShell(shell)
|
|
1257
|
+
for f in fs:
|
|
1258
|
+
bb.Add(shell, f)
|
|
1259
|
+
shell.Closed(True)
|
|
1260
|
+
return shell
|
|
1261
|
+
|
|
1262
|
+
def make(fs, rest):
|
|
1263
|
+
bb = BRep_Builder()
|
|
1264
|
+
out = TopoDS_Solid()
|
|
1265
|
+
bb.MakeSolid(out)
|
|
1266
|
+
bb.Add(out, shell_of(fs))
|
|
1267
|
+
for r in rest:
|
|
1268
|
+
bb.Add(out, shell_of(r))
|
|
1269
|
+
return out
|
|
1270
|
+
|
|
1271
|
+
if len(shells) > 1: # the outside first
|
|
1272
|
+
vols = [_volume(make(fs, [])) for fs in shells]
|
|
1273
|
+
k = int(np.argmax(vols))
|
|
1274
|
+
shells = [shells[k]] + shells[:k] + shells[k + 1:]
|
|
1275
|
+
faces, rest = shells[0], shells[1:]
|
|
1276
|
+
out = make(faces, rest)
|
|
1277
|
+
if _volume(out) <= 0:
|
|
1278
|
+
return solid
|
|
1279
|
+
for k in range(min(tries, len(faces))):
|
|
1280
|
+
cand = make(faces[k:] + faces[:k], rest) if k else out
|
|
1281
|
+
cl = BRepClass3d_SolidClassifier(cand)
|
|
1282
|
+
cl.PerformInfinitePoint(1e-6)
|
|
1283
|
+
if cl.State() == TopAbs_OUT:
|
|
1284
|
+
return cand
|
|
1285
|
+
return out
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _pairs(V, F, lab, O, cos_min=0.5, angle_max=160.0):
|
|
1289
|
+
"""Organic triangles matched in pairs across an edge inside one region,
|
|
1290
|
+
best-shaped quads first: [(i, j)]. A pair must be convex, with corner
|
|
1291
|
+
angles under `angle_max` degrees and the two normals within ~60 degrees."""
|
|
1292
|
+
E, _, f0, f1 = _edge_faces(F)
|
|
1293
|
+
ok = (lab[f0] == lab[f1]) & (lab[f0] >= O)
|
|
1294
|
+
E, f0, f1 = E[ok], f0[ok], f1[ok]
|
|
1295
|
+
n = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]])
|
|
1296
|
+
n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
|
|
1297
|
+
a, b = E[:, 0], E[:, 1]
|
|
1298
|
+
c = F[f0].sum(axis=1) - a - b
|
|
1299
|
+
d = F[f1].sum(axis=1) - a - b
|
|
1300
|
+
Q = np.stack([V[a], V[c], V[b], V[d]], axis=1) # around the quad
|
|
1301
|
+
p = np.roll(Q, 1, axis=1) - Q
|
|
1302
|
+
q = np.roll(Q, -1, axis=1) - Q
|
|
1303
|
+
cosang = np.einsum("ikj,ikj->ik", p, q) / np.maximum(np.linalg.norm(p, axis=2) * np.linalg.norm(q, axis=2), 1e-300)
|
|
1304
|
+
ang = np.degrees(np.arccos(np.clip(cosang, -1, 1)))
|
|
1305
|
+
nn = n[f0] + n[f1]
|
|
1306
|
+
turn = np.einsum("ikj,ij->ik", np.cross(q, p), nn) # convex: every corner turns the same way
|
|
1307
|
+
good = ((np.einsum("ij,ij->i", n[f0], n[f1]) > cos_min) & (ang.max(axis=1) < angle_max)
|
|
1308
|
+
& ((turn > 0).all(axis=1) | (turn < 0).all(axis=1)))
|
|
1309
|
+
score = np.abs(ang - 90.0).max(axis=1)
|
|
1310
|
+
used = np.zeros(len(F), bool)
|
|
1311
|
+
out = []
|
|
1312
|
+
for k in np.flatnonzero(good)[np.argsort(score[good], kind="stable")]:
|
|
1313
|
+
i, j = int(f0[k]), int(f1[k])
|
|
1314
|
+
if not used[i] and not used[j]:
|
|
1315
|
+
used[i] = used[j] = True
|
|
1316
|
+
out.append((i, j))
|
|
1317
|
+
return out
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _uv(s, P, ref=None):
|
|
1321
|
+
"""(u, v) of points on exact surface `s` in OpenCascade's parameters for
|
|
1322
|
+
s.geom(): planes and cylinders (angle unwrapped around `ref`)."""
|
|
1323
|
+
if s.kind == "plane":
|
|
1324
|
+
X, Y = _frame(s.n)
|
|
1325
|
+
d = P - s.p
|
|
1326
|
+
return np.stack([d @ X, d @ Y], axis=1)
|
|
1327
|
+
X, Y = _frame(s.axis)
|
|
1328
|
+
d = P - s.p0
|
|
1329
|
+
u = np.arctan2(d @ Y, d @ X)
|
|
1330
|
+
if ref is not None:
|
|
1331
|
+
u = ref + (u - ref + np.pi) % (2 * np.pi) - np.pi
|
|
1332
|
+
return np.stack([u, d @ s.axis], axis=1)
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _frame(n):
|
|
1336
|
+
"""The X and Y directions OpenCascade gives gp_Ax3(origin, n)."""
|
|
1337
|
+
from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt
|
|
1338
|
+
|
|
1339
|
+
ax = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(*map(float, n)))
|
|
1340
|
+
x, y = ax.XDirection(), ax.YDirection()
|
|
1341
|
+
return np.array([x.X(), x.Y(), x.Z()]), np.array([y.X(), y.Y(), y.Z()])
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _exact_face(s, geom, loop, centre, tol=0.01):
|
|
1345
|
+
"""A face on exact surface `s` (its OpenCascade surface `geom`) bounded by
|
|
1346
|
+
`loop`, [(edge, reversed)], with each edge's curve on the surface given
|
|
1347
|
+
explicitly (sampled, mapped to (u, v), fitted), so a bore's face covers
|
|
1348
|
+
the arc it should and not the rest of the cylinder."""
|
|
1349
|
+
from OCP.BRep import BRep_Builder, BRep_Tool
|
|
1350
|
+
from OCP.BRepLib import BRepLib
|
|
1351
|
+
from OCP.Geom2dAPI import Geom2dAPI_PointsToBSpline
|
|
1352
|
+
from OCP.GeomAbs import GeomAbs_C2
|
|
1353
|
+
from OCP.gp import gp_Pnt2d
|
|
1354
|
+
from OCP.OCP.collections import Array1_double, Array1_gp_Pnt2d
|
|
1355
|
+
from OCP.TopAbs import TopAbs_REVERSED
|
|
1356
|
+
from OCP.TopoDS import TopoDS_Face, TopoDS_Wire
|
|
1357
|
+
|
|
1358
|
+
if s.kind == "sphere":
|
|
1359
|
+
raise RuntimeError("spherical exact faces are not built yet")
|
|
1360
|
+
ref = None
|
|
1361
|
+
if s.kind == "cylinder":
|
|
1362
|
+
ref = float(_uv(s, centre[None])[0, 0])
|
|
1363
|
+
bb = BRep_Builder()
|
|
1364
|
+
face = TopoDS_Face()
|
|
1365
|
+
bb.MakeFace(face, geom, 1e-7)
|
|
1366
|
+
wire = TopoDS_Wire()
|
|
1367
|
+
bb.MakeWire(wire)
|
|
1368
|
+
ring = []
|
|
1369
|
+
for e, rev in loop:
|
|
1370
|
+
c = BRep_Tool.Curve_s(e, 0.0, 1.0)
|
|
1371
|
+
t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
|
|
1372
|
+
P = np.array([[p.X(), p.Y(), p.Z()] for p in (c.Value(x) for x in t)])
|
|
1373
|
+
UV = _uv(s, P, ref)
|
|
1374
|
+
pts, par = Array1_gp_Pnt2d(1, len(t)), Array1_double(1, len(t))
|
|
1375
|
+
for j, (q, x) in enumerate(zip(UV, t)):
|
|
1376
|
+
pts.SetValue(j + 1, gp_Pnt2d(float(q[0]), float(q[1])))
|
|
1377
|
+
par.SetValue(j + 1, float(x))
|
|
1378
|
+
pc = Geom2dAPI_PointsToBSpline(pts, par, 1, 3, GeomAbs_C2, 1e-7).Curve()
|
|
1379
|
+
bb.UpdateEdge(e, pc, face, tol)
|
|
1380
|
+
bb.Add(wire, e.Oriented(TopAbs_REVERSED) if rev else e)
|
|
1381
|
+
ring.append(UV[::-1] if rev else UV)
|
|
1382
|
+
R = np.vstack(ring)
|
|
1383
|
+
area = 0.5 * np.sum(R[:, 0] * np.roll(R[:, 1], -1) - np.roll(R[:, 0], -1) * R[:, 1])
|
|
1384
|
+
if area < 0:
|
|
1385
|
+
wire.Reverse()
|
|
1386
|
+
bb.Add(face, wire)
|
|
1387
|
+
BRepLib.SameParameter_s(face, tol)
|
|
1388
|
+
from OCP.ShapeFix import ShapeFix_Face
|
|
1389
|
+
|
|
1390
|
+
fx = ShapeFix_Face(face) # the wire's own checks (uv gaps at the corners)
|
|
1391
|
+
fx.Perform()
|
|
1392
|
+
return fx.Face()
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _region_face(s, geom, loops, flip, tol=0.01):
|
|
1396
|
+
"""One face on exact surface `s` bounded by `loops` ([(edge, reversed)]
|
|
1397
|
+
each, counterclockwise about the part's outward normal; `flip` when that
|
|
1398
|
+
is against the surface's own normal). Each edge gets its curve on the
|
|
1399
|
+
surface, the angle around a cylinder unwrapped along the loop, so a ring
|
|
1400
|
+
round a bore closes in 3D but not in (u, v): ShapeFix adds the seam."""
|
|
1401
|
+
from OCP.BRep import BRep_Builder, BRep_Tool
|
|
1402
|
+
from OCP.BRepCheck import BRepCheck_Analyzer
|
|
1403
|
+
from OCP.BRepLib import BRepLib
|
|
1404
|
+
from OCP.Geom2dAPI import Geom2dAPI_PointsToBSpline
|
|
1405
|
+
from OCP.GeomAbs import GeomAbs_C2
|
|
1406
|
+
from OCP.gp import gp_Pnt2d
|
|
1407
|
+
from OCP.OCP.collections import Array1_double, Array1_gp_Pnt2d
|
|
1408
|
+
from OCP.ShapeFix import ShapeFix_Face
|
|
1409
|
+
from OCP.TopAbs import TopAbs_REVERSED
|
|
1410
|
+
from OCP.TopoDS import TopoDS_Face, TopoDS_Wire
|
|
1411
|
+
|
|
1412
|
+
if s.kind == "sphere":
|
|
1413
|
+
raise RuntimeError("spherical exact faces are not built yet")
|
|
1414
|
+
bb = BRep_Builder()
|
|
1415
|
+
face = TopoDS_Face()
|
|
1416
|
+
bb.MakeFace(face, geom, 1e-7)
|
|
1417
|
+
for loop in loops:
|
|
1418
|
+
if flip:
|
|
1419
|
+
loop = [(e, not r) for e, r in reversed(loop)]
|
|
1420
|
+
parts = []
|
|
1421
|
+
for e, rev in loop:
|
|
1422
|
+
c = BRep_Tool.Curve_s(e, 0.0, 1.0)
|
|
1423
|
+
t = np.linspace(c.FirstParameter(), c.LastParameter(), 12)
|
|
1424
|
+
P = np.array([[p.X(), p.Y(), p.Z()] for p in (c.Value(x) for x in t)])
|
|
1425
|
+
parts.append((t, P[::-1] if rev else P))
|
|
1426
|
+
UV = _uv(s, np.vstack([P for _, P in parts]))
|
|
1427
|
+
if s.kind == "cylinder":
|
|
1428
|
+
UV[:, 0] = np.unwrap(UV[:, 0])
|
|
1429
|
+
wire = TopoDS_Wire()
|
|
1430
|
+
bb.MakeWire(wire)
|
|
1431
|
+
for k, ((e, rev), (t, _)) in enumerate(zip(loop, parts)):
|
|
1432
|
+
uv = UV[12 * k:12 * (k + 1)]
|
|
1433
|
+
if rev:
|
|
1434
|
+
uv = uv[::-1]
|
|
1435
|
+
pts, par = Array1_gp_Pnt2d(1, len(t)), Array1_double(1, len(t))
|
|
1436
|
+
for j, (q, x) in enumerate(zip(uv, t)):
|
|
1437
|
+
pts.SetValue(j + 1, gp_Pnt2d(float(q[0]), float(q[1])))
|
|
1438
|
+
par.SetValue(j + 1, float(x))
|
|
1439
|
+
pc = Geom2dAPI_PointsToBSpline(pts, par, 1, 3, GeomAbs_C2, 1e-7).Curve()
|
|
1440
|
+
bb.UpdateEdge(e, pc, face, tol)
|
|
1441
|
+
bb.Add(wire, e.Oriented(TopAbs_REVERSED) if rev else e)
|
|
1442
|
+
bb.Add(face, wire)
|
|
1443
|
+
BRepLib.SameParameter_s(face, tol)
|
|
1444
|
+
fx = ShapeFix_Face(face)
|
|
1445
|
+
fx.Perform()
|
|
1446
|
+
out = fx.Face()
|
|
1447
|
+
if not BRepCheck_Analyzer(out).IsValid():
|
|
1448
|
+
raise RuntimeError("exact region face is not valid")
|
|
1449
|
+
return out
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _bernstein(d):
|
|
1453
|
+
"""B[i, j] = j-th Bernstein polynomial of degree d at i / d."""
|
|
1454
|
+
from math import comb
|
|
1455
|
+
|
|
1456
|
+
t = np.linspace(0, 1, d + 1)
|
|
1457
|
+
return np.array([[comb(d, j) * x ** j * (1 - x) ** (d - j) for j in range(d + 1)] for x in t])
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _onto(s, P):
|
|
1461
|
+
"""Points pulled onto exact surface `s`."""
|
|
1462
|
+
if s.kind == "plane":
|
|
1463
|
+
return P - ((P - s.p) @ s.n)[:, None] * s.n
|
|
1464
|
+
if s.kind == "cylinder":
|
|
1465
|
+
d = P - s.p0
|
|
1466
|
+
t = d @ s.axis
|
|
1467
|
+
r = d - t[:, None] * s.axis
|
|
1468
|
+
return s.p0 + t[:, None] * s.axis + r / np.maximum(np.linalg.norm(r, axis=1, keepdims=True), 1e-12) * s.r
|
|
1469
|
+
d = P - s.c
|
|
1470
|
+
return s.c + d / np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-12) * s.r
|