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/sharp.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sharp features for field-built parts, and a measure of how sharp a part is.
|
|
3
|
+
|
|
4
|
+
A field meshed by marching cubes cannot hold a crease: wherever a hard
|
|
5
|
+
boolean meets two surfaces at an angle (a bearing bore through a flat face,
|
|
6
|
+
a bolt face, a keep-out's wall) the mesh comes out rounded over about one
|
|
7
|
+
grid step, and a bore cut from the field is a polygon a little undersized
|
|
8
|
+
near its rims. The study path (reconstruct.py) has never had this problem
|
|
9
|
+
for its interfaces because it does the last booleans on exact meshes; this
|
|
10
|
+
module gives field graphs the same treatment.
|
|
11
|
+
|
|
12
|
+
`plan(graph)` splits a graph at its hard booleans (union, intersect and
|
|
13
|
+
subtract with no blend) wherever one side is a plain shape (box, cylinder,
|
|
14
|
+
sphere, a union of those) or an imported mesh: those sides become `Exact`
|
|
15
|
+
leaves, cut, added or clipped with manifold3d, and everything else stays
|
|
16
|
+
an `Organic` leaf meshed from its field. The organic side is meshed from
|
|
17
|
+
an *extended* field: inside the region an exact shape will cut away (or
|
|
18
|
+
add, or clip off) anyway, the field is carried on from just outside it
|
|
19
|
+
along the shape's normal, so the organic mesh runs straight through where
|
|
20
|
+
the exact surface will be instead of trying to draw it (a collar built as
|
|
21
|
+
a shell round a bore has its own inner wall exactly on the bore's: meshed
|
|
22
|
+
as-is, the exact cut would leave a film of slivers there). The exact
|
|
23
|
+
boolean then draws the crease, on the exact shape's surface.
|
|
24
|
+
|
|
25
|
+
`edge_accuracy(mesh, refs, solid)` measures the result: it samples the
|
|
26
|
+
surfaces of the exact shapes where they are part of the delivered part's
|
|
27
|
+
boundary and reports how far the mesh is from them, which is where a
|
|
28
|
+
rounded crease shows (a 90 degree edge rounded with radius r is
|
|
29
|
+
0.41 r off at its corner).
|
|
30
|
+
|
|
31
|
+
The same plan is what `step.export_step` turns into a B-rep: exact leaves
|
|
32
|
+
become exact OpenCascade solids (planes and cylinders in the STEP file),
|
|
33
|
+
organic leaves faceted solids.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
from dataclasses import dataclass, field as dfield
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
__all__ = ["Exact", "Organic", "Op", "plan", "mesh_plan", "plan_refs", "has_exact", "exact_manifold",
|
|
43
|
+
"surface_samples", "edge_accuracy", "segments_for", "study_refs", "mesh_distance"]
|
|
44
|
+
|
|
45
|
+
# chordal tolerance (mm) of the exact cylinders and spheres in a sharp mesh
|
|
46
|
+
CHORD_TOL = 0.002
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Exact:
|
|
51
|
+
"""A plain shape (shapes.Shape) taken exactly into the result."""
|
|
52
|
+
shape: object
|
|
53
|
+
name: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Organic:
|
|
58
|
+
"""A field leaf: meshed from `field(P)` (negative inside)."""
|
|
59
|
+
field: object
|
|
60
|
+
names: list = dfield(default_factory=list)
|
|
61
|
+
mesh: object = None # set by mesh_plan
|
|
62
|
+
extended: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Op:
|
|
67
|
+
op: str # union | intersect | subtract
|
|
68
|
+
kids: list
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _hard(nd):
|
|
72
|
+
b = nd.refs.get("blend")
|
|
73
|
+
return b is None or (isinstance(b, float) and abs(b) <= 1e-12)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _exact_leaf(g, name):
|
|
77
|
+
"""The exact shape behind a graph node, or None."""
|
|
78
|
+
from .shapes import MeshShape, shape_from_spec
|
|
79
|
+
|
|
80
|
+
nd = g.nodes[name]
|
|
81
|
+
if nd.op == "shape":
|
|
82
|
+
return g._resource(nd, lambda: shape_from_spec(nd.spec["shape"], g.base_dir))
|
|
83
|
+
if nd.op == "mesh":
|
|
84
|
+
mf = g._resource(nd, lambda: g._mesh_field(nd))
|
|
85
|
+
try:
|
|
86
|
+
return MeshShape(mf.mesh)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return None
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _chunk(fields):
|
|
93
|
+
"""The points per thread task the fields' evaluators ask for (a compiled
|
|
94
|
+
graph's are small, narrowband.peval), when they all ask."""
|
|
95
|
+
cs = [getattr(f, "chunk", None) for f in fields]
|
|
96
|
+
return min(cs) if cs and all(cs) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Combined:
|
|
100
|
+
"""Hard boolean of field leaves: min / max of their values."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, op, fields):
|
|
103
|
+
self.op, self.fields = op, fields
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def chunk(self):
|
|
107
|
+
return _chunk(self.fields)
|
|
108
|
+
|
|
109
|
+
def __call__(self, P):
|
|
110
|
+
if self.op == "union":
|
|
111
|
+
return np.min([f(P) for f in self.fields], axis=0)
|
|
112
|
+
if self.op == "intersect":
|
|
113
|
+
return np.max([f(P) for f in self.fields], axis=0)
|
|
114
|
+
first, rest = self.fields[0], self.fields[1:]
|
|
115
|
+
return np.max([first(P)] + [-f(P) for f in rest], axis=0)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _combine(op, fields):
|
|
119
|
+
if len(fields) == 1:
|
|
120
|
+
return fields[0]
|
|
121
|
+
return _Combined(op, fields)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def plan(g, name=None):
|
|
125
|
+
"""The CSG plan of graph node `name` (default the output): an Organic
|
|
126
|
+
leaf for a graph with nothing to do exactly, else a tree of Op, Exact
|
|
127
|
+
and Organic. Hard booleans between field-only inputs stay in the field
|
|
128
|
+
(two field surfaces that coincide, a skin and the envelope it was
|
|
129
|
+
shelled from, must not meet in a mesh boolean)."""
|
|
130
|
+
return _plan(g, name or g.output)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _plan(g, name):
|
|
134
|
+
nd = g.nodes[name]
|
|
135
|
+
shape = _exact_leaf(g, name)
|
|
136
|
+
if shape is not None:
|
|
137
|
+
return Exact(shape, name)
|
|
138
|
+
if nd.op in ("union", "intersect", "subtract") and _hard(nd):
|
|
139
|
+
kids = [_plan(g, x) if isinstance(x, str) else None for x in nd.refs["inputs"]]
|
|
140
|
+
if any(k is None for k in kids) or all(isinstance(k, Organic) for k in kids):
|
|
141
|
+
return Organic(g.field(name), [name])
|
|
142
|
+
if nd.op in ("union", "intersect"):
|
|
143
|
+
org = [k for k in kids if isinstance(k, Organic)]
|
|
144
|
+
rest = [k for k in kids if not isinstance(k, Organic)]
|
|
145
|
+
if len(org) > 1:
|
|
146
|
+
org = [Organic(_combine(nd.op, [k.field for k in org]), sum((k.names for k in org), []))]
|
|
147
|
+
return Op(nd.op, org + rest)
|
|
148
|
+
first, cutters = kids[0], kids[1:]
|
|
149
|
+
org = [k for k in cutters if isinstance(k, Organic)]
|
|
150
|
+
if isinstance(first, Organic) and org:
|
|
151
|
+
first = Organic(_combine("subtract", [first.field] + [k.field for k in org]),
|
|
152
|
+
first.names + sum((k.names for k in org), []))
|
|
153
|
+
cutters = [k for k in cutters if not isinstance(k, Organic)]
|
|
154
|
+
if not cutters:
|
|
155
|
+
return first
|
|
156
|
+
return Op("subtract", [first] + cutters)
|
|
157
|
+
return Organic(g.field(name), [name])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def has_exact(p):
|
|
161
|
+
return not isinstance(p, Organic)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _leaves(p):
|
|
165
|
+
if isinstance(p, Op):
|
|
166
|
+
for k in p.kids:
|
|
167
|
+
yield from _leaves(k)
|
|
168
|
+
else:
|
|
169
|
+
yield p
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def plan_refs(p, sign=1):
|
|
173
|
+
"""[(shape, role)] of the plan's exact leaves: 'cut' (the part is outside
|
|
174
|
+
it), 'add' (unioned on) or 'clip' (the part is inside it)."""
|
|
175
|
+
out = []
|
|
176
|
+
if isinstance(p, Exact):
|
|
177
|
+
return [(p.shape, "add" if sign > 0 else "cut")]
|
|
178
|
+
if isinstance(p, Op):
|
|
179
|
+
for i, k in enumerate(p.kids):
|
|
180
|
+
if p.op == "subtract" and i > 0:
|
|
181
|
+
out += plan_refs(k, -sign)
|
|
182
|
+
elif p.op == "intersect" and isinstance(k, Exact):
|
|
183
|
+
out.append((k.shape, "clip" if sign > 0 else "cut"))
|
|
184
|
+
elif p.op == "subtract" and i == 0 and isinstance(k, Exact):
|
|
185
|
+
out.append((k.shape, "clip" if sign > 0 else "cut"))
|
|
186
|
+
else:
|
|
187
|
+
out += plan_refs(k, sign)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def study_refs(study):
|
|
192
|
+
"""[(shape, role)] of a study's exact interfaces: keep-outs are cut,
|
|
193
|
+
preserved shapes added, the design space clips."""
|
|
194
|
+
return ([(r.shape, "cut") for r in study.keep_out] + [(r.shape, "add") for r in study.preserve]
|
|
195
|
+
+ [(sh, "clip") for sh in study.design_space])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---- the extended field ----------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _dont_care(p, inherited=()):
|
|
201
|
+
"""Yield (Organic, [(shape, +1 inside | -1 outside)]): for each organic
|
|
202
|
+
leaf, the regions where its value cannot change the result."""
|
|
203
|
+
if isinstance(p, Organic):
|
|
204
|
+
yield p, list(inherited)
|
|
205
|
+
return
|
|
206
|
+
if not isinstance(p, Op):
|
|
207
|
+
return
|
|
208
|
+
exact = [k for k in p.kids if isinstance(k, Exact)]
|
|
209
|
+
for i, k in enumerate(p.kids):
|
|
210
|
+
if not isinstance(k, (Op, Organic)):
|
|
211
|
+
continue
|
|
212
|
+
extra = []
|
|
213
|
+
if p.op == "union":
|
|
214
|
+
extra = [(e.shape, +1) for e in exact]
|
|
215
|
+
elif p.op == "intersect":
|
|
216
|
+
extra = [(e.shape, -1) for e in exact]
|
|
217
|
+
elif p.op == "subtract":
|
|
218
|
+
if i == 0:
|
|
219
|
+
extra = [(e.shape, +1) for e in exact[0:] if e is not p.kids[0]]
|
|
220
|
+
elif isinstance(p.kids[0], Exact):
|
|
221
|
+
extra = [(p.kids[0].shape, -1)]
|
|
222
|
+
yield from _dont_care(k, list(inherited) + extra)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _Extended:
|
|
226
|
+
"""field(P) carried on into the don't-care region D along its normals:
|
|
227
|
+
with g the signed distance to D (negative inside), a point with
|
|
228
|
+
g < eps reads the field at its projection onto the g = eps level, and
|
|
229
|
+
from 2 * `core` inside D it is solid regardless (the projection
|
|
230
|
+
jumps across D's medial axis; whatever the field does there is cut
|
|
231
|
+
away, so it is kept from drawing surfaces)."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, field, regions, eps, core):
|
|
234
|
+
self.field = field
|
|
235
|
+
self.regions = regions
|
|
236
|
+
self.eps = float(eps)
|
|
237
|
+
self.core = float(core)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def chunk(self):
|
|
241
|
+
return _chunk([self.field])
|
|
242
|
+
|
|
243
|
+
def g(self, P):
|
|
244
|
+
if not hasattr(self, "_g"):
|
|
245
|
+
from . import fieldjit
|
|
246
|
+
|
|
247
|
+
# the same values, one compiled pass (fieldjit.CompiledShapes)
|
|
248
|
+
self._g = fieldjit.compile_shapes(self.regions)
|
|
249
|
+
if self._g is not None:
|
|
250
|
+
return self._g(P)
|
|
251
|
+
return np.min([s * sh.sdf(P) for sh, s in self.regions], axis=0)
|
|
252
|
+
|
|
253
|
+
def __call__(self, P):
|
|
254
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
255
|
+
gv = self.g(P)
|
|
256
|
+
near = gv < self.eps
|
|
257
|
+
if not near.any():
|
|
258
|
+
# the same clamp as below: a point must read the same whatever
|
|
259
|
+
# batch it comes in (narrowband merges block seams bit for bit;
|
|
260
|
+
# unclamped here, seams away from the cuts left holes)
|
|
261
|
+
out = np.asarray(self.field(P), float)
|
|
262
|
+
return np.minimum(np.maximum(out, -self.core), gv + self.core)
|
|
263
|
+
# 2 * core deep the clamp below gives gv + core whatever the field
|
|
264
|
+
# reads (a NaN aside): those points skip the projection and the field
|
|
265
|
+
deep = gv + self.core <= -self.core
|
|
266
|
+
live = near & ~deep
|
|
267
|
+
Q = P.copy()
|
|
268
|
+
Pn = P[live]
|
|
269
|
+
h = 1e-3
|
|
270
|
+
grad = np.stack([(self.g(Pn + h * e) - self.g(Pn - h * e)) / (2 * h) for e in np.eye(3)], axis=1)
|
|
271
|
+
nrm = grad / np.maximum(np.linalg.norm(grad, axis=1, keepdims=True), 1e-12)
|
|
272
|
+
Q[live] = Pn + (self.eps - gv[live])[:, None] * nrm
|
|
273
|
+
if deep.any():
|
|
274
|
+
out = np.full(len(P), -self.core)
|
|
275
|
+
if not deep.all():
|
|
276
|
+
out[~deep] = np.asarray(self.field(Q[~deep]), float)
|
|
277
|
+
else:
|
|
278
|
+
out = np.asarray(self.field(Q), float)
|
|
279
|
+
# clamped from below, the carried-on field gives way to the solid
|
|
280
|
+
# core by 2 * core deep: continuous there, whatever the projection
|
|
281
|
+
# does at the medial axis (a jump would wreck narrowband's
|
|
282
|
+
# Lipschitz bound)
|
|
283
|
+
return np.minimum(np.maximum(out, -self.core), gv + self.core)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- meshing ---------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def segments_for(r, tol=CHORD_TOL):
|
|
289
|
+
"""Segments per circle so a radius-r polygon is within tol of the circle."""
|
|
290
|
+
if r <= tol:
|
|
291
|
+
return 8
|
|
292
|
+
return int(min(max(math.ceil(math.pi / math.acos(1.0 - tol / r)), 32), 2048))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def exact_manifold(shape, tol=CHORD_TOL):
|
|
296
|
+
"""shape.to_manifold() with circles fine enough to be `tol` mm true."""
|
|
297
|
+
import manifold3d as m3d
|
|
298
|
+
|
|
299
|
+
from .shapes import Box, Cylinder, Shell, Sphere, Union
|
|
300
|
+
|
|
301
|
+
if isinstance(shape, Cylinder):
|
|
302
|
+
n = segments_for(shape.r, tol)
|
|
303
|
+
outer = m3d.Manifold.cylinder(shape.length, shape.r, shape.r, n)
|
|
304
|
+
if shape.r_inner > 0:
|
|
305
|
+
ni = segments_for(shape.r_inner, tol)
|
|
306
|
+
inner = m3d.Manifold.cylinder(shape.length + 2.0, shape.r_inner, shape.r_inner, ni).translate((0, 0, -1.0))
|
|
307
|
+
outer = outer - inner
|
|
308
|
+
return shape._align(outer)
|
|
309
|
+
if isinstance(shape, Sphere):
|
|
310
|
+
return m3d.Manifold.sphere(shape.r, segments_for(shape.r, tol)).translate(tuple(shape.center))
|
|
311
|
+
if isinstance(shape, Union):
|
|
312
|
+
mans = [exact_manifold(s, tol) for s in shape.shapes]
|
|
313
|
+
return m3d.Manifold.batch_boolean(mans, m3d.OpType.Add) if len(mans) > 1 else mans[0]
|
|
314
|
+
if isinstance(shape, Shell):
|
|
315
|
+
man = exact_manifold(shape.outer, tol) - exact_manifold(shape.inner, tol)
|
|
316
|
+
return man if shape.clip is None else man ^ exact_manifold(shape.clip, tol)
|
|
317
|
+
if isinstance(shape, Box):
|
|
318
|
+
return shape.to_manifold()
|
|
319
|
+
return shape.to_manifold()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def mesh_plan(p, lo, hi, pitch=None, project=3, progress=None, tol=CHORD_TOL, decimate=None, creases=None):
|
|
323
|
+
"""Mesh a plan: organic leaves by fields.field_mesh over [lo, hi] (from
|
|
324
|
+
their extended fields), exact leaves exactly, booleans by manifold3d.
|
|
325
|
+
Returns a trimesh; each Organic leaf keeps its mesh in `.mesh`.
|
|
326
|
+
`creases` (the FieldGraph): an organic leaf that is one graph node gets
|
|
327
|
+
the creases between its own field surfaces back (crease.py); near an
|
|
328
|
+
exact cut, where the mesh is the extended field, none are put in."""
|
|
329
|
+
import manifold3d as m3d
|
|
330
|
+
|
|
331
|
+
from .fields import _finish, field_mesh
|
|
332
|
+
from .shapes import manifold_from_trimesh, trimesh_from_manifold
|
|
333
|
+
|
|
334
|
+
lo = np.asarray(lo, float)
|
|
335
|
+
hi = np.asarray(hi, float)
|
|
336
|
+
if pitch is None:
|
|
337
|
+
pitch = float((np.prod(hi - lo) / 4e6) ** (1 / 3))
|
|
338
|
+
pitch = float(pitch)
|
|
339
|
+
stats = {}
|
|
340
|
+
for leaf, regions in _dont_care(p):
|
|
341
|
+
fld = leaf.field
|
|
342
|
+
if regions:
|
|
343
|
+
fld = _Extended(leaf.field, regions, eps=pitch, core=pitch)
|
|
344
|
+
leaf.extended = True
|
|
345
|
+
tree = None
|
|
346
|
+
if creases is not None and len(leaf.names) == 1:
|
|
347
|
+
from .crease import CreaseTree
|
|
348
|
+
|
|
349
|
+
tree = CreaseTree.from_graph(creases, leaf.names[0])
|
|
350
|
+
if tree is not None and regions:
|
|
351
|
+
# near the exact cuts the mesh is the extended field, not the
|
|
352
|
+
# tree's (and the boolean cuts it away): no creases there
|
|
353
|
+
margin = fld.eps + 4 * pitch
|
|
354
|
+
tree.ignore = lambda P, fld=fld, margin=margin: fld.g(np.asarray(P, float)) < margin
|
|
355
|
+
leaf.mesh = field_mesh(fld, lo, hi, pitch=pitch, project=project, progress=progress, decimate=decimate,
|
|
356
|
+
creases=tree)
|
|
357
|
+
leaf.plain = None
|
|
358
|
+
if tree is not None:
|
|
359
|
+
# if the creased mesh will not close, the plain one is used instead
|
|
360
|
+
leaf.plain = lambda fld=fld: field_mesh(fld, lo, hi, pitch=pitch, project=project, decimate=decimate)
|
|
361
|
+
if leaf.mesh.metadata.get("sampling"):
|
|
362
|
+
stats = leaf.mesh.metadata["sampling"]
|
|
363
|
+
|
|
364
|
+
def organic(m):
|
|
365
|
+
try:
|
|
366
|
+
return manifold_from_trimesh(m)
|
|
367
|
+
except ValueError:
|
|
368
|
+
import trimesh
|
|
369
|
+
m = m.copy()
|
|
370
|
+
trimesh.repair.fix_winding(m)
|
|
371
|
+
trimesh.repair.fill_holes(m)
|
|
372
|
+
# fill_holes leaves loops over four edges: a few slivers a thin
|
|
373
|
+
# lattice wall left open at a coarse pitch. A loop that touches
|
|
374
|
+
# another at a vertex closes on the next pass.
|
|
375
|
+
for last in (False, False, True):
|
|
376
|
+
try:
|
|
377
|
+
return manifold_from_trimesh(m)
|
|
378
|
+
except ValueError:
|
|
379
|
+
if last:
|
|
380
|
+
raise
|
|
381
|
+
m = _close_loops(m)
|
|
382
|
+
|
|
383
|
+
def build(q):
|
|
384
|
+
if isinstance(q, Exact):
|
|
385
|
+
return exact_manifold(q.shape, tol)
|
|
386
|
+
if isinstance(q, Organic):
|
|
387
|
+
try:
|
|
388
|
+
return organic(q.mesh)
|
|
389
|
+
except ValueError:
|
|
390
|
+
if getattr(q, "plain", None) is None:
|
|
391
|
+
raise
|
|
392
|
+
q.mesh = q.plain()
|
|
393
|
+
q.mesh.metadata["creases"] = {"reverted": "the creased mesh would not close"}
|
|
394
|
+
return organic(q.mesh)
|
|
395
|
+
kids = [build(k) for k in q.kids]
|
|
396
|
+
if q.op == "union":
|
|
397
|
+
return m3d.Manifold.batch_boolean(kids, m3d.OpType.Add)
|
|
398
|
+
if q.op == "intersect":
|
|
399
|
+
return m3d.Manifold.batch_boolean(kids, m3d.OpType.Intersect)
|
|
400
|
+
return m3d.Manifold.batch_boolean(kids, m3d.OpType.Subtract)
|
|
401
|
+
|
|
402
|
+
if progress is not None:
|
|
403
|
+
progress({"stage": "booleans", "exact": True})
|
|
404
|
+
man = build(p)
|
|
405
|
+
if man.is_empty():
|
|
406
|
+
raise ValueError("the part is empty after its exact booleans")
|
|
407
|
+
mesh = trimesh_from_manifold(man)
|
|
408
|
+
mesh = _finish(mesh.vertices, mesh.faces, None, pitch, 0, stats or None)
|
|
409
|
+
mesh.metadata["sharp"] = True
|
|
410
|
+
from .step import attach_plan
|
|
411
|
+
|
|
412
|
+
return attach_plan(mesh, p)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _close_loops(m, max_len=64):
|
|
416
|
+
"""Close each open boundary loop of up to `max_len` edges with a fan
|
|
417
|
+
from its centroid, wound against the faces beside it."""
|
|
418
|
+
import trimesh
|
|
419
|
+
|
|
420
|
+
F = np.asarray(m.faces)
|
|
421
|
+
d = np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]])
|
|
422
|
+
key = np.sort(d, axis=1)
|
|
423
|
+
_, inv, cnt = np.unique(key, axis=0, return_inverse=True, return_counts=True)
|
|
424
|
+
nxt = {int(a): int(b) for a, b in d[cnt[inv.ravel()] == 1]}
|
|
425
|
+
V = [np.asarray(m.vertices, float)]
|
|
426
|
+
new = []
|
|
427
|
+
n = len(V[0])
|
|
428
|
+
while nxt:
|
|
429
|
+
a0, a = nxt.popitem()
|
|
430
|
+
loop = [a0]
|
|
431
|
+
while a is not None and a != a0:
|
|
432
|
+
loop.append(a)
|
|
433
|
+
a = nxt.pop(a, None)
|
|
434
|
+
if a is None or len(loop) < 3 or len(loop) > max_len:
|
|
435
|
+
continue
|
|
436
|
+
V.append(V[0][loop].mean(axis=0)[None])
|
|
437
|
+
new += [[loop[(i + 1) % len(loop)], loop[i], n] for i in range(len(loop))]
|
|
438
|
+
n += 1
|
|
439
|
+
if not new:
|
|
440
|
+
return m
|
|
441
|
+
return trimesh.Trimesh(np.vstack(V), np.vstack([F, np.asarray(new)]), process=False)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---- measuring -------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def _frame(axis):
|
|
447
|
+
a = np.asarray(axis, float)
|
|
448
|
+
a = a / np.linalg.norm(a)
|
|
449
|
+
t = np.array([1.0, 0, 0]) if abs(a[0]) < 0.9 else np.array([0, 1.0, 0])
|
|
450
|
+
u = np.cross(a, t)
|
|
451
|
+
u /= np.linalg.norm(u)
|
|
452
|
+
return a, u, np.cross(a, u)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def surface_samples(shape, spacing=0.25):
|
|
456
|
+
"""(points, outward normals) spread over a shape's surface about
|
|
457
|
+
`spacing` mm apart."""
|
|
458
|
+
from .shapes import Box, Cylinder, MeshShape, Sphere, Union
|
|
459
|
+
|
|
460
|
+
P, N = [], []
|
|
461
|
+
if isinstance(shape, Box):
|
|
462
|
+
for a in range(3):
|
|
463
|
+
b, c = [i for i in range(3) if i != a]
|
|
464
|
+
ub = np.arange(shape.lo[b] + spacing / 2, shape.hi[b], spacing)
|
|
465
|
+
uc = np.arange(shape.lo[c] + spacing / 2, shape.hi[c], spacing)
|
|
466
|
+
B, C = (x.ravel() for x in np.meshgrid(ub, uc, indexing="ij"))
|
|
467
|
+
for sgn, at in ((-1.0, shape.lo[a]), (1.0, shape.hi[a])):
|
|
468
|
+
q = np.empty((len(B), 3))
|
|
469
|
+
q[:, a], q[:, b], q[:, c] = at, B, C
|
|
470
|
+
n = np.zeros((len(B), 3))
|
|
471
|
+
n[:, a] = sgn
|
|
472
|
+
P.append(q)
|
|
473
|
+
N.append(n)
|
|
474
|
+
elif isinstance(shape, Cylinder):
|
|
475
|
+
a, u, v = _frame(shape.axis)
|
|
476
|
+
ts = np.arange(spacing / 2, shape.length, spacing)
|
|
477
|
+
for r, sgn in ((shape.r, 1.0), (shape.r_inner, -1.0)):
|
|
478
|
+
if r <= 0:
|
|
479
|
+
continue
|
|
480
|
+
th = np.arange(0, 2 * math.pi, spacing / r)
|
|
481
|
+
T, TH = (x.ravel() for x in np.meshgrid(ts, th, indexing="ij"))
|
|
482
|
+
radial = np.cos(TH)[:, None] * u + np.sin(TH)[:, None] * v
|
|
483
|
+
P.append(shape.p0 + T[:, None] * a + r * radial)
|
|
484
|
+
N.append(sgn * radial)
|
|
485
|
+
rings = np.arange(max(shape.r_inner, 0) + spacing / 2, shape.r, spacing)
|
|
486
|
+
for at, sgn in ((shape.p0, -1.0), (shape.p1, 1.0)):
|
|
487
|
+
for r in rings:
|
|
488
|
+
th = np.arange(0, 2 * math.pi, spacing / r)
|
|
489
|
+
radial = np.cos(th)[:, None] * u + np.sin(th)[:, None] * v
|
|
490
|
+
P.append(at + r * radial)
|
|
491
|
+
N.append(np.repeat(sgn * a[None], len(th), axis=0))
|
|
492
|
+
elif isinstance(shape, Sphere):
|
|
493
|
+
n = max(int(4 * math.pi * shape.r ** 2 / spacing ** 2), 16)
|
|
494
|
+
i = np.arange(n) + 0.5
|
|
495
|
+
phi = np.arccos(1 - 2 * i / n)
|
|
496
|
+
th = math.pi * (1 + 5 ** 0.5) * i
|
|
497
|
+
d = np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], axis=1)
|
|
498
|
+
P.append(shape.center + shape.r * d)
|
|
499
|
+
N.append(d)
|
|
500
|
+
elif isinstance(shape, Union):
|
|
501
|
+
for s in shape.shapes:
|
|
502
|
+
p, n = surface_samples(s, spacing)
|
|
503
|
+
P.append(p)
|
|
504
|
+
N.append(n)
|
|
505
|
+
elif isinstance(shape, MeshShape):
|
|
506
|
+
import trimesh
|
|
507
|
+
|
|
508
|
+
m = shape.mesh
|
|
509
|
+
cnt = int(min(max(m.area / spacing ** 2, 100), 400_000))
|
|
510
|
+
p, fi = trimesh.sample.sample_surface_even(m, cnt, seed=1)
|
|
511
|
+
P.append(np.asarray(p))
|
|
512
|
+
N.append(np.asarray(m.face_normals)[fi])
|
|
513
|
+
if not P:
|
|
514
|
+
return np.zeros((0, 3)), np.zeros((0, 3))
|
|
515
|
+
return np.concatenate(P), np.concatenate(N)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def edge_accuracy(mesh, refs, solid=None, boundary=None, spacing=0.25, tol=0.01, delta=0.05, near=1.0):
|
|
519
|
+
"""How exactly `mesh` reproduces the exact shapes it was built with.
|
|
520
|
+
|
|
521
|
+
refs: [(shape, role)] (see plan_refs; studies: keep-outs 'cut', preserved
|
|
522
|
+
'add', design space 'clip'). A sample of each shape's surface counts
|
|
523
|
+
where it is the part's boundary: `boundary(P)` (True where P is on the
|
|
524
|
+
ideal part's surface, from an exact field) when given, else the
|
|
525
|
+
`solid(P)` oracle (default: the mesh itself) a `delta` step off the
|
|
526
|
+
surface on the material side. For each counted point the distance to
|
|
527
|
+
the mesh is the error; samples more than max(0.5, 4 delta) mm from the
|
|
528
|
+
mesh are taken as not on the part, so that is the largest error read.
|
|
529
|
+
|
|
530
|
+
Returns overall and per-role stats: mean, p99 and max error (mm), the
|
|
531
|
+
fraction of interface area within `tol` (`exactFraction`), and the
|
|
532
|
+
same over the points within `near` mm of a crease of the interface (the
|
|
533
|
+
edges: where the rounding is)."""
|
|
534
|
+
if solid is None:
|
|
535
|
+
def solid(P):
|
|
536
|
+
# the probes are a step off the surface: the closest triangles' side
|
|
537
|
+
return mesh_distance(mesh, P, signed=True) < 0
|
|
538
|
+
rows = {}
|
|
539
|
+
allP, allE, allEdge = [], [], []
|
|
540
|
+
for shape, role in refs:
|
|
541
|
+
P, N = surface_samples(shape, spacing)
|
|
542
|
+
if not len(P):
|
|
543
|
+
continue
|
|
544
|
+
# the shape's own surface, not the parts of it buried in another
|
|
545
|
+
# piece of the same shape (a union's members)
|
|
546
|
+
if not _has_mesh(shape):
|
|
547
|
+
own = np.abs(shape.sdf(P)) < 1e-6
|
|
548
|
+
P, N = P[own], N[own]
|
|
549
|
+
# a sample the mesh does not come near is not on the part (most of
|
|
550
|
+
# a design space's faces): drop those before the costlier tests
|
|
551
|
+
far = max(0.5, 4 * delta)
|
|
552
|
+
dist = mesh_distance(mesh, P, cap=far)
|
|
553
|
+
near_mesh = dist < far
|
|
554
|
+
P, N, dist = P[near_mesh], N[near_mesh], dist[near_mesh]
|
|
555
|
+
if boundary is not None:
|
|
556
|
+
keep = boundary(P)
|
|
557
|
+
else:
|
|
558
|
+
side = -1.0 if role == "clip" else 1.0
|
|
559
|
+
probe = P + side * delta * N
|
|
560
|
+
inside = solid(probe)
|
|
561
|
+
keep = inside if role in ("cut", "clip") else ~inside
|
|
562
|
+
if role == "add":
|
|
563
|
+
# out in the air beside a preserved face, and on the part
|
|
564
|
+
keep &= solid(P - delta * N)
|
|
565
|
+
P, N, dist = P[keep], N[keep], dist[keep]
|
|
566
|
+
if not len(P):
|
|
567
|
+
continue
|
|
568
|
+
# near an edge of this interface: a counted point with an uncounted
|
|
569
|
+
# sample within `near` (the interface stops there: a crease)
|
|
570
|
+
allP.append(P)
|
|
571
|
+
allE.append(dist)
|
|
572
|
+
rows.setdefault(role, []).append(dist)
|
|
573
|
+
if not allE:
|
|
574
|
+
return {"points": 0}
|
|
575
|
+
P = np.concatenate(allP)
|
|
576
|
+
E = np.concatenate(allE)
|
|
577
|
+
edge = _near_boundary(P, near, spacing)
|
|
578
|
+
|
|
579
|
+
def stats(e):
|
|
580
|
+
if not len(e):
|
|
581
|
+
return {"points": 0}
|
|
582
|
+
return {"points": int(len(e)), "mean": round(float(e.mean()), 4), "p99": round(float(np.percentile(e, 99)), 4),
|
|
583
|
+
"max": round(float(e.max()), 4), "exactFraction": round(float((e <= tol).mean()), 4)}
|
|
584
|
+
|
|
585
|
+
if hasattr(mesh, "_loadpath_dist"):
|
|
586
|
+
del mesh._loadpath_dist # the split triangles: big, and only for this measurement
|
|
587
|
+
out = stats(E)
|
|
588
|
+
out["edges"] = stats(E[edge])
|
|
589
|
+
out["tol"] = tol
|
|
590
|
+
out["byRole"] = {k: stats(np.concatenate(v)) for k, v in rows.items()}
|
|
591
|
+
return out
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def mesh_distance(mesh, P, edge=0.5, k=16, cap=np.inf, signed=False):
|
|
595
|
+
"""Distance from points P to a triangle mesh's surface (signed,
|
|
596
|
+
negative inside, with `signed`: by the normals of the closest
|
|
597
|
+
triangles, so for points near a closed mesh).
|
|
598
|
+
|
|
599
|
+
trimesh.proximity.closest_point gathers candidates by triangle
|
|
600
|
+
bounding boxes, and an exact boolean's long slivers (a bore wall is a
|
|
601
|
+
ring of triangles the length of the bore) make every point a candidate
|
|
602
|
+
of all of them; here the mesh is split to `edge` mm and each point
|
|
603
|
+
tries the k triangles with the nearest centroids. Distances are exact
|
|
604
|
+
up to `cap`; beyond it they are only known to be at least cap."""
|
|
605
|
+
import trimesh
|
|
606
|
+
from scipy.spatial import cKDTree
|
|
607
|
+
|
|
608
|
+
from . import meshops as _meshops
|
|
609
|
+
from .fields import _closest_on_triangles
|
|
610
|
+
|
|
611
|
+
key = (id(mesh), edge)
|
|
612
|
+
cache = getattr(mesh, "_loadpath_dist", None)
|
|
613
|
+
if cache is None or cache[0] != key:
|
|
614
|
+
v, f = np.asarray(mesh.vertices, float), np.asarray(mesh.faces, np.int64)
|
|
615
|
+
# only the long triangles need splitting (a marching-cubes surface's
|
|
616
|
+
# are already short): subdividing those alone gives the same
|
|
617
|
+
# triangles without trimesh's passes over the whole mesh (21 of the
|
|
618
|
+
# arm link's 36 s here)
|
|
619
|
+
tri = v[f]
|
|
620
|
+
e2 = np.max([np.einsum("ij,ij->i", d, d) for d in (tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 1],
|
|
621
|
+
tri[:, 0] - tri[:, 2])], axis=0)
|
|
622
|
+
long = e2 > edge * edge
|
|
623
|
+
if long.any():
|
|
624
|
+
vs, fs = trimesh.remesh.subdivide_to_size(v, f[long], max_edge=edge, max_iter=16)
|
|
625
|
+
tri = np.concatenate([tri[~long], vs[fs]])
|
|
626
|
+
nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
627
|
+
nrm /= np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-300)
|
|
628
|
+
cache = (key, tri, nrm, cKDTree(tri.mean(axis=1)))
|
|
629
|
+
mesh._loadpath_dist = cache
|
|
630
|
+
_, tri, nrm, tree = cache
|
|
631
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
632
|
+
out = np.empty(len(P))
|
|
633
|
+
side = np.ones(len(P))
|
|
634
|
+
k = min(k, len(tri))
|
|
635
|
+
reach = edge / math.sqrt(3.0) # no point of a split triangle is further from its centroid
|
|
636
|
+
|
|
637
|
+
def best(q, cand):
|
|
638
|
+
n = cand.shape[1]
|
|
639
|
+
step = max(1, 2_000_000 // n)
|
|
640
|
+
if len(q) > step:
|
|
641
|
+
parts = [best(q[j:j + step], cand[j:j + step]) for j in range(0, len(q), step)]
|
|
642
|
+
return np.concatenate([x[0] for x in parts]), np.concatenate([x[1] for x in parts])
|
|
643
|
+
if _meshops.available():
|
|
644
|
+
b2, sg = _meshops.nearest_on_triangles(q, cand, tri, nrm if signed else None, 1e-18, parallel=True)
|
|
645
|
+
return np.sqrt(b2), sg
|
|
646
|
+
T = tri[cand]
|
|
647
|
+
Q = np.repeat(q[:, None, :], n, axis=1).reshape(-1, 3)
|
|
648
|
+
C = _closest_on_triangles(Q, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
|
|
649
|
+
T[:, :, 2].reshape(-1, 3)).reshape(len(q), n, 3)
|
|
650
|
+
d2 = np.sum((C - q[:, None, :]) ** 2, axis=2)
|
|
651
|
+
b2 = d2.min(axis=1)
|
|
652
|
+
if not signed:
|
|
653
|
+
return np.sqrt(b2), np.ones(len(q))
|
|
654
|
+
# the side: normals of every candidate sharing the closest point
|
|
655
|
+
# (an edge or a corner), as fields.MeshField does
|
|
656
|
+
tie = d2 <= b2[:, None] * (1 + 1e-6) + 1e-18
|
|
657
|
+
cb = C[np.arange(len(q)), np.argmin(d2, axis=1)]
|
|
658
|
+
sn = np.einsum("ij,ij->i", q - cb, (nrm[cand] * tie[:, :, None]).sum(axis=1))
|
|
659
|
+
return np.sqrt(b2), np.where(sn < 0, -1.0, 1.0)
|
|
660
|
+
|
|
661
|
+
for i in range(0, len(P), 50_000):
|
|
662
|
+
q = P[i:i + 50_000]
|
|
663
|
+
d = np.full(len(q), np.inf)
|
|
664
|
+
sg = np.ones(len(q))
|
|
665
|
+
todo = np.arange(len(q))
|
|
666
|
+
kk = k
|
|
667
|
+
|
|
668
|
+
def take(idx, cand):
|
|
669
|
+
dd, ss = best(q[idx], cand)
|
|
670
|
+
better = dd < d[idx]
|
|
671
|
+
d[idx[better]] = dd[better]
|
|
672
|
+
sg[idx[better]] = ss[better]
|
|
673
|
+
|
|
674
|
+
# the k nearest centroids miss the closest triangle only if a
|
|
675
|
+
# centroid beyond them is within d + reach: widen k for those
|
|
676
|
+
while len(todo):
|
|
677
|
+
kk = min(kk, len(tri))
|
|
678
|
+
dk = np.empty((len(todo), kk))
|
|
679
|
+
for j in range(0, len(todo), max(1, 2_000_000 // kk)):
|
|
680
|
+
part = todo[j:j + max(1, 2_000_000 // kk)]
|
|
681
|
+
dkj, cand = tree.query(q[part], k=kk)
|
|
682
|
+
dk[j:j + len(part)] = np.asarray(dkj).reshape(len(part), kk)
|
|
683
|
+
take(part, np.asarray(cand).reshape(len(part), kk))
|
|
684
|
+
if kk == len(tri):
|
|
685
|
+
break
|
|
686
|
+
todo = todo[dk[:, -1] < np.minimum(d[todo], cap) + reach]
|
|
687
|
+
kk *= 4
|
|
688
|
+
if kk > 4096 and len(todo):
|
|
689
|
+
for j in todo: # a handful of points among thousands of tiny triangles
|
|
690
|
+
c = tree.query_ball_point(q[j], d[j] + reach)
|
|
691
|
+
if c:
|
|
692
|
+
take(np.array([j]), np.asarray(c)[None])
|
|
693
|
+
break
|
|
694
|
+
out[i:i + 50_000] = d
|
|
695
|
+
side[i:i + 50_000] = sg
|
|
696
|
+
return out * side if signed else out
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _has_mesh(shape):
|
|
700
|
+
from .shapes import MeshShape, Union
|
|
701
|
+
|
|
702
|
+
if isinstance(shape, Union):
|
|
703
|
+
return any(_has_mesh(s) for s in shape.shapes)
|
|
704
|
+
return isinstance(shape, MeshShape)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _near_boundary(P, near, spacing):
|
|
708
|
+
"""Points with fewer neighbours within `near` than a flat interior
|
|
709
|
+
point would have: the rims of an interface patch."""
|
|
710
|
+
from scipy.spatial import cKDTree
|
|
711
|
+
|
|
712
|
+
if len(P) < 4:
|
|
713
|
+
return np.ones(len(P), bool)
|
|
714
|
+
tree = cKDTree(P)
|
|
715
|
+
cnt = np.asarray([len(x) for x in tree.query_ball_point(P, near, workers=-1)])
|
|
716
|
+
full = math.pi * near ** 2 / spacing ** 2
|
|
717
|
+
return cnt < 0.8 * full
|