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/crease.py
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sharp creases between field surfaces, without an exact B-rep.
|
|
3
|
+
|
|
4
|
+
Marching cubes cannot hold a crease: wherever a hard boolean joins two
|
|
5
|
+
surfaces at an angle, the triangles that straddle the join cut the corner
|
|
6
|
+
off, and the part comes out rounded over about one grid step. sharp.py
|
|
7
|
+
fixes that where one side of the boolean is a plain shape or a mesh, by
|
|
8
|
+
doing the boolean exactly; between two organic fields (a TPMS clipped by
|
|
9
|
+
a shell, two smooth bodies unioned without a blend, a body minus another)
|
|
10
|
+
there is no exact surface to hand to a mesh boolean.
|
|
11
|
+
|
|
12
|
+
But the graph still knows how the field was built. A hard union,
|
|
13
|
+
intersect or subtract is a min or a max of its inputs, so at every point
|
|
14
|
+
one input (a *leaf*) is the active one, and the crease is where the active
|
|
15
|
+
leaf changes: the curve where two leaf surfaces meet (or the corner where
|
|
16
|
+
three do). `CreaseTree.from_graph` reads that min/max tree off the graph;
|
|
17
|
+
`sharpen(V, F, tree, pitch)` then works on the finished, projected
|
|
18
|
+
marching-cubes mesh:
|
|
19
|
+
|
|
20
|
+
1. every vertex is labelled with its active leaf; an edge whose ends have
|
|
21
|
+
different labels crosses a crease;
|
|
22
|
+
2. a vertex very close to the crease (the crease point found from its
|
|
23
|
+
edge is within `snap` of the edge length of it) is moved onto the
|
|
24
|
+
crease and takes both labels, so the split below leaves no slivers;
|
|
25
|
+
3. every edge that still crosses a crease is split at the crease point,
|
|
26
|
+
found by Gauss-Newton on the two leaves' zero levels (two constraints:
|
|
27
|
+
the intersection curve; three where three labels meet in a triangle:
|
|
28
|
+
the corner), and every triangle is re-cut by how many of its edges
|
|
29
|
+
were split (two, three or six triangles);
|
|
30
|
+
4. near the creases, every triangle thinner than 8 degrees or turned over
|
|
31
|
+
against its own surface's true normal is collapsed away (shortest edge,
|
|
32
|
+
the crease end kept, link condition checked, nothing may turn over);
|
|
33
|
+
5. a crease vertex whose triangles still cross another part of the mesh
|
|
34
|
+
or face the wrong way goes back to where the plain mesh had it: the
|
|
35
|
+
crease is kept only where it does no harm.
|
|
36
|
+
|
|
37
|
+
Edges are left alone where there is no crease to see or the tree does not
|
|
38
|
+
describe the mesh: two leaves meeting at under `tangent` degrees (a skin's
|
|
39
|
+
outside lying on the body it was shelled from), within a few rings of a hole
|
|
40
|
+
the mesher left (a thin wall; the boolean step repairs it), and where
|
|
41
|
+
`tree.ignore` says so (sharp.py: near an exact cut, where the mesh is the
|
|
42
|
+
extended field). Creases are on by default (mesh.creases).
|
|
43
|
+
|
|
44
|
+
Where lattice walls meet a body at under ~45 degrees the material (or the
|
|
45
|
+
gap) between them is thinner than one pitch for a pitch or more back from
|
|
46
|
+
the true crease: that knife edge is not in the marching-cubes mesh at all,
|
|
47
|
+
and no relax or snapping of its vertices brings it back (moonshots round 2,
|
|
48
|
+
crease-aware relax tried 2026-10-31: same-side smoothing, own-leaf
|
|
49
|
+
projection, a flip guard; none beat the plain relax before this pass).
|
|
50
|
+
|
|
51
|
+
The split is decided per edge, so both triangles of an edge agree and the
|
|
52
|
+
mesh stays watertight; no vertex off the crease moves (other than the few
|
|
53
|
+
collapsed into it). A crease point that
|
|
54
|
+
Gauss-Newton does not find near the edge (the leaves are tangent there, or
|
|
55
|
+
a third leaf owns the spot) falls back to the edge midpoint projected onto
|
|
56
|
+
the field, which is what the mesh had. The crease vertices land on the
|
|
57
|
+
true intersection to the solver tolerance, and the triangles on each side
|
|
58
|
+
lie on their own surface, so the dihedral angle comes out as designed.
|
|
59
|
+
"""
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import numpy as np
|
|
63
|
+
|
|
64
|
+
__all__ = ["CreaseTree", "sharpen"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Leaf:
|
|
68
|
+
__slots__ = ("fn", "sign", "name", "index", "spec")
|
|
69
|
+
|
|
70
|
+
def __init__(self, fn, sign, name, spec=None):
|
|
71
|
+
self.fn, self.sign, self.name, self.index = fn, sign, name, -1
|
|
72
|
+
self.spec = spec # (node, plus, plus sign) for a graph's leaf: fieldjit compiles it
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Node:
|
|
76
|
+
__slots__ = ("kind", "kids")
|
|
77
|
+
|
|
78
|
+
def __init__(self, kind, kids):
|
|
79
|
+
self.kind, self.kids = kind, kids # kind: "min" | "max"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _negate(t):
|
|
83
|
+
if isinstance(t, _Leaf):
|
|
84
|
+
return _Leaf(t.fn, -t.sign, t.name, t.spec)
|
|
85
|
+
return _Node("max" if t.kind == "min" else "min", [_negate(k) for k in t.kids])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CreaseTree:
|
|
89
|
+
"""The hard min/max tree above a field: leaves are smooth fields, each
|
|
90
|
+
with a sign (a subtracted input enters negated). `values(P)` gives
|
|
91
|
+
every leaf's signed value, `evaluate(P)` the field and the active leaf."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, root, evaluator=None):
|
|
94
|
+
self.root = root
|
|
95
|
+
self.leaves = []
|
|
96
|
+
seen = {}
|
|
97
|
+
|
|
98
|
+
def walk(t):
|
|
99
|
+
if isinstance(t, _Leaf):
|
|
100
|
+
# the same field met twice (a body that is both shelled and
|
|
101
|
+
# clipped against) is one surface: one leaf
|
|
102
|
+
key = (t.name, t.sign)
|
|
103
|
+
if key in seen:
|
|
104
|
+
t.index = seen[key]
|
|
105
|
+
return
|
|
106
|
+
t.index = seen[key] = len(self.leaves)
|
|
107
|
+
self.leaves.append(t)
|
|
108
|
+
else:
|
|
109
|
+
for k in t.kids:
|
|
110
|
+
walk(k)
|
|
111
|
+
walk(root)
|
|
112
|
+
self._evaluator = evaluator
|
|
113
|
+
# optional P -> bool: points where the mesh is not this tree's field
|
|
114
|
+
# (sharp.py's extended field near an exact cut): no crease there
|
|
115
|
+
self.ignore = None
|
|
116
|
+
|
|
117
|
+
def __len__(self):
|
|
118
|
+
return len(self.leaves)
|
|
119
|
+
|
|
120
|
+
# -- construction --
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_graph(cls, g, name=None, shells=True):
|
|
124
|
+
"""The tree of graph node `name` (default the output), or None when
|
|
125
|
+
it has no hard boolean between fields at the top (nothing to sharpen).
|
|
126
|
+
Descends through union, intersect and subtract with no blend and,
|
|
127
|
+
with `shells`, through shells (inside: max(f, -(f + t)), outside:
|
|
128
|
+
max(-f, f - t)), whose rims are creases too."""
|
|
129
|
+
from .narrowband import peval # noqa: F401 (import check: fields are numpy callables)
|
|
130
|
+
|
|
131
|
+
def is_hard(nd):
|
|
132
|
+
b = nd.refs.get("blend")
|
|
133
|
+
return b is None or (isinstance(b, float) and abs(b) <= 1e-12)
|
|
134
|
+
|
|
135
|
+
def leaf(ref, sign=1.0, plus=None):
|
|
136
|
+
# a leaf: node `ref` (+ an optional thickness node or number)
|
|
137
|
+
def fn(P, memo):
|
|
138
|
+
v = g._val(ref, P, memo)
|
|
139
|
+
if plus is not None:
|
|
140
|
+
v = v + g._val(plus[0], P, memo) * plus[1]
|
|
141
|
+
return np.broadcast_to(np.asarray(v, float), (len(P),))
|
|
142
|
+
t = None if plus is None else plus[0] if isinstance(plus[0], str) else f"{plus[0]:g}"
|
|
143
|
+
spec = (ref, None, 0.0) if plus is None else (ref, plus[0], float(plus[1]))
|
|
144
|
+
return _Leaf(fn, sign, ref if plus is None else f"{ref}{'+' if plus[1] > 0 else '-'}{t}", spec)
|
|
145
|
+
|
|
146
|
+
def build(ref):
|
|
147
|
+
if isinstance(ref, float):
|
|
148
|
+
return None
|
|
149
|
+
nd = g.nodes[ref]
|
|
150
|
+
if nd.op in ("union", "intersect", "subtract") and is_hard(nd):
|
|
151
|
+
kids = [build(x) or leaf(x) for x in nd.refs["inputs"] if not isinstance(x, float)]
|
|
152
|
+
if len(kids) != len(nd.refs["inputs"]):
|
|
153
|
+
return None
|
|
154
|
+
if nd.op == "union":
|
|
155
|
+
return _Node("min", kids)
|
|
156
|
+
if nd.op == "intersect":
|
|
157
|
+
return _Node("max", kids)
|
|
158
|
+
return _Node("max", [kids[0]] + [_negate(k) for k in kids[1:]])
|
|
159
|
+
if shells and nd.op == "shell" and not isinstance(nd.refs.get("input"), float):
|
|
160
|
+
f, t = nd.refs["input"], nd.refs.get("thickness", 0.0)
|
|
161
|
+
side = nd.spec.get("side", "inside")
|
|
162
|
+
inner = build(f) or leaf(f)
|
|
163
|
+
if side == "inside": # max(f, -(f + t))
|
|
164
|
+
return _Node("max", [inner, leaf(f, -1.0, (t, 1.0))])
|
|
165
|
+
if side == "outside": # max(-f, f - t)
|
|
166
|
+
return _Node("max", [_negate(inner), leaf(f, 1.0, (t, -1.0))])
|
|
167
|
+
return None
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
root = build(name or g.output)
|
|
171
|
+
if root is None:
|
|
172
|
+
return None
|
|
173
|
+
tree = cls(root)
|
|
174
|
+
|
|
175
|
+
def ev(P):
|
|
176
|
+
rows = tree._compiled(g, None)
|
|
177
|
+
if rows is not None:
|
|
178
|
+
return rows(P)
|
|
179
|
+
memo = {}
|
|
180
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
181
|
+
return np.stack([lf.sign * lf.fn(P, memo) for lf in tree.leaves])
|
|
182
|
+
tree._evaluator = ev
|
|
183
|
+
tree._graph = g
|
|
184
|
+
return tree
|
|
185
|
+
|
|
186
|
+
def _compiled(self, g, only):
|
|
187
|
+
"""fieldjit.CompiledRows for the leaves `only` (all with None): every
|
|
188
|
+
leaf in one compiled pass, the same values. None when fieldjit is off."""
|
|
189
|
+
key = None if only is None else tuple(only)
|
|
190
|
+
cache = self.__dict__.setdefault("_rows", {})
|
|
191
|
+
if key not in cache:
|
|
192
|
+
from . import fieldjit
|
|
193
|
+
|
|
194
|
+
leaves = self.leaves if only is None else [self.leaves[j] for j in only]
|
|
195
|
+
cache[key] = fieldjit.compile_rows(g, [(lf.spec[0], lf.spec[1], lf.spec[2], lf.sign)
|
|
196
|
+
for lf in leaves])
|
|
197
|
+
return cache[key]
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_callables(cls, spec):
|
|
201
|
+
"""A tree from nested tuples: ("min"|"max", [kids]) or a callable
|
|
202
|
+
P -> values (a leaf), ("neg", kid) to negate. For experiments."""
|
|
203
|
+
def build(s):
|
|
204
|
+
if callable(s):
|
|
205
|
+
return _Leaf(lambda P, memo, s=s: np.asarray(s(P), float), 1.0, f"leaf{id(s)}")
|
|
206
|
+
if s[0] == "neg":
|
|
207
|
+
return _negate(build(s[1]))
|
|
208
|
+
return _Node(s[0], [build(k) for k in s[1]])
|
|
209
|
+
tree = cls(build(spec))
|
|
210
|
+
|
|
211
|
+
def ev(P):
|
|
212
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
213
|
+
return np.stack([lf.sign * lf.fn(P, {}) for lf in tree.leaves])
|
|
214
|
+
tree._evaluator = ev
|
|
215
|
+
return tree
|
|
216
|
+
|
|
217
|
+
# -- evaluation --
|
|
218
|
+
|
|
219
|
+
def values(self, P, only=None):
|
|
220
|
+
"""(n_leaves, n) signed leaf values, in chunks over threads (as
|
|
221
|
+
narrowband.peval); with `only` (leaf
|
|
222
|
+
indices), just those rows (the rest are left zero): a lattice leaf
|
|
223
|
+
costs many times a body's, so a stencil about one leaf's surface
|
|
224
|
+
need not pay for the others."""
|
|
225
|
+
from .narrowband import pblock
|
|
226
|
+
|
|
227
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
228
|
+
L = len(self.leaves)
|
|
229
|
+
if only is not None:
|
|
230
|
+
only = [int(i) for i in np.unique(only)]
|
|
231
|
+
if len(only) == L:
|
|
232
|
+
only = None
|
|
233
|
+
g = getattr(self, "_graph", None)
|
|
234
|
+
if only is None:
|
|
235
|
+
# compiled leaves thread well in small chunks (narrowband.peval)
|
|
236
|
+
ck = 32_768 if g is not None and self._compiled(g, None) is not None else 250_000
|
|
237
|
+
return pblock(self._evaluator, P, L, chunk=ck)
|
|
238
|
+
|
|
239
|
+
rows = self._compiled(g, only) if g is not None else None
|
|
240
|
+
if rows is not None:
|
|
241
|
+
def some(Q):
|
|
242
|
+
out = np.zeros((L, len(Q)))
|
|
243
|
+
out[only] = rows(Q)
|
|
244
|
+
return out
|
|
245
|
+
return pblock(some, P, L, chunk=32_768)
|
|
246
|
+
|
|
247
|
+
def some(Q):
|
|
248
|
+
out = np.zeros((L, len(Q)))
|
|
249
|
+
memo = {}
|
|
250
|
+
for j in only:
|
|
251
|
+
lf = self.leaves[j]
|
|
252
|
+
out[j] = lf.sign * lf.fn(Q, memo)
|
|
253
|
+
return out
|
|
254
|
+
return pblock(some, P, L)
|
|
255
|
+
|
|
256
|
+
def combine(self, vals):
|
|
257
|
+
"""(field value, active leaf index) from leaf values."""
|
|
258
|
+
def go(t):
|
|
259
|
+
if isinstance(t, _Leaf):
|
|
260
|
+
return vals[t.index], np.full(vals.shape[1], t.index)
|
|
261
|
+
parts = [go(k) for k in t.kids]
|
|
262
|
+
v = np.stack([p[0] for p in parts])
|
|
263
|
+
lab = np.stack([p[1] for p in parts])
|
|
264
|
+
j = np.argmin(v, axis=0) if t.kind == "min" else np.argmax(v, axis=0)
|
|
265
|
+
idx = np.arange(v.shape[1])
|
|
266
|
+
return v[j, idx], lab[j, idx]
|
|
267
|
+
return go(self.root)
|
|
268
|
+
|
|
269
|
+
def evaluate(self, P):
|
|
270
|
+
return self.combine(self.values(P))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---- Gauss-Newton onto the meeting of several leaf surfaces ------------------------
|
|
274
|
+
|
|
275
|
+
def _leaf_grad(tree, P, which, h, center=True):
|
|
276
|
+
"""Values and central-difference gradients of the leaves `which`
|
|
277
|
+
(n, k) at P (n, 3): (n, k) and (n, k, 3); without `center` the values
|
|
278
|
+
are None (four evaluations a point, not five)."""
|
|
279
|
+
# a tetrahedral stencil (as fields._gradient): second-order like central
|
|
280
|
+
# differences, five evaluations a point instead of seven
|
|
281
|
+
n = len(P)
|
|
282
|
+
tet = np.array([[1, -1, -1], [-1, -1, 1], [-1, 1, -1], [1, 1, 1]], float) * h
|
|
283
|
+
c = 1 if center else 0
|
|
284
|
+
vals = tree.values(np.concatenate(([P] if center else []) + [P + t for t in tet]), only=which)
|
|
285
|
+
rows = np.arange(n)[:, None]
|
|
286
|
+
v0 = vals[:, :n].T[rows, which] if center else None # (n, k)
|
|
287
|
+
g = np.zeros(which.shape + (3,))
|
|
288
|
+
for i in range(4):
|
|
289
|
+
vi = vals[:, (c + i) * n:(c + 1 + i) * n].T[rows, which]
|
|
290
|
+
g += vi[:, :, None] * tet[i]
|
|
291
|
+
return v0, g / (4 * h * h)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def meet(tree, X, which, pitch, iters=12, tol=1e-7):
|
|
295
|
+
"""Gauss-Newton from X (n, 3) onto {leaf_i = 0 for i in which[n]}
|
|
296
|
+
(minimum-norm steps, so the nearest point of the curve or corner).
|
|
297
|
+
Returns (points, converged)."""
|
|
298
|
+
X = np.array(X, float)
|
|
299
|
+
which = np.asarray(which)
|
|
300
|
+
h = 1e-4 * pitch
|
|
301
|
+
ok = np.zeros(len(X), bool)
|
|
302
|
+
todo = np.arange(len(X))
|
|
303
|
+
for _ in range(iters):
|
|
304
|
+
if not len(todo):
|
|
305
|
+
break
|
|
306
|
+
v, g = _leaf_grad(tree, X[todo], which[todo], h)
|
|
307
|
+
done = np.abs(v).max(axis=1) < tol * pitch
|
|
308
|
+
ok[todo[done]] = True
|
|
309
|
+
todo, v, g = todo[~done], v[~done], g[~done]
|
|
310
|
+
if not len(todo):
|
|
311
|
+
break
|
|
312
|
+
JJ = np.einsum("nka,nja->nkj", g, g)
|
|
313
|
+
k = which.shape[1]
|
|
314
|
+
JJ += 1e-12 * np.eye(k)[None]
|
|
315
|
+
try:
|
|
316
|
+
lam = np.linalg.solve(JJ, v[:, :, None])[:, :, 0]
|
|
317
|
+
except np.linalg.LinAlgError:
|
|
318
|
+
lam = np.einsum("nkj,nj->nk", np.linalg.pinv(JJ), v)
|
|
319
|
+
step = np.einsum("nk,nka->na", lam, g)
|
|
320
|
+
ln = np.linalg.norm(step, axis=1)
|
|
321
|
+
step *= np.minimum(1.0, pitch / np.maximum(ln, 1e-300))[:, None]
|
|
322
|
+
X[todo] -= step
|
|
323
|
+
if len(todo):
|
|
324
|
+
v, _ = _leaf_grad(tree, X[todo], which[todo], h)
|
|
325
|
+
ok[todo] = np.abs(v).max(axis=1) < tol * pitch
|
|
326
|
+
return X, ok
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _on_part(tree, X, pitch, tol=1e-5):
|
|
330
|
+
f, _ = tree.evaluate(X)
|
|
331
|
+
return np.abs(f) < tol * pitch
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---- the mesh operation ------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def _edges(F):
|
|
337
|
+
"""Unique edges (m, 2) sorted, and face -> edge index (n, 3): edge j of
|
|
338
|
+
a face runs from corner j to corner j + 1."""
|
|
339
|
+
e = np.stack([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]], axis=1).reshape(-1, 2)
|
|
340
|
+
s = np.sort(e, axis=1)
|
|
341
|
+
key = s[:, 0].astype(np.int64) * (int(F.max()) + 1) + s[:, 1]
|
|
342
|
+
uk, inv = np.unique(key, return_inverse=True)
|
|
343
|
+
E = np.stack([uk // (int(F.max()) + 1), uk % (int(F.max()) + 1)], axis=1)
|
|
344
|
+
return E, inv.reshape(-1, 3)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def sharpen(V, F, tree, pitch, snap=0.25, reach=4.0, field=None, stats=None, lab=None, mask=None, clean=True,
|
|
348
|
+
tangent=12.0):
|
|
349
|
+
"""Put the creases of `tree` back into the projected marching-cubes
|
|
350
|
+
mesh (V, F). Returns (V, F, crease) with `crease` a bool mask of the
|
|
351
|
+
vertices on a crease (snapped or inserted). `snap` (fraction of an
|
|
352
|
+
edge) moves a vertex onto the crease instead of splitting next to it;
|
|
353
|
+
`reach` (pitches) is how far a crease point may be from its edge.
|
|
354
|
+
`field` (default the tree's own min/max) projects fallback points.
|
|
355
|
+
Two leaves meeting at under `tangent` degrees are no crease."""
|
|
356
|
+
V = np.asarray(V, float)
|
|
357
|
+
F = np.asarray(F, np.int64)
|
|
358
|
+
if field is None:
|
|
359
|
+
def field(P):
|
|
360
|
+
return tree.evaluate(P)[0]
|
|
361
|
+
st = stats if stats is not None else {}
|
|
362
|
+
import time
|
|
363
|
+
|
|
364
|
+
secs = st.setdefault("seconds", {})
|
|
365
|
+
clock = [time.time()]
|
|
366
|
+
|
|
367
|
+
def tick(name):
|
|
368
|
+
t = time.time()
|
|
369
|
+
secs[name] = round(secs.get(name, 0.0) + t - clock[0], 2)
|
|
370
|
+
clock[0] = t
|
|
371
|
+
nl = len(tree)
|
|
372
|
+
if nl > 62:
|
|
373
|
+
return V, F, np.zeros(len(V), bool)
|
|
374
|
+
if lab is None:
|
|
375
|
+
_, lab = tree.evaluate(V)
|
|
376
|
+
if mask is None:
|
|
377
|
+
mask = (np.int64(1) << lab.astype(np.int64))
|
|
378
|
+
mask = np.array(mask, np.int64)
|
|
379
|
+
tick("labels")
|
|
380
|
+
# the edge table only round the faces with a crossing edge (ends with
|
|
381
|
+
# no leaf in common): elsewhere nothing is split. Five rings out, so
|
|
382
|
+
# a hole within three of a crossing edge (below) is seen whole: an edge
|
|
383
|
+
# counts as a hole only between vertices whose faces are all in the
|
|
384
|
+
# patch (else it is just the patch's border)
|
|
385
|
+
F = np.asarray(F, np.int64)
|
|
386
|
+
mixed = np.zeros(len(F), bool)
|
|
387
|
+
for j in range(3):
|
|
388
|
+
mixed |= (mask[F[:, j]] & mask[F[:, (j + 1) % 3]]) == 0
|
|
389
|
+
if not mixed.any():
|
|
390
|
+
st["crossingEdges"] = 0
|
|
391
|
+
return V, F, (mask & (mask - 1)) != 0
|
|
392
|
+
near5 = np.zeros(len(V), bool)
|
|
393
|
+
near5[F[mixed].ravel()] = True
|
|
394
|
+
for _r in range(4):
|
|
395
|
+
near5[F[near5[F].any(axis=1)].ravel()] = True
|
|
396
|
+
interior = near5.copy()
|
|
397
|
+
near5[F[near5[F].any(axis=1)].ravel()] = True
|
|
398
|
+
region = np.flatnonzero(near5[F].any(axis=1))
|
|
399
|
+
Fr = F[region]
|
|
400
|
+
E, fe = _edges(Fr)
|
|
401
|
+
tick("edges")
|
|
402
|
+
cross = (mask[E[:, 0]] & mask[E[:, 1]]) == 0
|
|
403
|
+
# edges that are no crease: an end the tree does not describe (tree.ignore),
|
|
404
|
+
# or two leaves that meet tangentially (a skin's outside lying on the body
|
|
405
|
+
# it was shelled from: no edge to see, and Gauss-Newton is ill-posed there)
|
|
406
|
+
skip = np.zeros(len(E), bool)
|
|
407
|
+
ign = np.zeros(len(V), bool)
|
|
408
|
+
if tree.ignore is not None and cross.any():
|
|
409
|
+
ends = np.unique(E[cross].ravel())
|
|
410
|
+
ign[ends] = np.asarray(tree.ignore(V[ends]), bool)
|
|
411
|
+
# and near a hole (a thin wall the mesher left open, which the boolean
|
|
412
|
+
# step repairs): leave the mesh round it as it was, so the repair holds
|
|
413
|
+
ecount = np.bincount(fe.ravel(), minlength=len(E))
|
|
414
|
+
hole = (ecount != 2) & interior[E[:, 0]] & interior[E[:, 1]]
|
|
415
|
+
if hole.any():
|
|
416
|
+
near = np.zeros(len(V), bool)
|
|
417
|
+
near[E[hole].ravel()] = True
|
|
418
|
+
for _r in range(3):
|
|
419
|
+
near[Fr[near[Fr].any(axis=1)].ravel()] = True
|
|
420
|
+
ign |= near
|
|
421
|
+
st["nearHoles"] = int(near.sum())
|
|
422
|
+
skip |= ign[E[:, 0]] | ign[E[:, 1]]
|
|
423
|
+
ci = np.flatnonzero(cross & ~skip)
|
|
424
|
+
if len(ci):
|
|
425
|
+
# a tie: each end lies on the other end's leaf as well (two surfaces
|
|
426
|
+
# that coincide along a band, where the gradients may still differ)
|
|
427
|
+
a, b = E[ci, 0], E[ci, 1]
|
|
428
|
+
ends, inv = np.unique(np.r_[a, b], return_inverse=True)
|
|
429
|
+
vals = tree.values(V[ends])
|
|
430
|
+
ia, ib = inv[:len(ci)], inv[len(ci):]
|
|
431
|
+
tie = (np.abs(vals[lab[b], ia]) < 0.02 * pitch) & (np.abs(vals[lab[a], ib]) < 0.02 * pitch)
|
|
432
|
+
skip[ci[tie]] = True
|
|
433
|
+
st["ties"] = int(tie.sum())
|
|
434
|
+
ci = ci[~tie]
|
|
435
|
+
if len(ci):
|
|
436
|
+
# the two leaves' normals at both ends and the midpoint: nearly
|
|
437
|
+
# parallel at any of them and the surfaces only graze (a smooth
|
|
438
|
+
# union's bulge clipped flat): no edge to see
|
|
439
|
+
a, b = E[ci, 0], E[ci, 1]
|
|
440
|
+
P = np.concatenate([V[a], V[b], 0.5 * (V[a] + V[b])])
|
|
441
|
+
w = np.tile(np.stack([lab[a], lab[b]], 1), (3, 1))
|
|
442
|
+
_, gr = _leaf_grad(tree, P, w, 1e-3 * pitch)
|
|
443
|
+
gr /= np.maximum(np.linalg.norm(gr, axis=2, keepdims=True), 1e-300)
|
|
444
|
+
c = (gr[:, 0] * gr[:, 1]).sum(1).reshape(3, len(ci)).max(axis=0)
|
|
445
|
+
skip[ci[c > np.cos(np.radians(tangent))]] = True
|
|
446
|
+
st["tangentOrIgnored"] = int((cross & skip).sum())
|
|
447
|
+
tick("skip")
|
|
448
|
+
cross &= ~skip
|
|
449
|
+
st["crossingEdges"] = int(cross.sum())
|
|
450
|
+
crease = (mask & (mask - 1)) != 0 # more than one label: on a crease already
|
|
451
|
+
if not cross.any():
|
|
452
|
+
return V, F, crease
|
|
453
|
+
V = V.copy()
|
|
454
|
+
home = V.copy() # where each vertex goes back to if its crease tangles
|
|
455
|
+
|
|
456
|
+
def crease_points(ei):
|
|
457
|
+
"""Crease point of edges ei (from their midpoints), and whether found."""
|
|
458
|
+
a, b = E[ei, 0], E[ei, 1]
|
|
459
|
+
mid = 0.5 * (V[a] + V[b])
|
|
460
|
+
which = np.stack([lab[a], lab[b]], axis=1)
|
|
461
|
+
X, ok = meet(tree, mid, which, pitch)
|
|
462
|
+
# near the edge, and on the part (no third leaf owns it)
|
|
463
|
+
ab = V[b] - V[a]
|
|
464
|
+
t = np.clip(((X - V[a]) * ab).sum(1) / np.maximum((ab * ab).sum(1), 1e-300), 0, 1)
|
|
465
|
+
dseg = np.linalg.norm(X - (V[a] + t[:, None] * ab), axis=1)
|
|
466
|
+
ok &= dseg < reach * pitch
|
|
467
|
+
ok[ok] &= _on_part(tree, X[ok], pitch)
|
|
468
|
+
return X, ok, t
|
|
469
|
+
|
|
470
|
+
# 1. snap vertices that sit almost on a crease
|
|
471
|
+
ci = np.flatnonzero(cross)
|
|
472
|
+
X, ok, t = crease_points(ci)
|
|
473
|
+
step1 = (ci, X.copy(), ok.copy())
|
|
474
|
+
moved_v = np.zeros(len(V), bool)
|
|
475
|
+
snapped = 0
|
|
476
|
+
if snap > 0 and ok.any():
|
|
477
|
+
a, b = E[ci, 0], E[ci, 1]
|
|
478
|
+
cand_v = np.r_[a[ok & (t < snap)], b[ok & (t > 1 - snap)]]
|
|
479
|
+
cand_p = np.r_[lab[b[ok & (t < snap)]], lab[a[ok & (t > 1 - snap)]]]
|
|
480
|
+
cand_t = np.r_[t[ok & (t < snap)], 1 - t[ok & (t > 1 - snap)]]
|
|
481
|
+
if len(cand_v):
|
|
482
|
+
# one partner label per vertex (else it is near a corner: leave it)
|
|
483
|
+
order = np.lexsort((cand_t, cand_v))
|
|
484
|
+
cand_v, cand_p, cand_t = cand_v[order], cand_p[order], cand_t[order]
|
|
485
|
+
first = np.r_[True, cand_v[1:] != cand_v[:-1]]
|
|
486
|
+
starts = np.flatnonzero(first)
|
|
487
|
+
pmin = np.minimum.reduceat(cand_p, starts)
|
|
488
|
+
pmax = np.maximum.reduceat(cand_p, starts)
|
|
489
|
+
uv = cand_v[starts]
|
|
490
|
+
# also every crossing edge of the vertex must have that partner
|
|
491
|
+
single = pmin == pmax
|
|
492
|
+
uv, partner, ut = uv[single], pmin[single], cand_t[starts][single]
|
|
493
|
+
ea, eb = E[ci, 0], E[ci, 1]
|
|
494
|
+
# every crossing edge of the vertex must have that partner (else
|
|
495
|
+
# it is near a corner: leave it to the split)
|
|
496
|
+
lo_p = np.full(len(V), nl, np.int64)
|
|
497
|
+
hi_p = np.full(len(V), -1, np.int64)
|
|
498
|
+
for x, y in ((ea, eb), (eb, ea)):
|
|
499
|
+
np.minimum.at(lo_p, x, lab[y])
|
|
500
|
+
np.maximum.at(hi_p, x, lab[y])
|
|
501
|
+
keep = (lo_p[uv] == hi_p[uv]) & (lo_p[uv] == partner)
|
|
502
|
+
uv, partner, ut = uv[keep], partner[keep], ut[keep]
|
|
503
|
+
if len(uv):
|
|
504
|
+
Xs, oks = meet(tree, V[uv], np.stack([lab[uv], partner], axis=1), pitch)
|
|
505
|
+
oks &= np.linalg.norm(Xs - V[uv], axis=1) < 0.75 * pitch
|
|
506
|
+
if oks.any():
|
|
507
|
+
oks[oks] &= _on_part(tree, Xs[oks], pitch)
|
|
508
|
+
uv, partner, Xs, ut = uv[oks], partner[oks], Xs[oks], ut[oks]
|
|
509
|
+
# no crossing edge may have both ends snapped: keep the nearer
|
|
510
|
+
take = np.zeros(len(V), bool)
|
|
511
|
+
take[uv] = True
|
|
512
|
+
score = np.full(len(V), np.inf)
|
|
513
|
+
score[uv] = ut
|
|
514
|
+
both = take[ea] & take[eb]
|
|
515
|
+
drop = np.where(score[ea[both]] > score[eb[both]], ea[both], eb[both])
|
|
516
|
+
take[drop] = False
|
|
517
|
+
# and after dropping, never two snapped ends again (greedy, repeat)
|
|
518
|
+
for _ in range(4):
|
|
519
|
+
both = take[ea] & take[eb]
|
|
520
|
+
if not both.any():
|
|
521
|
+
break
|
|
522
|
+
take[np.where(score[ea[both]] > score[eb[both]], ea[both], eb[both])] = False
|
|
523
|
+
sel = take[uv]
|
|
524
|
+
uv, partner, Xs = uv[sel], partner[sel], Xs[sel]
|
|
525
|
+
V[uv] = Xs
|
|
526
|
+
moved_v[uv] = True
|
|
527
|
+
mask[uv] |= np.int64(1) << partner.astype(np.int64)
|
|
528
|
+
crease[uv] = True
|
|
529
|
+
snapped = len(uv)
|
|
530
|
+
st["snapped"] = int(snapped)
|
|
531
|
+
|
|
532
|
+
tick("snap")
|
|
533
|
+
# 2. split the edges that still cross
|
|
534
|
+
cross = ((mask[E[:, 0]] & mask[E[:, 1]]) == 0) & ~skip
|
|
535
|
+
ci = np.flatnonzero(cross)
|
|
536
|
+
# an edge neither of whose ends was snapped has the crease point step 1
|
|
537
|
+
# found already (all of them, as snapping only removes crossings)
|
|
538
|
+
c0, X0, ok0 = step1
|
|
539
|
+
pos = np.searchsorted(c0, ci)
|
|
540
|
+
again = moved_v[E[ci, 0]] | moved_v[E[ci, 1]]
|
|
541
|
+
X, ok = X0[pos].copy(), ok0[pos].copy()
|
|
542
|
+
if again.any():
|
|
543
|
+
Xa, oka, _ = crease_points(ci[again])
|
|
544
|
+
X[again], ok[again] = Xa, oka
|
|
545
|
+
# fallback: the midpoint on the field (where the mesh had it)
|
|
546
|
+
from .fields import _project
|
|
547
|
+
|
|
548
|
+
mfall = 0.5 * (V[E[ci, 0]] + V[E[ci, 1]])
|
|
549
|
+
for _ in range(3):
|
|
550
|
+
mfall = _project(field, mfall, pitch)
|
|
551
|
+
X[~ok] = mfall[~ok]
|
|
552
|
+
# home: the plain midpoint of the edge as the mesh had it, on its
|
|
553
|
+
# faces (the projected one can poke through a thin neighbour)
|
|
554
|
+
home = np.concatenate([home, 0.5 * (home[E[ci, 0]] + home[E[ci, 1]])])
|
|
555
|
+
st["split"] = int(len(ci))
|
|
556
|
+
st["splitFallback"] = int((~ok).sum())
|
|
557
|
+
nv = len(V)
|
|
558
|
+
epos = np.full(len(E), -1, np.int64)
|
|
559
|
+
epos[ci] = nv + np.arange(len(ci))
|
|
560
|
+
V = np.concatenate([V, X])
|
|
561
|
+
newc = np.zeros(len(V), bool)
|
|
562
|
+
newc[:nv] = crease
|
|
563
|
+
newc[nv:] = ok
|
|
564
|
+
crease = newc
|
|
565
|
+
|
|
566
|
+
tick("split")
|
|
567
|
+
# 3. re-cut every face by its split edges
|
|
568
|
+
M = np.full(F.shape, -1, np.int64) # (nf, 3) new vertex on edge j, or -1
|
|
569
|
+
M[region] = epos[fe]
|
|
570
|
+
c = (M >= 0).sum(axis=1)
|
|
571
|
+
out = [F[c == 0]]
|
|
572
|
+
# one split edge: (a, m, c), (m, b, c) with a->b the split edge
|
|
573
|
+
idx = np.flatnonzero(c == 1)
|
|
574
|
+
if len(idx):
|
|
575
|
+
j = np.argmax(M[idx] >= 0, axis=1)
|
|
576
|
+
r = np.arange(len(idx))
|
|
577
|
+
a = F[idx, j]
|
|
578
|
+
b = F[idx, (j + 1) % 3]
|
|
579
|
+
cc = F[idx, (j + 2) % 3]
|
|
580
|
+
m = M[idx, j]
|
|
581
|
+
out += [np.stack([a, m, cc], 1), np.stack([m, b, cc], 1)]
|
|
582
|
+
del r
|
|
583
|
+
# two split edges: the lone corner and a quad
|
|
584
|
+
idx = np.flatnonzero(c == 2)
|
|
585
|
+
if len(idx):
|
|
586
|
+
j = np.argmin(M[idx] >= 0, axis=1) # the unsplit edge b->c
|
|
587
|
+
b = F[idx, j]
|
|
588
|
+
cc = F[idx, (j + 1) % 3]
|
|
589
|
+
a = F[idx, (j + 2) % 3]
|
|
590
|
+
m_ca = M[idx, (j + 1) % 3]
|
|
591
|
+
m_ab = M[idx, (j + 2) % 3]
|
|
592
|
+
out.append(np.stack([a, m_ab, m_ca], 1))
|
|
593
|
+
d1 = np.linalg.norm(V[m_ab] - V[cc], axis=1)
|
|
594
|
+
d2 = np.linalg.norm(V[b] - V[m_ca], axis=1)
|
|
595
|
+
s = d1 <= d2
|
|
596
|
+
out += [np.stack([m_ab, b, cc], 1)[s], np.stack([m_ab, cc, m_ca], 1)[s],
|
|
597
|
+
np.stack([m_ab, b, m_ca], 1)[~s], np.stack([b, cc, m_ca], 1)[~s]]
|
|
598
|
+
# three: a centre at the corner where the three leaves meet
|
|
599
|
+
idx = np.flatnonzero(c == 3)
|
|
600
|
+
st["corners"] = int(len(idx))
|
|
601
|
+
if len(idx):
|
|
602
|
+
tri = F[idx]
|
|
603
|
+
which = lab[tri]
|
|
604
|
+
cen0 = V[M[idx]].mean(axis=1)
|
|
605
|
+
distinct = (which[:, 0] != which[:, 1]) & (which[:, 1] != which[:, 2]) & (which[:, 0] != which[:, 2])
|
|
606
|
+
cen = cen0.copy()
|
|
607
|
+
good = np.zeros(len(idx), bool)
|
|
608
|
+
if distinct.any():
|
|
609
|
+
Xc, okc = meet(tree, cen0[distinct], which[distinct], pitch)
|
|
610
|
+
okc &= np.linalg.norm(Xc - cen0[distinct], axis=1) < reach * pitch
|
|
611
|
+
if okc.any():
|
|
612
|
+
okc[okc] &= _on_part(tree, Xc[okc], pitch)
|
|
613
|
+
sub = np.flatnonzero(distinct)
|
|
614
|
+
cen[sub[okc]] = Xc[okc]
|
|
615
|
+
good[sub[okc]] = True
|
|
616
|
+
if (~good).any():
|
|
617
|
+
from .fields import _project
|
|
618
|
+
|
|
619
|
+
q = cen[~good]
|
|
620
|
+
for _ in range(3):
|
|
621
|
+
q = _project(field, q, pitch)
|
|
622
|
+
cen[~good] = q
|
|
623
|
+
# home: the centre of the three edge midpoints' homes, inside the
|
|
624
|
+
# face the mesh had
|
|
625
|
+
home = np.concatenate([home, home[M[idx]].mean(axis=1)])
|
|
626
|
+
k = len(V) + np.arange(len(idx))
|
|
627
|
+
V = np.concatenate([V, cen])
|
|
628
|
+
crease = np.r_[crease, good]
|
|
629
|
+
v0, v1, v2 = tri[:, 0], tri[:, 1], tri[:, 2]
|
|
630
|
+
m01, m12, m20 = M[idx, 0], M[idx, 1], M[idx, 2]
|
|
631
|
+
out += [np.stack(t, 1) for t in ((v0, m01, k), (m01, v1, k), (v1, m12, k),
|
|
632
|
+
(m12, v2, k), (v2, m20, k), (m20, v0, k))]
|
|
633
|
+
F2 = np.concatenate(out).astype(np.int64)
|
|
634
|
+
tick("recut")
|
|
635
|
+
if clean:
|
|
636
|
+
# masks only within three rings of the creases (clean-up looks at
|
|
637
|
+
# faces within two): the whole mesh's took seconds on a lattice
|
|
638
|
+
ring = crease.copy()
|
|
639
|
+
for _r in range(3):
|
|
640
|
+
ring[F2[ring[F2].any(axis=1)].ravel()] = True
|
|
641
|
+
masks = np.zeros(len(V), np.int64)
|
|
642
|
+
masks[ring] = label_masks(tree, V[ring], pitch)
|
|
643
|
+
tick("masks")
|
|
644
|
+
V, F2, _, crease, used = cleanup(V, F2, tree, masks, crease, pitch, stats=st, home=home)
|
|
645
|
+
home = home[used]
|
|
646
|
+
tick("cleanup")
|
|
647
|
+
V, crease = untangle(V, F2, tree, crease, home, pitch, stats=st)
|
|
648
|
+
tick("untangle")
|
|
649
|
+
st["creaseVertices"] = int(crease.sum())
|
|
650
|
+
return V, F2, crease
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def label_masks(tree, V, pitch, tol=1e-5):
|
|
654
|
+
"""Bit mask per vertex of the leaves it lies on: its active leaf, and
|
|
655
|
+
every other leaf within tol * pitch of zero there (a crease vertex is on
|
|
656
|
+
two, a corner on three)."""
|
|
657
|
+
vals = tree.values(V)
|
|
658
|
+
_, lab = tree.combine(vals)
|
|
659
|
+
near = np.abs(vals) < tol * pitch
|
|
660
|
+
bits = (np.int64(1) << np.arange(len(tree), dtype=np.int64))[:, None]
|
|
661
|
+
return (np.int64(1) << lab.astype(np.int64)) | np.bitwise_or.reduce(np.where(near, bits, 0), axis=0)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _face_normals(V, F):
|
|
665
|
+
from .narrowband import face_cross
|
|
666
|
+
|
|
667
|
+
n = face_cross(V, F)
|
|
668
|
+
return n / np.maximum(np.sqrt(np.einsum("ij,ij->i", n, n))[:, None], 1e-300)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# ---- clean-up: collapse the slivers and turned-over triangles the split leaves ------
|
|
672
|
+
|
|
673
|
+
def _min_angle(T):
|
|
674
|
+
out = np.full(len(T), 180.0)
|
|
675
|
+
for i in range(3):
|
|
676
|
+
u = T[:, (i + 1) % 3] - T[:, i]
|
|
677
|
+
w = T[:, (i + 2) % 3] - T[:, i]
|
|
678
|
+
c = (u * w).sum(-1) / np.maximum(np.linalg.norm(u, axis=-1) * np.linalg.norm(w, axis=-1), 1e-300)
|
|
679
|
+
out = np.minimum(out, np.degrees(np.arccos(np.clip(c, -1, 1))))
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def side_normals(tree, V, F, mask):
|
|
684
|
+
"""The true outward normal each face should have: the gradient of the
|
|
685
|
+
leaf its three vertices share (None-like zero where they share none)."""
|
|
686
|
+
common = mask[F[:, 0]] & mask[F[:, 1]] & mask[F[:, 2]]
|
|
687
|
+
has = common != 0
|
|
688
|
+
leaf = np.zeros(len(F), np.int64)
|
|
689
|
+
c = common[has]
|
|
690
|
+
# lowest set bit
|
|
691
|
+
leaf[has] = np.log2(c & -c).astype(np.int64)
|
|
692
|
+
cen = V[F].mean(1)
|
|
693
|
+
out = np.zeros((len(F), 3))
|
|
694
|
+
if has.any():
|
|
695
|
+
_, g = _leaf_grad(tree, cen[has], leaf[has][:, None], 1e-5, center=False)
|
|
696
|
+
g = g[:, 0]
|
|
697
|
+
out[has] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-300)
|
|
698
|
+
return out, has
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _edge_order(el, ea, eb):
|
|
702
|
+
"""Per row, the order of a face's three edges by (length, a, b), as
|
|
703
|
+
sorting the tuples would."""
|
|
704
|
+
o = np.argsort(el, axis=1, kind="stable")
|
|
705
|
+
# ties in length are rare; settle them exactly where they occur
|
|
706
|
+
sl = np.take_along_axis(el, o, 1)
|
|
707
|
+
tie = np.flatnonzero((sl[:, 1:] == sl[:, :-1]).any(axis=1))
|
|
708
|
+
for r in tie:
|
|
709
|
+
o[r] = sorted(range(3), key=lambda k: (el[r, k], ea[r, k], eb[r, k]))
|
|
710
|
+
return o
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
_SIN_DEGENERATE = float(np.sin(np.radians(1e-3)))
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _turns_or_degenerates(V, old, new):
|
|
717
|
+
"""Whether moving faces `old` to `new` (rows of vertex ids) turns one
|
|
718
|
+
over (normals under 0.2 in cosine, as unit vectors) or leaves one
|
|
719
|
+
with an angle under 1e-3 degrees: one fused pass for the clean-up's
|
|
720
|
+
few faces at a time."""
|
|
721
|
+
a0, b0, c0 = V[old[:, 0]], V[old[:, 1]], V[old[:, 2]]
|
|
722
|
+
a1, b1, c1 = V[new[:, 0]], V[new[:, 1]], V[new[:, 2]]
|
|
723
|
+
n0 = np.cross(b0 - a0, c0 - a0)
|
|
724
|
+
e1, e2 = b1 - a1, c1 - a1
|
|
725
|
+
n1 = np.cross(e1, e2)
|
|
726
|
+
s0, s1 = (n0 * n0).sum(1), (n1 * n1).sum(1)
|
|
727
|
+
if ((n0 * n1).sum(1) < 0.2 * np.sqrt(s0 * s1)).any() or (s0 == 0).any() or (s1 == 0).any():
|
|
728
|
+
return True
|
|
729
|
+
e3 = e2 - e1
|
|
730
|
+
l2 = np.sort(np.stack([(e1 * e1).sum(1), (e2 * e2).sum(1), (e3 * e3).sum(1)], 1), axis=1)
|
|
731
|
+
# the smallest angle faces the shortest edge: sin = |n| / (two longer edges)
|
|
732
|
+
return bool((np.sqrt(s1 / (l2[:, 1] * l2[:, 2])) < _SIN_DEGENERATE).any())
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def cleanup(V, F, tree, mask, crease, pitch, min_angle=8.0, passes=6, stats=None, home=None):
|
|
736
|
+
"""Collapse slivers and turned-over triangles near the creases (see
|
|
737
|
+
_cleanup), on the patch of faces within four rings of a crease vertex:
|
|
738
|
+
the passes look two rings out, and a vertex on the patch's border (it
|
|
739
|
+
has faces outside) is never collapsed, so the rest of the mesh is
|
|
740
|
+
untouched and never visited. Returns (V, F, mask, crease, used)."""
|
|
741
|
+
F = np.asarray(F, np.int64)
|
|
742
|
+
crease = np.asarray(crease, bool)
|
|
743
|
+
near = crease.copy()
|
|
744
|
+
for _r in range(4):
|
|
745
|
+
near[F[near[F].any(axis=1)].ravel()] = True
|
|
746
|
+
sub = near[F].any(axis=1)
|
|
747
|
+
inside = np.zeros(len(V), bool)
|
|
748
|
+
inside[F[sub].ravel()] = True
|
|
749
|
+
frozen = np.zeros(len(V), bool)
|
|
750
|
+
frozen[F[~sub].ravel()] = True
|
|
751
|
+
frozen &= inside
|
|
752
|
+
check = None if home is None else (np.asarray(home, float),)
|
|
753
|
+
Fs = _cleanup(V, F[sub], tree, mask, crease, pitch, min_angle, passes, stats, frozen, check)
|
|
754
|
+
F = np.concatenate([F[~sub], Fs])
|
|
755
|
+
# drop the vertices nothing uses
|
|
756
|
+
used = np.zeros(len(V), bool)
|
|
757
|
+
used[F] = True
|
|
758
|
+
new = np.cumsum(used) - 1
|
|
759
|
+
return np.asarray(V, float)[used], new[F], np.asarray(mask)[used], crease[used], used
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _fold_undo(F, alive, log, check):
|
|
763
|
+
"""The collapses of `log` (see _cleanup) whose rewritten faces cross
|
|
764
|
+
another live face at any of the positions in `check`."""
|
|
765
|
+
owner = {}
|
|
766
|
+
for k, (_s, moved, _o, _a, _b) in enumerate(log):
|
|
767
|
+
for f in moved.tolist():
|
|
768
|
+
owner[f] = k
|
|
769
|
+
if not owner:
|
|
770
|
+
return []
|
|
771
|
+
live = np.flatnonzero(alive)
|
|
772
|
+
pos = np.full(len(F), -1, np.int64)
|
|
773
|
+
pos[live] = np.arange(len(live))
|
|
774
|
+
Fl = F[live]
|
|
775
|
+
mv = np.fromiter(owner.keys(), np.int64)
|
|
776
|
+
mv = mv[alive[mv]]
|
|
777
|
+
q = pos[mv]
|
|
778
|
+
bad = set()
|
|
779
|
+
for X in check:
|
|
780
|
+
x = crossing_faces(X, Fl, q)
|
|
781
|
+
hit = live[x]
|
|
782
|
+
bad |= {owner[f] for f in hit.tolist() if f in owner}
|
|
783
|
+
return sorted(bad)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _cleanup(V, F, tree, mask, crease, pitch, min_angle=8.0, passes=6, stats=None, frozen=None, check=None):
|
|
787
|
+
"""Collapse, near the creases, every triangle that is turned over against
|
|
788
|
+
its surface's true normal or thinner than `min_angle`: its shortest
|
|
789
|
+
collapsible edge goes, the crease end kept (two crease ends only on the
|
|
790
|
+
same curve), and only where the two ends share exactly the two opposite
|
|
791
|
+
vertices (so the mesh stays a closed manifold) and no remaining triangle
|
|
792
|
+
turns over; never an edge with a `frozen` end. With `check` (vertex
|
|
793
|
+
positions, one array or several), a pass's collapses that leave a face
|
|
794
|
+
crossing another at any of them are undone. Returns the faces."""
|
|
795
|
+
st = stats if stats is not None else {}
|
|
796
|
+
V = np.asarray(V, float)
|
|
797
|
+
F = np.asarray(F, np.int64).copy()
|
|
798
|
+
mask = np.asarray(mask, np.int64).copy()
|
|
799
|
+
crease = np.asarray(crease, bool).copy()
|
|
800
|
+
done = 0
|
|
801
|
+
# V and mask do not change here, so a face's true normal, own normal and
|
|
802
|
+
# smallest angle are functions of its three vertices: kept per face
|
|
803
|
+
# (rows follow F), recomputed only for faces a collapse rewrote
|
|
804
|
+
face_n = np.zeros((len(F), 3)) # the leaf's normal
|
|
805
|
+
face_h = np.zeros(len(F), bool) # (whether there is one)
|
|
806
|
+
face_a = np.zeros(len(F)) # smallest angle
|
|
807
|
+
face_m = np.zeros((len(F), 3)) # the face's own normal
|
|
808
|
+
known_f = np.zeros(len(F), bool)
|
|
809
|
+
for _ in range(passes):
|
|
810
|
+
# the neighbourhood of the creases: faces with a vertex within two rings
|
|
811
|
+
near = crease.copy()
|
|
812
|
+
for _r in range(2):
|
|
813
|
+
fn = near[F].any(axis=1)
|
|
814
|
+
near[F[fn].ravel()] = True
|
|
815
|
+
region = np.flatnonzero(near[F].any(axis=1))
|
|
816
|
+
if not len(region):
|
|
817
|
+
break
|
|
818
|
+
new_f = region[~known_f[region]]
|
|
819
|
+
if len(new_f):
|
|
820
|
+
face_n[new_f], face_h[new_f] = side_normals(tree, V, F[new_f], mask)
|
|
821
|
+
face_a[new_f] = _min_angle(V[F[new_f]])
|
|
822
|
+
face_m[new_f] = _face_normals(V, F[new_f])
|
|
823
|
+
known_f[new_f] = True
|
|
824
|
+
sn, has = face_n[region], face_h[region]
|
|
825
|
+
ma, nrm = face_a[region], face_m[region]
|
|
826
|
+
flipped = has & ((nrm * sn).sum(1) < 0)
|
|
827
|
+
bad = region[(ma < min_angle) | flipped]
|
|
828
|
+
if not len(bad):
|
|
829
|
+
break
|
|
830
|
+
# vertex -> faces over the whole mesh, for the vertices involved
|
|
831
|
+
inv = {}
|
|
832
|
+
involved = set(F[bad].ravel().tolist())
|
|
833
|
+
# one ring of faces around the involved vertices
|
|
834
|
+
ring = np.flatnonzero(np.isin(F, list(involved)).any(axis=1))
|
|
835
|
+
for fi in ring:
|
|
836
|
+
for v in F[fi]:
|
|
837
|
+
if v in involved:
|
|
838
|
+
inv.setdefault(v, set()).add(int(fi))
|
|
839
|
+
alive = np.ones(len(F), bool)
|
|
840
|
+
touched = set()
|
|
841
|
+
log = [] # per collapse: (faces removed, faces rewritten, their old rows, keep, gone)
|
|
842
|
+
changed = 0
|
|
843
|
+
order = bad[np.argsort(ma[np.searchsorted(region, bad)])]
|
|
844
|
+
# each face's edges, shortest first (a face is only collapsed while
|
|
845
|
+
# untouched, so its vertices are still these)
|
|
846
|
+
Fo = F[order]
|
|
847
|
+
ea, eb = Fo, np.roll(Fo, -1, axis=1)
|
|
848
|
+
el = np.einsum("nka,nka->nk", V[eb] - V[ea], V[eb] - V[ea])
|
|
849
|
+
eo = _edge_order(el, ea, eb)
|
|
850
|
+
ea = np.take_along_axis(ea, eo, 1).tolist()
|
|
851
|
+
eb = np.take_along_axis(eb, eo, 1).tolist()
|
|
852
|
+
for oi, fi in enumerate(order.tolist()):
|
|
853
|
+
if not alive[fi] or fi in touched:
|
|
854
|
+
continue
|
|
855
|
+
for a, b in zip(ea[oi], eb[oi]):
|
|
856
|
+
# keep: the crease end; both on a crease only with the same labels
|
|
857
|
+
if crease[a] and crease[b] and mask[a] != mask[b]:
|
|
858
|
+
continue
|
|
859
|
+
if frozen is not None and (frozen[a] or frozen[b]):
|
|
860
|
+
continue
|
|
861
|
+
keep, gone = (a, b) if (crease[a] or not crease[b]) else (b, a)
|
|
862
|
+
if keep not in inv or gone not in inv:
|
|
863
|
+
continue
|
|
864
|
+
fk = {x for x in inv[keep] if alive[x]}
|
|
865
|
+
fg = {x for x in inv[gone] if alive[x]}
|
|
866
|
+
if fk & touched or fg & touched:
|
|
867
|
+
continue
|
|
868
|
+
shared = fk & fg
|
|
869
|
+
if len(shared) != 2:
|
|
870
|
+
continue
|
|
871
|
+
nk = set(F[list(fk)].ravel().tolist()) - {keep}
|
|
872
|
+
ng = set(F[list(fg)].ravel().tolist()) - {gone}
|
|
873
|
+
opp = set(F[list(shared)].ravel().tolist()) - {keep, gone}
|
|
874
|
+
if (nk & ng) != opp:
|
|
875
|
+
continue
|
|
876
|
+
# the triangles that stay: gone -> keep, none may turn over
|
|
877
|
+
moved = np.array(sorted(fg - shared), np.int64)
|
|
878
|
+
if len(moved):
|
|
879
|
+
old = F[moved]
|
|
880
|
+
new = np.where(old == gone, keep, old)
|
|
881
|
+
if _turns_or_degenerates(V, old, new):
|
|
882
|
+
continue
|
|
883
|
+
F[moved] = new
|
|
884
|
+
known_f[moved] = False
|
|
885
|
+
log.append((list(shared), moved, old if len(moved) else None, keep, gone))
|
|
886
|
+
for x in shared:
|
|
887
|
+
alive[x] = False
|
|
888
|
+
for x in moved:
|
|
889
|
+
inv[keep].add(int(x))
|
|
890
|
+
touched |= fk | fg
|
|
891
|
+
changed += 1
|
|
892
|
+
break
|
|
893
|
+
if log and check is not None:
|
|
894
|
+
# a collapse may fold the mesh through a face a ring or two off
|
|
895
|
+
# (a thin wall's other side), with the vertices where they are
|
|
896
|
+
# or where untangle may send them back: undo those, and leave
|
|
897
|
+
# their ends alone from now on
|
|
898
|
+
undone = _fold_undo(F, alive, log, check)
|
|
899
|
+
for k in undone:
|
|
900
|
+
shared_k, moved_k, old_k, keep_k, gone_k = log[k]
|
|
901
|
+
if old_k is not None:
|
|
902
|
+
F[moved_k] = old_k
|
|
903
|
+
known_f[moved_k] = False
|
|
904
|
+
alive[shared_k] = True
|
|
905
|
+
if frozen is None:
|
|
906
|
+
frozen = np.zeros(len(V), bool)
|
|
907
|
+
frozen[[keep_k, gone_k]] = True
|
|
908
|
+
changed -= len(undone)
|
|
909
|
+
F = F[alive]
|
|
910
|
+
face_n, face_h, known_f = face_n[alive], face_h[alive], known_f[alive]
|
|
911
|
+
face_a, face_m = face_a[alive], face_m[alive]
|
|
912
|
+
done += changed
|
|
913
|
+
if not changed:
|
|
914
|
+
break
|
|
915
|
+
st["collapsed"] = int(done)
|
|
916
|
+
return F
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _seg_tri(p, q, T, eps=1e-9):
|
|
920
|
+
"""Does segment pq cross triangle T (strictly inside both)?"""
|
|
921
|
+
d = q - p
|
|
922
|
+
e1 = T[:, 1] - T[:, 0]
|
|
923
|
+
e2 = T[:, 2] - T[:, 0]
|
|
924
|
+
h = np.cross(d, e2)
|
|
925
|
+
a = (e1 * h).sum(1)
|
|
926
|
+
ok = np.abs(a) > 1e-14
|
|
927
|
+
f = np.where(ok, 1.0 / np.where(ok, a, 1.0), 0.0)
|
|
928
|
+
s = p - T[:, 0]
|
|
929
|
+
u = f * (s * h).sum(1)
|
|
930
|
+
qq = np.cross(s, e1)
|
|
931
|
+
v = f * (d * qq).sum(1)
|
|
932
|
+
t = f * (e2 * qq).sum(1)
|
|
933
|
+
return ok & (u > eps) & (v > eps) & (u + v < 1 - eps) & (t > eps) & (t < 1 - eps)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _cent_rad(V, F, chunk=262144):
|
|
937
|
+
"""Face centroids and circumradii (about the centroid), in chunks: the
|
|
938
|
+
whole (n, 3, 3) gather at once is memory-bound and ~4x slower."""
|
|
939
|
+
n = len(F)
|
|
940
|
+
C = np.empty((n, 3))
|
|
941
|
+
R = np.empty(n)
|
|
942
|
+
for i in range(0, n, chunk):
|
|
943
|
+
f = F[i:i + chunk]
|
|
944
|
+
a, b, c = V[f[:, 0]], V[f[:, 1]], V[f[:, 2]]
|
|
945
|
+
m = (a + b + c) / 3
|
|
946
|
+
C[i:i + chunk] = m
|
|
947
|
+
R[i:i + chunk] = np.sqrt(np.maximum(np.maximum(((a - m) ** 2).sum(1), ((b - m) ** 2).sum(1)),
|
|
948
|
+
((c - m) ** 2).sum(1)))
|
|
949
|
+
return C, R
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
class _Centroids:
|
|
953
|
+
"""Face centroids, circumradii and a k-d tree over them, kept across
|
|
954
|
+
untangle's rounds: a round moves a few vertices back, so only their
|
|
955
|
+
faces are refreshed. The tree (built once) still holds those faces
|
|
956
|
+
where they were: they are marked dirty, left out of its answers, and
|
|
957
|
+
searched in a small tree of their own instead."""
|
|
958
|
+
|
|
959
|
+
def __init__(self, V, F, p99=None):
|
|
960
|
+
self._V, self._F, self._p99 = V, F, p99
|
|
961
|
+
self._built = False
|
|
962
|
+
|
|
963
|
+
def _build(self):
|
|
964
|
+
from scipy.spatial import cKDTree
|
|
965
|
+
|
|
966
|
+
self.C, self.rad = _cent_rad(self._V, self._F)
|
|
967
|
+
self.p99 = float(np.percentile(self.rad, 99)) if self._p99 is None else float(self._p99)
|
|
968
|
+
# built once and queried a few times: the unbalanced build is ~2x faster
|
|
969
|
+
self.tree = cKDTree(self.C, balanced_tree=False, compact_nodes=False)
|
|
970
|
+
self.dirty = np.zeros(len(self._F), bool)
|
|
971
|
+
self._V = self._F = None
|
|
972
|
+
self._built = True
|
|
973
|
+
|
|
974
|
+
def __getattr__(self, name):
|
|
975
|
+
# built on first use: the compiled search (selfx.py) needs none of it
|
|
976
|
+
if name in ("C", "rad", "p99", "tree", "dirty") and not self.__dict__.get("_built"):
|
|
977
|
+
self._build()
|
|
978
|
+
return getattr(self, name)
|
|
979
|
+
raise AttributeError(name)
|
|
980
|
+
|
|
981
|
+
def update(self, V, F, faces):
|
|
982
|
+
if not self._built:
|
|
983
|
+
self._V = V # not built yet: built from V as it is when it is
|
|
984
|
+
return
|
|
985
|
+
self.C[faces], self.rad[faces] = _cent_rad(V, F[faces])
|
|
986
|
+
self.dirty[faces] = True
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _within(ctree, Q, r, ids=None):
|
|
990
|
+
"""Pairs (query row, point) with the point within r[row] of Q[row]: the
|
|
991
|
+
nearest 24, then 96, then 384 where that many were all within reach
|
|
992
|
+
(as arrays: ball queries build python lists), and a ball query for
|
|
993
|
+
what is left. `ids` maps the tree's points to face numbers."""
|
|
994
|
+
rows = np.arange(len(Q))
|
|
995
|
+
A, B = [], []
|
|
996
|
+
n = ctree.n
|
|
997
|
+
for k in (24, 96, 384):
|
|
998
|
+
if not len(rows):
|
|
999
|
+
break
|
|
1000
|
+
k = min(k, n)
|
|
1001
|
+
d, j = ctree.query(Q[rows], k=k, distance_upper_bound=float(r[rows].max()))
|
|
1002
|
+
d, j = d.reshape(len(rows), k), j.reshape(len(rows), k)
|
|
1003
|
+
ok = d <= r[rows][:, None]
|
|
1004
|
+
full = ok[:, -1].copy() if k < n else np.zeros(len(rows), bool)
|
|
1005
|
+
ok[full] = False
|
|
1006
|
+
A.append(np.broadcast_to(rows[:, None], j.shape)[ok])
|
|
1007
|
+
B.append(j[ok].astype(np.int64))
|
|
1008
|
+
rows = rows[full]
|
|
1009
|
+
if len(rows):
|
|
1010
|
+
lst = ctree.query_ball_point(Q[rows], r[rows])
|
|
1011
|
+
A.append(np.repeat(rows, [len(x) for x in lst]))
|
|
1012
|
+
B.append(np.concatenate([np.asarray(x, np.int64) for x in lst]))
|
|
1013
|
+
A, B = np.concatenate(A), np.concatenate(B)
|
|
1014
|
+
return A, (B if ids is None else ids[B])
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def crossing_faces(V, F, faces, cache=None):
|
|
1018
|
+
"""Faces among `faces` that cross another face of the mesh (sharing no
|
|
1019
|
+
vertex with it): a self-intersection. `cache` (a _Centroids of this
|
|
1020
|
+
V, F) saves rebuilding the centroids and their tree."""
|
|
1021
|
+
from scipy.spatial import cKDTree
|
|
1022
|
+
|
|
1023
|
+
faces = np.asarray(faces, np.int64)
|
|
1024
|
+
if not len(faces):
|
|
1025
|
+
return faces
|
|
1026
|
+
from . import selfx
|
|
1027
|
+
|
|
1028
|
+
if selfx.available():
|
|
1029
|
+
return selfx.crossing_faces(V, F, faces)
|
|
1030
|
+
if cache is None:
|
|
1031
|
+
cache = _Centroids(V, F)
|
|
1032
|
+
C, rad = cache.C, cache.rad
|
|
1033
|
+
# a face's reach: its own circumradius plus a typical neighbour's
|
|
1034
|
+
# (the 99th percentile: the long faces are the crease's own, and those
|
|
1035
|
+
# are tested from their side)
|
|
1036
|
+
r = rad[faces] + cache.p99 + 1e-12
|
|
1037
|
+
i, B = _within(cache.tree, C[faces], r)
|
|
1038
|
+
A = faces[i]
|
|
1039
|
+
dirty = np.flatnonzero(cache.dirty)
|
|
1040
|
+
if len(dirty):
|
|
1041
|
+
# the tree has these where they were: drop them, search them as they are
|
|
1042
|
+
keep = ~cache.dirty[B]
|
|
1043
|
+
A, B = A[keep], B[keep]
|
|
1044
|
+
i2, B2 = _within(cKDTree(C[dirty]), C[faces], r, ids=dirty)
|
|
1045
|
+
A, B = np.r_[A, faces[i2]], np.r_[B, B2]
|
|
1046
|
+
# only faces whose bounding spheres overlap can cross
|
|
1047
|
+
keep = (A != B) & (np.linalg.norm(C[A] - C[B], axis=1) < rad[A] + rad[B] + 1e-12)
|
|
1048
|
+
A, B = A[keep], B[keep]
|
|
1049
|
+
# each pair once (both orders turn up when both faces are in `faces`)
|
|
1050
|
+
lo_, hi_ = np.minimum(A, B), np.maximum(A, B)
|
|
1051
|
+
_, one = np.unique(lo_ * np.int64(len(F)) + hi_, return_index=True)
|
|
1052
|
+
A, B = lo_[one], hi_[one]
|
|
1053
|
+
share = (F[A][:, :, None] == F[B][:, None, :]).any(axis=(1, 2))
|
|
1054
|
+
A, B = A[~share], B[~share]
|
|
1055
|
+
# no crossing if one triangle lies wholly on one side of the other's plane
|
|
1056
|
+
for flip in (False, True):
|
|
1057
|
+
X, Y = (B, A) if flip else (A, B)
|
|
1058
|
+
TX = V[F[X]]
|
|
1059
|
+
n = np.cross(TX[:, 1] - TX[:, 0], TX[:, 2] - TX[:, 0])
|
|
1060
|
+
sd = np.einsum("nka,na->nk", V[F[Y]] - TX[:, :1], n)
|
|
1061
|
+
tol = 1e-12 * (n * n).sum(1)[:, None]
|
|
1062
|
+
apart = (sd > tol).all(1) | (sd < -tol).all(1)
|
|
1063
|
+
A, B = A[~apart], B[~apart]
|
|
1064
|
+
hit = np.zeros(len(A), bool)
|
|
1065
|
+
for X, Y in ((A, B), (B, A)):
|
|
1066
|
+
T = V[F[Y]]
|
|
1067
|
+
for k in range(3):
|
|
1068
|
+
hit |= _seg_tri(V[F[X, k]], V[F[X, (k + 1) % 3]], T)
|
|
1069
|
+
return np.unique(np.r_[A[hit], B[hit]])
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
_MOVED = 0.05 # pitches a crease vertex must move before untangle checks it
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _away(V, home, crease, pitch):
|
|
1076
|
+
"""Vertices away from home: a crease vertex moved more than _MOVED
|
|
1077
|
+
pitch, any other (a split's fallback, on the field) at all."""
|
|
1078
|
+
d = np.linalg.norm(V - home, axis=1)
|
|
1079
|
+
return np.where(crease, d > _MOVED * pitch, d > 0)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def untangle(V, F, tree, crease, home, pitch, rounds=4, stats=None):
|
|
1083
|
+
"""Send crease vertices (and the split points that fell back to the
|
|
1084
|
+
field) back to where the mesh had them (`home`: on its faces, as it
|
|
1085
|
+
was) wherever the sharpened crease crosses another part of the mesh or
|
|
1086
|
+
turns a triangle over: a crease is kept only where it does no harm."""
|
|
1087
|
+
V = np.array(V, float)
|
|
1088
|
+
crease = np.array(crease, bool)
|
|
1089
|
+
back = 0
|
|
1090
|
+
near = None
|
|
1091
|
+
F = np.asarray(F, np.int64)
|
|
1092
|
+
cache = _Centroids(V, F)
|
|
1093
|
+
# every round looks at faces with a moved crease vertex (a set that only
|
|
1094
|
+
# shrinks) and the rings round what it sent back: the patch of faces
|
|
1095
|
+
# within three rings of the first round's, not the whole mesh
|
|
1096
|
+
moved = _away(V, home, crease, pitch)
|
|
1097
|
+
pv = np.zeros(len(V), bool)
|
|
1098
|
+
pv[F[moved[F].any(axis=1)].ravel()] = True
|
|
1099
|
+
for _r in range(3):
|
|
1100
|
+
pv[F[pv[F].any(axis=1)].ravel()] = True
|
|
1101
|
+
loc = np.flatnonzero(pv[F].any(axis=1))
|
|
1102
|
+
FL = F[loc]
|
|
1103
|
+
for _ in range(rounds):
|
|
1104
|
+
# only where a crease vertex moved: elsewhere the mesh is as it was;
|
|
1105
|
+
# after the first round, only around what the last round sent back
|
|
1106
|
+
if _:
|
|
1107
|
+
moved = _away(V, home, crease, pitch)
|
|
1108
|
+
sel = moved[FL].any(axis=1)
|
|
1109
|
+
if near is not None:
|
|
1110
|
+
sel &= near[FL].any(axis=1)
|
|
1111
|
+
cf = loc[sel]
|
|
1112
|
+
if not len(cf):
|
|
1113
|
+
break
|
|
1114
|
+
bad = crossing_faces(V, F, cf, cache)
|
|
1115
|
+
mask = np.zeros(len(V), np.int64)
|
|
1116
|
+
fv = np.unique(F[cf].ravel())
|
|
1117
|
+
mask[fv] = label_masks(tree, V[fv], pitch)
|
|
1118
|
+
sn, has = side_normals(tree, V, F[cf], mask)
|
|
1119
|
+
flip = cf[has & ((_face_normals(V, F[cf]) * sn).sum(1) < 0)]
|
|
1120
|
+
bad = np.union1d(bad, flip)
|
|
1121
|
+
verts = np.unique(F[bad].ravel())
|
|
1122
|
+
verts = verts[np.any(V[verts] != home[verts], axis=1)]
|
|
1123
|
+
if not len(verts):
|
|
1124
|
+
break
|
|
1125
|
+
V[verts] = home[verts]
|
|
1126
|
+
crease[verts] = False
|
|
1127
|
+
back += len(verts)
|
|
1128
|
+
vm = np.zeros(len(V), bool)
|
|
1129
|
+
vm[verts] = True
|
|
1130
|
+
cache.update(V, F, loc[vm[FL].any(axis=1)])
|
|
1131
|
+
# the faces that could cross now: within a few rings of those moved back
|
|
1132
|
+
near = np.zeros(len(V), bool)
|
|
1133
|
+
near[verts] = True
|
|
1134
|
+
for _r in range(3):
|
|
1135
|
+
near[FL[near[FL].any(axis=1)].ravel()] = True
|
|
1136
|
+
if stats is not None:
|
|
1137
|
+
stats["untangled"] = int(back)
|
|
1138
|
+
return V, crease
|