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/fields.py
ADDED
|
@@ -0,0 +1,3697 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field-driven (implicit) design: a part is a graph of implicit fields that an
|
|
3
|
+
agent writes as JSON (or builds in Python), and every operation can be driven
|
|
4
|
+
by another field.
|
|
5
|
+
|
|
6
|
+
{"nodes": {
|
|
7
|
+
"body": {"op": "mesh", "path": "arm-link.stl"},
|
|
8
|
+
"stress": {"op": "result", "path": "arm-link.fields.npz", "quantity": "vonMises"},
|
|
9
|
+
"wall": {"op": "remap", "input": "stress", "from": [2, 30], "to": [0.8, 2.0]},
|
|
10
|
+
"core": {"op": "lattice", "type": "gyroid", "cell": 8, "thickness": "wall"},
|
|
11
|
+
"skin": {"op": "shell", "input": "body", "thickness": 1.2},
|
|
12
|
+
"part": {"op": "union", "inputs": ["skin", {"op": "intersect", "inputs": ["core", "body"]}],
|
|
13
|
+
"blend": 1.5}},
|
|
14
|
+
"output": "part",
|
|
15
|
+
"mesh": {"pitch": 0.3}}
|
|
16
|
+
|
|
17
|
+
python -m loadpath field part.json --out out/
|
|
18
|
+
|
|
19
|
+
Two kinds of field, one representation. A SOLID is a signed distance in mm,
|
|
20
|
+
negative inside (shapes, meshes, lattices, booleans, shells). A SCALAR is
|
|
21
|
+
any number per point (a coordinate, a distance, a stress, a temperature, a
|
|
22
|
+
remapped ramp). Every parameter that takes a number (a wall thickness, a
|
|
23
|
+
fillet radius, a lattice cell, an offset) also takes a scalar field, which
|
|
24
|
+
is the whole idea: the wall follows the stress, the cell grows away from
|
|
25
|
+
the bolts, the fillet is larger where the load comes in.
|
|
26
|
+
|
|
27
|
+
A parameter is a number, the name of another node, or an inline node
|
|
28
|
+
object. Nodes are evaluated lazily at whatever points are asked for, each
|
|
29
|
+
once per batch of points, so a graph can share a subtree (the body used by
|
|
30
|
+
the skin, the clip and the distance ramp) without paying for it three
|
|
31
|
+
times.
|
|
32
|
+
|
|
33
|
+
Meshes come out smooth: the field is marching-cubed on a grid and each
|
|
34
|
+
vertex is then moved onto the field's own zero level along its gradient (a
|
|
35
|
+
Newton step), so a curved wall is curved, not faceted at the grid, and no
|
|
36
|
+
step of a voxel mask survives into the part. A mesh imported as a field
|
|
37
|
+
keeps its own surface to within the projection's tolerance: near the
|
|
38
|
+
surface its distance is exact (closest point on the closest triangle), and
|
|
39
|
+
only far from it does the voxel distance transform stand in.
|
|
40
|
+
|
|
41
|
+
The pieces this builds on: shapes.py's exact primitives, sdf_kit's smooth
|
|
42
|
+
booleans, lattice.py's TPMS level sets and homogenized density tables, and
|
|
43
|
+
voxels.py's scanline voxelizer. See `schema.FIELD_SCHEMA` for every node.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import ast
|
|
48
|
+
import json
|
|
49
|
+
import math
|
|
50
|
+
import os
|
|
51
|
+
import threading
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
from scipy import ndimage
|
|
56
|
+
|
|
57
|
+
from .narrowband import face_cross
|
|
58
|
+
|
|
59
|
+
__all__ = ["FieldGraph", "MeshField", "field_mesh", "save_result_field", "result_samples", "OPS"]
|
|
60
|
+
|
|
61
|
+
# the operations and what they take (the schema says the same, with docs)
|
|
62
|
+
OPS = ("constant", "coordinate", "distance", "shape", "mesh", "result", "grid", "image", "remap", "expr",
|
|
63
|
+
"union", "intersect", "subtract", "shell", "offset", "lattice", "transform")
|
|
64
|
+
|
|
65
|
+
_CELL_BUDGET = 60_000_000
|
|
66
|
+
_CHUNK = 1_000_000
|
|
67
|
+
_JIT_CHUNK = 32_768 # points per thread task for a compiled field (narrowband.peval)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- expressions -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _smoothstep(e0, e1, x):
|
|
73
|
+
u = np.clip((x - e0) / np.where(e1 == e0, 1e-12, e1 - e0), 0.0, 1.0)
|
|
74
|
+
return u * u * (3.0 - 2.0 * u)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_FUNCS = {
|
|
78
|
+
"abs": np.abs, "sqrt": lambda a: np.sqrt(np.maximum(a, 0.0)), "exp": np.exp,
|
|
79
|
+
"log": lambda a: np.log(np.maximum(a, 1e-300)), "sin": np.sin, "cos": np.cos, "tan": np.tan,
|
|
80
|
+
"floor": np.floor, "ceil": np.ceil, "sign": np.sign,
|
|
81
|
+
"min": lambda *a: _reduce(np.minimum, a), "max": lambda *a: _reduce(np.maximum, a),
|
|
82
|
+
"clamp": lambda x, lo, hi: np.clip(x, lo, hi),
|
|
83
|
+
"lerp": lambda a, b, t: a + (b - a) * t,
|
|
84
|
+
"smoothstep": _smoothstep,
|
|
85
|
+
"step": lambda edge, x: (x >= edge).astype(float),
|
|
86
|
+
"where": lambda c, a, b: np.where(c > 0, a, b),
|
|
87
|
+
}
|
|
88
|
+
_CONSTS = {"pi": math.pi, "e": math.e}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _reduce(fn, args):
|
|
92
|
+
out = args[0]
|
|
93
|
+
for a in args[1:]:
|
|
94
|
+
out = fn(out, a)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_BINOPS = {ast.Add: np.add, ast.Sub: np.subtract, ast.Mult: np.multiply, ast.Div: np.divide,
|
|
99
|
+
ast.Pow: np.power, ast.Mod: np.mod}
|
|
100
|
+
_CMPOPS = {ast.Lt: np.less, ast.LtE: np.less_equal, ast.Gt: np.greater, ast.GtE: np.greater_equal}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _compile_expr(text: str, lib=None):
|
|
104
|
+
"""(names it reads, fn(env) -> value) for an arithmetic expression over
|
|
105
|
+
node names, x, y, z, pi, e and the functions in _FUNCS. A whitelist over
|
|
106
|
+
Python's AST: no attributes, no subscripts, no calls but those. `lib`
|
|
107
|
+
swaps the array functions (funcs, binops, cmpops, where, float) for
|
|
108
|
+
another array library's (fieldtorch.py)."""
|
|
109
|
+
lib = lib or {}
|
|
110
|
+
funcs, binops, cmpops = lib.get("funcs", _FUNCS), lib.get("binops", _BINOPS), lib.get("cmpops", _CMPOPS)
|
|
111
|
+
where = lib.get("where", lambda c, a, b: np.where(np.asarray(c) > 0, a, b))
|
|
112
|
+
as_float = lib.get("float", lambda a: a.astype(float))
|
|
113
|
+
try:
|
|
114
|
+
tree = ast.parse(text, mode="eval")
|
|
115
|
+
except SyntaxError as exc:
|
|
116
|
+
raise ValueError(f"expr {text!r} is not a valid expression: {exc.msg}") from None
|
|
117
|
+
names = set()
|
|
118
|
+
|
|
119
|
+
def build(n):
|
|
120
|
+
if isinstance(n, ast.Expression):
|
|
121
|
+
return build(n.body)
|
|
122
|
+
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)) and not isinstance(n.value, bool):
|
|
123
|
+
v = float(n.value)
|
|
124
|
+
return lambda env: v
|
|
125
|
+
if isinstance(n, ast.Name):
|
|
126
|
+
if n.id in _CONSTS:
|
|
127
|
+
v = _CONSTS[n.id]
|
|
128
|
+
return lambda env: v
|
|
129
|
+
names.add(n.id)
|
|
130
|
+
k = n.id
|
|
131
|
+
return lambda env: env[k]
|
|
132
|
+
if isinstance(n, ast.BinOp) and type(n.op) in binops:
|
|
133
|
+
f, a, b = binops[type(n.op)], build(n.left), build(n.right)
|
|
134
|
+
return lambda env: f(a(env), b(env))
|
|
135
|
+
if isinstance(n, ast.UnaryOp) and isinstance(n.op, (ast.USub, ast.UAdd)):
|
|
136
|
+
a = build(n.operand)
|
|
137
|
+
return (lambda env: -a(env)) if isinstance(n.op, ast.USub) else a
|
|
138
|
+
if isinstance(n, ast.Compare) and len(n.ops) == 1 and type(n.ops[0]) in cmpops:
|
|
139
|
+
f, a, b = cmpops[type(n.ops[0])], build(n.left), build(n.comparators[0])
|
|
140
|
+
return lambda env: as_float(f(a(env), b(env)))
|
|
141
|
+
if isinstance(n, ast.IfExp):
|
|
142
|
+
c, a, b = build(n.test), build(n.body), build(n.orelse)
|
|
143
|
+
return lambda env: where(c(env), a(env), b(env))
|
|
144
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id in funcs and not n.keywords:
|
|
145
|
+
f, args = funcs[n.func.id], [build(a) for a in n.args]
|
|
146
|
+
return lambda env: f(*(a(env) for a in args))
|
|
147
|
+
bad = ast.unparse(n) if hasattr(ast, "unparse") else type(n).__name__
|
|
148
|
+
raise ValueError(f"expr {text!r}: {bad!r} is not allowed (arithmetic, comparisons, "
|
|
149
|
+
f"node names, x/y/z and {', '.join(sorted(_FUNCS))})")
|
|
150
|
+
|
|
151
|
+
fn = build(tree)
|
|
152
|
+
return names, fn
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---- exact distance to a triangle mesh ---------------------------------------
|
|
156
|
+
|
|
157
|
+
def _closest_on_triangles(p, a, b, c):
|
|
158
|
+
"""Closest points on triangles (a, b, c) to points p, all (N, 3)
|
|
159
|
+
(Ericson, Real-Time Collision Detection 5.1.5, vectorized)."""
|
|
160
|
+
ab, ac, ap = b - a, c - a, p - a
|
|
161
|
+
d1 = np.einsum("ij,ij->i", ab, ap)
|
|
162
|
+
d2 = np.einsum("ij,ij->i", ac, ap)
|
|
163
|
+
bp = p - b
|
|
164
|
+
d3 = np.einsum("ij,ij->i", ab, bp)
|
|
165
|
+
d4 = np.einsum("ij,ij->i", ac, bp)
|
|
166
|
+
cp = p - c
|
|
167
|
+
d5 = np.einsum("ij,ij->i", ab, cp)
|
|
168
|
+
d6 = np.einsum("ij,ij->i", ac, cp)
|
|
169
|
+
va = d3 * d6 - d5 * d4
|
|
170
|
+
vb = d5 * d2 - d1 * d6
|
|
171
|
+
vc = d1 * d4 - d3 * d2
|
|
172
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
173
|
+
denom = 1.0 / (va + vb + vc)
|
|
174
|
+
v = vb * denom
|
|
175
|
+
w = vc * denom
|
|
176
|
+
out = a + ab * v[:, None] + ac * w[:, None]
|
|
177
|
+
# edges
|
|
178
|
+
t_ab = d1 / (d1 - d3)
|
|
179
|
+
e_ab = (vc <= 0) & (d1 >= 0) & (d3 <= 0)
|
|
180
|
+
out = np.where(e_ab[:, None], a + ab * t_ab[:, None], out)
|
|
181
|
+
t_ac = d2 / (d2 - d6)
|
|
182
|
+
e_ac = (vb <= 0) & (d2 >= 0) & (d6 <= 0)
|
|
183
|
+
out = np.where(e_ac[:, None], a + ac * t_ac[:, None], out)
|
|
184
|
+
t_bc = (d4 - d3) / ((d4 - d3) + (d5 - d6))
|
|
185
|
+
e_bc = (va <= 0) & ((d4 - d3) >= 0) & ((d5 - d6) >= 0)
|
|
186
|
+
out = np.where(e_bc[:, None], b + (c - b) * t_bc[:, None], out)
|
|
187
|
+
# vertices
|
|
188
|
+
out = np.where(((d1 <= 0) & (d2 <= 0))[:, None], a, out)
|
|
189
|
+
out = np.where(((d3 >= 0) & (d4 <= d3))[:, None], b, out)
|
|
190
|
+
out = np.where(((d6 >= 0) & (d5 <= d6))[:, None], c, out)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MeshField:
|
|
195
|
+
"""Signed distance to a closed triangle mesh, exact near the surface.
|
|
196
|
+
|
|
197
|
+
Far from the surface (beyond `band` mm) it is a voxel distance transform
|
|
198
|
+
at `pitch`, interpolated: good to half a voxel, which is all a grading
|
|
199
|
+
ramp needs. Within the band it is the distance to the closest point on
|
|
200
|
+
the closest triangle (candidates from a k-d tree over the centroids of
|
|
201
|
+
the mesh subdivided to `pitch`), signed by the angle-weighted normal of
|
|
202
|
+
the triangles that share that point. That is what keeps an imported part
|
|
203
|
+
smooth when it is re-meshed: the zero level is the mesh's own surface,
|
|
204
|
+
not a voxel staircase."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, mesh, pitch=None, band=None, k=12):
|
|
207
|
+
import trimesh
|
|
208
|
+
from scipy.spatial import cKDTree
|
|
209
|
+
|
|
210
|
+
from .voxels import mesh_solid
|
|
211
|
+
|
|
212
|
+
if not isinstance(mesh, trimesh.Trimesh):
|
|
213
|
+
raise ValueError("MeshField needs a trimesh.Trimesh")
|
|
214
|
+
if not mesh.is_watertight:
|
|
215
|
+
raise ValueError("a mesh field needs a closed (watertight) mesh to have an inside")
|
|
216
|
+
ext = float(np.max(mesh.extents))
|
|
217
|
+
self.pitch = float(pitch or max(ext / 200.0, 0.25))
|
|
218
|
+
self.band = float(band if band is not None else 3.0 * self.pitch)
|
|
219
|
+
self.k = int(k)
|
|
220
|
+
self.mesh = mesh
|
|
221
|
+
self.lo, self.hi = (np.asarray(b, float) for b in mesh.bounds)
|
|
222
|
+
solid, origin, _ = mesh_solid(mesh, self.pitch, pad=4)
|
|
223
|
+
dist = (ndimage.distance_transform_edt(~solid) - ndimage.distance_transform_edt(solid)) * self.pitch
|
|
224
|
+
dist -= 0.5 * self.pitch * np.sign(dist)
|
|
225
|
+
self._grid = dist.astype(np.float32)
|
|
226
|
+
self._origin = origin
|
|
227
|
+
v, f = np.asarray(mesh.vertices, float), np.asarray(mesh.faces, np.int64)
|
|
228
|
+
edge = max(self.pitch, ext / 400.0)
|
|
229
|
+
if np.linalg.norm(v[f[:, 0]] - v[f[:, 1]], axis=1).max() > edge:
|
|
230
|
+
v, f = trimesh.remesh.subdivide_to_size(v, f, max_edge=edge, max_iter=12)
|
|
231
|
+
self._tri = v[f]
|
|
232
|
+
n = np.cross(self._tri[:, 1] - self._tri[:, 0], self._tri[:, 2] - self._tri[:, 0])
|
|
233
|
+
self._normal = n / np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-300)
|
|
234
|
+
self._tree = cKDTree(self._tri.mean(axis=1))
|
|
235
|
+
|
|
236
|
+
def _coarse(self, P):
|
|
237
|
+
idx = ((P - self._origin) / self.pitch).T
|
|
238
|
+
n = np.asarray(self._grid.shape)[:, None] - 1
|
|
239
|
+
cl = np.clip(idx, 0, n)
|
|
240
|
+
out = ndimage.map_coordinates(self._grid, cl, order=1, mode="nearest").astype(float)
|
|
241
|
+
# past the grid: the distance at its nearest point plus the way there (continuous at the grid's
|
|
242
|
+
# face, never short of the true distance by more than the grid's error), so an offset of the mesh
|
|
243
|
+
# by more than the grid's padding stays the mesh's shape, not its box
|
|
244
|
+
return out + np.linalg.norm(idx - cl, axis=0) * self.pitch
|
|
245
|
+
|
|
246
|
+
def _exact(self, P):
|
|
247
|
+
k = min(self.k, len(self._tri))
|
|
248
|
+
from . import knn, meshops
|
|
249
|
+
|
|
250
|
+
if knn.available():
|
|
251
|
+
# the same neighbours, compiled and warm-started (knn.py)
|
|
252
|
+
if getattr(self, "_knn", None) is None:
|
|
253
|
+
self._knn = knn.KDTree(self._tree.data)
|
|
254
|
+
cand = self._knn.query(P, k)
|
|
255
|
+
else:
|
|
256
|
+
_, cand = self._tree.query(P, k=k)
|
|
257
|
+
cand = cand.reshape(len(P), k)
|
|
258
|
+
|
|
259
|
+
if meshops.available():
|
|
260
|
+
# compiled, serial (field evaluation runs on peval's threads)
|
|
261
|
+
best, side = meshops.nearest_on_triangles(P, cand, self._tri, self._normal, (1e-9 * self.pitch) ** 2)
|
|
262
|
+
return side * np.sqrt(best)
|
|
263
|
+
T = self._tri[cand] # (N, k, 3, 3)
|
|
264
|
+
Pk = np.repeat(P[:, None, :], k, axis=1).reshape(-1, 3)
|
|
265
|
+
C = _closest_on_triangles(Pk, T[:, :, 0].reshape(-1, 3), T[:, :, 1].reshape(-1, 3),
|
|
266
|
+
T[:, :, 2].reshape(-1, 3)).reshape(len(P), k, 3)
|
|
267
|
+
d2 = np.sum((C - P[:, None, :]) ** 2, axis=2)
|
|
268
|
+
best = d2.min(axis=1)
|
|
269
|
+
# sign: normals of every candidate whose closest point is (nearly) the
|
|
270
|
+
# closest point, weighted by how squarely they face it
|
|
271
|
+
tie = d2 <= best[:, None] * (1 + 1e-6) + (1e-9 * self.pitch) ** 2
|
|
272
|
+
ib = np.argmin(d2, axis=1)
|
|
273
|
+
cb = C[np.arange(len(P)), ib]
|
|
274
|
+
nrm = (self._normal[cand] * tie[:, :, None]).sum(axis=1)
|
|
275
|
+
s = np.einsum("ij,ij->i", P - cb, nrm)
|
|
276
|
+
dist = np.sqrt(best)
|
|
277
|
+
return np.where(s < 0, -dist, dist)
|
|
278
|
+
|
|
279
|
+
def __call__(self, P):
|
|
280
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
281
|
+
d = self._coarse(P)
|
|
282
|
+
near = np.abs(d) < self.band
|
|
283
|
+
if near.any():
|
|
284
|
+
d[near] = self._exact(P[near])
|
|
285
|
+
return d
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---- result fields -------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def result_samples(fields: dict, quantity: str = "vonMises", case=None):
|
|
291
|
+
"""(points, values) from verify_part(..., fields=True)["fields"]: von
|
|
292
|
+
Mises (MPa, the worst case at each node unless `case` names one) or
|
|
293
|
+
displacement magnitude (mm)."""
|
|
294
|
+
cases = fields["cases"]
|
|
295
|
+
if case is not None:
|
|
296
|
+
cases = [c for c in cases if c["name"] == case]
|
|
297
|
+
if not cases:
|
|
298
|
+
raise ValueError(f"no load case {case!r} in the result (have {[c['name'] for c in fields['cases']]})")
|
|
299
|
+
if quantity == "vonMises":
|
|
300
|
+
vals = np.max([np.asarray(c["vonMises"], float) for c in cases], axis=0)
|
|
301
|
+
elif quantity == "displacement":
|
|
302
|
+
vals = np.max([np.linalg.norm(np.asarray(c["displacement"], float), axis=1) for c in cases], axis=0)
|
|
303
|
+
else:
|
|
304
|
+
raise ValueError("quantity must be 'vonMises' or 'displacement' for a verify result")
|
|
305
|
+
return np.asarray(fields["nodes"], float), vals
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def save_result_field(path, points, values, **extra):
|
|
309
|
+
"""Write scattered samples (N, 3) / (N,) as the .npz a `result` node
|
|
310
|
+
reads. Extra arrays (other quantities) go in under their own names."""
|
|
311
|
+
np.savez_compressed(path, points=np.asarray(points, np.float32), values=np.asarray(values, np.float32),
|
|
312
|
+
**{k: np.asarray(v) for k, v in extra.items()})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _Samples:
|
|
316
|
+
"""A scalar field from samples: on a regular grid (origin, pitch,
|
|
317
|
+
values[nx, ny, nz]) trilinear; from scattered points (verify's FE nodes,
|
|
318
|
+
a CFD export) put onto the grid they lie on when they do (FE nodes
|
|
319
|
+
always do), else inverse-distance weighted over the 8 nearest. Outside
|
|
320
|
+
the samples the nearest value carries on, so a remap never sees NaN."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, points=None, values=None, grid=None, origin=None, pitch=None):
|
|
323
|
+
if grid is not None:
|
|
324
|
+
self.grid = np.asarray(grid, np.float32)
|
|
325
|
+
self.origin = np.asarray(origin, float)
|
|
326
|
+
self.pitch = np.broadcast_to(np.asarray(pitch, float), (3,)).copy()
|
|
327
|
+
self.tree = None
|
|
328
|
+
return
|
|
329
|
+
pts = np.asarray(points, float).reshape(-1, 3)
|
|
330
|
+
vals = np.asarray(values, float).ravel()
|
|
331
|
+
if len(pts) != len(vals):
|
|
332
|
+
raise ValueError(f"result field has {len(pts)} points but {len(vals)} values")
|
|
333
|
+
g = self._regular(pts, vals)
|
|
334
|
+
if g is not None:
|
|
335
|
+
self.grid, self.origin, self.pitch = g
|
|
336
|
+
self.tree = None
|
|
337
|
+
else:
|
|
338
|
+
from scipy.spatial import cKDTree
|
|
339
|
+
|
|
340
|
+
self.tree = cKDTree(pts)
|
|
341
|
+
self.vals = vals
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _regular(pts, vals):
|
|
345
|
+
lo = pts.min(axis=0)
|
|
346
|
+
steps = []
|
|
347
|
+
for i in range(3):
|
|
348
|
+
u = np.unique(np.round(pts[:, i] - lo[i], 6))
|
|
349
|
+
if len(u) < 2:
|
|
350
|
+
return None
|
|
351
|
+
du = np.diff(u)
|
|
352
|
+
h = du.min()
|
|
353
|
+
if h <= 0 or not np.allclose(du / h, np.round(du / h), atol=1e-3):
|
|
354
|
+
return None
|
|
355
|
+
steps.append(h)
|
|
356
|
+
pitch = np.asarray(steps)
|
|
357
|
+
ijk = np.round((pts - lo) / pitch).astype(np.int64)
|
|
358
|
+
dims = ijk.max(axis=0) + 1
|
|
359
|
+
if np.prod(dims) > 4 * _CELL_BUDGET:
|
|
360
|
+
return None
|
|
361
|
+
grid = np.full(tuple(dims), np.nan, np.float32)
|
|
362
|
+
grid[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = vals
|
|
363
|
+
hole = np.isnan(grid)
|
|
364
|
+
if hole.any():
|
|
365
|
+
idx = ndimage.distance_transform_edt(hole, return_distances=False, return_indices=True)
|
|
366
|
+
grid = grid[tuple(idx)]
|
|
367
|
+
return grid, lo, pitch
|
|
368
|
+
|
|
369
|
+
def __call__(self, P):
|
|
370
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
371
|
+
if self.tree is None:
|
|
372
|
+
idx = ((P - self.origin) / self.pitch).T
|
|
373
|
+
return ndimage.map_coordinates(self.grid, idx, order=1, mode="nearest").astype(float)
|
|
374
|
+
k = min(8, len(self.vals))
|
|
375
|
+
d, j = self.tree.query(P, k=k)
|
|
376
|
+
d, j = d.reshape(len(P), k), j.reshape(len(P), k)
|
|
377
|
+
w = 1.0 / np.maximum(d, 1e-9) ** 2
|
|
378
|
+
return (w * self.vals[j]).sum(axis=1) / w.sum(axis=1)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---- TPMS with field-driven cell and wall ---------------------------------------
|
|
382
|
+
|
|
383
|
+
_TPMS_KIND = {"gyroid": "gyroid", "diamond": "diamond", "schwarz-p": "schwarz-p", "schwarz_p": "schwarz-p",
|
|
384
|
+
"schwarzP": "schwarz-p"}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _frame_points(P, frame):
|
|
388
|
+
"""Lattice coordinates: cartesian (P itself), or cylindrical, which wraps
|
|
389
|
+
the lattice round an axis so cells follow a tube or a ring (r, arc length
|
|
390
|
+
at `radius`, height). The cell count round the circumference is rounded
|
|
391
|
+
to a whole number so the lattice closes on itself."""
|
|
392
|
+
if not frame or frame.get("type", "cartesian") == "cartesian":
|
|
393
|
+
return P, None
|
|
394
|
+
if frame["type"] != "cylindrical":
|
|
395
|
+
raise ValueError("lattice frame type must be 'cartesian', 'cylindrical', 'spherical' or 'conformal'")
|
|
396
|
+
c = np.asarray(frame.get("center", (0, 0, 0)), float)
|
|
397
|
+
ax = frame.get("axis", "z")
|
|
398
|
+
a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax, float)
|
|
399
|
+
a = a / np.linalg.norm(a)
|
|
400
|
+
e1 = np.cross(a, (1.0, 0, 0) if abs(a[0]) < 0.9 else (0, 1.0, 0))
|
|
401
|
+
e1 /= np.linalg.norm(e1)
|
|
402
|
+
e2 = np.cross(a, e1)
|
|
403
|
+
Q = P - c
|
|
404
|
+
h = Q @ a
|
|
405
|
+
u, v = Q @ e1, Q @ e2
|
|
406
|
+
r = np.hypot(u, v)
|
|
407
|
+
th = np.arctan2(v, u)
|
|
408
|
+
return np.stack([r, th, h], axis=1), float(frame.get("radius", 0) or 0)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def lattice_orientation(nd):
|
|
412
|
+
"""A lattice node's `orient` as a rotation matrix (columns: the cell's
|
|
413
|
+
axes in global components), or None. `orient` is a rotation vector in
|
|
414
|
+
degrees ([rx, ry, rz], its direction the axis, its length the angle) or
|
|
415
|
+
{"axis": [x, y, z] or "x"/"y"/"z", "degrees": a}. It turns the cells
|
|
416
|
+
about the lattice's origin and leaves its fields (thickness, density,
|
|
417
|
+
cell) where they are, unlike a transform node, which moves those too."""
|
|
418
|
+
o = nd.spec.get("orient")
|
|
419
|
+
if o is None:
|
|
420
|
+
return None
|
|
421
|
+
if isinstance(o, dict) and "zones" in o:
|
|
422
|
+
raise ValueError(f"node {nd.name!r}: a zoned orient has no one set of lattice coordinates "
|
|
423
|
+
"(lattice 3MF and beam export take a single orient)")
|
|
424
|
+
from scipy.spatial.transform import Rotation
|
|
425
|
+
|
|
426
|
+
if isinstance(o, dict):
|
|
427
|
+
ax = o.get("axis", "z")
|
|
428
|
+
ax = {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax
|
|
429
|
+
ax = np.asarray(ax, float)
|
|
430
|
+
n = np.linalg.norm(ax)
|
|
431
|
+
if n == 0:
|
|
432
|
+
raise ValueError(f"node {nd.name!r}: orient axis is zero")
|
|
433
|
+
rv = ax / n * np.radians(float(o.get("degrees", 0.0)))
|
|
434
|
+
else:
|
|
435
|
+
rv = np.radians(np.asarray(o, float))
|
|
436
|
+
if rv.shape != (3,):
|
|
437
|
+
raise ValueError(f"node {nd.name!r}: orient is a rotation vector [rx, ry, rz] in degrees")
|
|
438
|
+
frame = (nd.spec.get("frame") or {}).get("type", "cartesian")
|
|
439
|
+
if frame != "cartesian":
|
|
440
|
+
raise ValueError(f"node {nd.name!r}: orient turns a cartesian lattice; a {frame} frame sets its own axes")
|
|
441
|
+
if isinstance(nd.refs.get("cell"), str) and nd.spec.get("grading", "warp") == "warp":
|
|
442
|
+
raise ValueError(f"node {nd.name!r}: orient with a graded (warp) cell is not supported; "
|
|
443
|
+
"grade the thickness or density instead")
|
|
444
|
+
if not np.any(rv):
|
|
445
|
+
return None
|
|
446
|
+
return Rotation.from_rotvec(rv).as_matrix()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def zone_rotations(nd):
|
|
450
|
+
"""(k, 3, 3) each orient zone's rotation (columns: the cell's axes)."""
|
|
451
|
+
from scipy.spatial.transform import Rotation
|
|
452
|
+
|
|
453
|
+
zones = nd.spec["orient"]["zones"]
|
|
454
|
+
return np.stack([Rotation.from_rotvec(np.radians(np.asarray(z.get("rotate", (0, 0, 0)), float))).as_matrix()
|
|
455
|
+
for z in zones])
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def orient_weights(nd, P):
|
|
459
|
+
"""(n, k) blend weights of a zoned orient's zones at P, summing to 1: a
|
|
460
|
+
soft Voronoi split between the zones' `at` points, each zone's weight
|
|
461
|
+
falling as a Gaussian of how much farther its point is than the
|
|
462
|
+
nearest one, over `blend` mm (default one cell). Far from a boundary
|
|
463
|
+
one zone has all of it; across one the lattices cross-fade over about
|
|
464
|
+
`blend`."""
|
|
465
|
+
o = nd.spec["orient"]
|
|
466
|
+
zones = o["zones"]
|
|
467
|
+
if not zones:
|
|
468
|
+
raise ValueError(f"node {nd.name!r}: orient.zones is empty")
|
|
469
|
+
for z in zones:
|
|
470
|
+
if "at" not in z:
|
|
471
|
+
raise ValueError(f"node {nd.name!r}: every orient zone needs an 'at' point")
|
|
472
|
+
if (nd.spec.get("frame") or {}).get("type", "cartesian") != "cartesian":
|
|
473
|
+
raise ValueError(f"node {nd.name!r}: orient turns a cartesian lattice")
|
|
474
|
+
if isinstance(nd.refs.get("cell"), str) and nd.spec.get("grading", "warp") == "warp":
|
|
475
|
+
raise ValueError(f"node {nd.name!r}: orient with a graded (warp) cell is not supported")
|
|
476
|
+
at = np.array([z["at"] for z in zones], float)
|
|
477
|
+
if o.get("seam"):
|
|
478
|
+
# seam plates: each zone's cells run to the plate between it and the
|
|
479
|
+
# next zone, a hard split (the nearest zone's point)
|
|
480
|
+
d = ((np.asarray(P, float)[:, None, :] - at[None]) ** 2).sum(-1)
|
|
481
|
+
return np.eye(len(at))[np.argmin(d, axis=1)]
|
|
482
|
+
cell = nd.spec.get("cell", 8.0)
|
|
483
|
+
blend = float(o.get("blend", cell if isinstance(cell, (int, float)) else 8.0))
|
|
484
|
+
return zone_weights(at, blend, P)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def seam_distance(at, P):
|
|
488
|
+
"""Unsigned mm distance from P to the Voronoi face between its nearest
|
|
489
|
+
zone point and the next nearest (the bisector plane of the two)."""
|
|
490
|
+
at = np.asarray(at, float)
|
|
491
|
+
d2 = ((np.asarray(P, float)[:, None, :] - at[None]) ** 2).sum(-1)
|
|
492
|
+
if len(at) < 2:
|
|
493
|
+
return np.full(len(P), np.inf)
|
|
494
|
+
o = np.argsort(d2, axis=1)[:, :2]
|
|
495
|
+
i = np.arange(len(P))
|
|
496
|
+
gap = np.linalg.norm(at[o[:, 1]] - at[o[:, 0]], axis=1)
|
|
497
|
+
return (d2[i, o[:, 1]] - d2[i, o[:, 0]]) / np.maximum(2.0 * gap, 1e-12)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def zone_weights(at, blend, P):
|
|
501
|
+
"""orient_weights for zone points `at` (k, 3) and `blend` mm."""
|
|
502
|
+
P = np.asarray(P, float)
|
|
503
|
+
d = np.sqrt(((P[:, None, :] - np.asarray(at, float)[None]) ** 2).sum(-1))
|
|
504
|
+
x = (d - d.min(axis=1, keepdims=True)) / max(0.5 * blend, 1e-9)
|
|
505
|
+
w = np.exp(-x * x)
|
|
506
|
+
return w / w.sum(axis=1, keepdims=True)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _tpms_distance(kind, P, cell, frame=None):
|
|
510
|
+
"""Signed mm distance to the TPMS midsurface with period `cell` (a scalar
|
|
511
|
+
or a per-point array). This is the `grading: "blend"` path for a varying
|
|
512
|
+
cell: the distance fields of a ladder of fixed periods (at most 1.25x
|
|
513
|
+
apart) cross-faded with a smoothstep. Exact inside a level; across a step
|
|
514
|
+
the two lattices disagree and leave crumbs, which is why the default is
|
|
515
|
+
the warp (_CellWarp)."""
|
|
516
|
+
from .lattice import tpms
|
|
517
|
+
|
|
518
|
+
kind = _TPMS_KIND.get(kind)
|
|
519
|
+
if kind is None:
|
|
520
|
+
raise ValueError(f"lattice type must be one of gyroid, diamond, schwarz-p")
|
|
521
|
+
Q, radius = _frame_points(P, frame)
|
|
522
|
+
cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
|
|
523
|
+
if np.any(cell <= 0):
|
|
524
|
+
raise ValueError("lattice cell must be positive everywhere")
|
|
525
|
+
|
|
526
|
+
def at(c, idx):
|
|
527
|
+
q = Q[idx]
|
|
528
|
+
if radius is None:
|
|
529
|
+
return tpms(kind, q, c)
|
|
530
|
+
# cylindrical: arc length at the frame radius, whole cells round
|
|
531
|
+
R = radius or float(np.median(q[:, 0]))
|
|
532
|
+
n = max(1, round(2 * np.pi * R / c))
|
|
533
|
+
q = np.stack([q[:, 0], q[:, 1] * n * c / (2 * np.pi), q[:, 2]], axis=1)
|
|
534
|
+
return tpms(kind, q, c)
|
|
535
|
+
|
|
536
|
+
cmin, cmax = float(cell.min()), float(cell.max())
|
|
537
|
+
if cmax - cmin <= 1e-9 * cmax:
|
|
538
|
+
return at(cmin, slice(None))
|
|
539
|
+
ratio = 1.25
|
|
540
|
+
n = max(1, int(np.ceil(np.log(cmax / cmin) / np.log(ratio))))
|
|
541
|
+
ratio = (cmax / cmin) ** (1.0 / n)
|
|
542
|
+
s = np.clip(np.log(cell / cmin) / np.log(ratio), 0.0, n - 1e-9)
|
|
543
|
+
lev = np.floor(s).astype(int)
|
|
544
|
+
t = s - lev
|
|
545
|
+
t = t * t * (3 - 2 * t)
|
|
546
|
+
out = np.empty(len(P))
|
|
547
|
+
for k in np.unique(lev):
|
|
548
|
+
idx = np.nonzero(lev == k)[0]
|
|
549
|
+
lo_c, hi_c = cmin * ratio ** k, cmin * ratio ** (k + 1)
|
|
550
|
+
tk = t[idx]
|
|
551
|
+
d = at(lo_c, idx)
|
|
552
|
+
mix = tk > 1e-6
|
|
553
|
+
if mix.any():
|
|
554
|
+
d_hi = at(hi_c, idx[mix])
|
|
555
|
+
d[mix] = (1 - tk[mix]) * d[mix] + tk[mix] * d_hi
|
|
556
|
+
out[idx] = d
|
|
557
|
+
return out
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _lsq_potential(T, h):
|
|
561
|
+
"""The grid scalar u whose differences along every grid edge best match
|
|
562
|
+
the target gradient T (dims + (3,), per unit length) times the step h:
|
|
563
|
+
least squares, D^T D u = D^T t. D^T D is the grid graph's Laplacian with
|
|
564
|
+
free (Neumann) ends, which the DCT-II diagonalizes exactly, so the solve
|
|
565
|
+
is direct, O(n log n). Defined up to a constant (its mean is zero)."""
|
|
566
|
+
from scipy import fft
|
|
567
|
+
|
|
568
|
+
dims = T.shape[:3]
|
|
569
|
+
lam = sum((2 - 2 * np.cos(np.pi * np.arange(dims[a]) / dims[a])).reshape([-1 if i == a else 1
|
|
570
|
+
for i in range(3)])
|
|
571
|
+
for a in range(3))
|
|
572
|
+
lam[0, 0, 0] = 1.0
|
|
573
|
+
b = np.zeros(dims)
|
|
574
|
+
for a in range(3):
|
|
575
|
+
sl0 = [slice(None)] * 3
|
|
576
|
+
sl1 = [slice(None)] * 3
|
|
577
|
+
sl0[a] = slice(0, -1)
|
|
578
|
+
sl1[a] = slice(1, None)
|
|
579
|
+
te = 0.5 * (T[tuple(sl0) + (a,)] + T[tuple(sl1) + (a,)]) * h # target step along each edge
|
|
580
|
+
b[tuple(sl0)] -= te
|
|
581
|
+
b[tuple(sl1)] += te # D^T t
|
|
582
|
+
B = fft.dctn(b, type=2, norm="ortho") / lam
|
|
583
|
+
B[0, 0, 0] = 0.0
|
|
584
|
+
return fft.idctn(B, type=2, norm="ortho")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class _CellWarp:
|
|
588
|
+
"""Lattice coordinates u(x) for a cell size that varies in space: the
|
|
589
|
+
least-squares map whose gradient is I / cell(x) (a Poisson solve on a
|
|
590
|
+
grid over the lattice's domain, direct by DCT), so one period
|
|
591
|
+
of u is one local cell. In 1D that is exactly u = integral of dx / cell;
|
|
592
|
+
in 3D, where a cell field has curl the map cannot follow everywhere, the
|
|
593
|
+
misfit spreads out smoothly instead of tearing. Every wall stays
|
|
594
|
+
continuous across a grading, unlike cross-fading lattices of different
|
|
595
|
+
periods, which leaves crumbs where they disagree."""
|
|
596
|
+
|
|
597
|
+
def __init__(self, cell_fn, lo, hi, max_cells=400_000):
|
|
598
|
+
lo, h, dims = self.grid(cell_fn, lo, hi, max_cells)
|
|
599
|
+
axes = [lo[i] + np.arange(dims[i]) * h for i in range(3)]
|
|
600
|
+
G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
|
|
601
|
+
c = np.asarray(cell_fn(G), float)
|
|
602
|
+
if np.any(c <= 0):
|
|
603
|
+
raise ValueError("lattice cell must be positive everywhere")
|
|
604
|
+
g = (1.0 / c).reshape(tuple(dims))
|
|
605
|
+
n = int(np.prod(dims))
|
|
606
|
+
self.u = []
|
|
607
|
+
for comp in range(3):
|
|
608
|
+
T = np.zeros(tuple(dims) + (3,))
|
|
609
|
+
T[..., comp] = g
|
|
610
|
+
u = _lsq_potential(T, h).ravel()
|
|
611
|
+
# anchor: u = x / cell at the domain's centre point, so a constant
|
|
612
|
+
# cell gives back the plain lattice
|
|
613
|
+
self.u.append((u - u[n // 2]).reshape(tuple(dims)) + G[n // 2, comp] * g.ravel()[n // 2])
|
|
614
|
+
self.lo, self.h, self.dims = lo, h, dims
|
|
615
|
+
|
|
616
|
+
def _grids(self):
|
|
617
|
+
if getattr(self, "_c", None) is None:
|
|
618
|
+
self._c = tuple(np.ascontiguousarray(u, np.float64) for u in self.u)
|
|
619
|
+
return self._c
|
|
620
|
+
|
|
621
|
+
@staticmethod
|
|
622
|
+
def grid(cell_fn, lo, hi, max_cells):
|
|
623
|
+
"""(lo, h, dims) of the grid the map is solved on."""
|
|
624
|
+
lo = np.asarray(lo, float)
|
|
625
|
+
hi = np.asarray(hi, float)
|
|
626
|
+
ext = np.maximum(hi - lo, 1e-6)
|
|
627
|
+
h = float((np.prod(ext) / max_cells) ** (1 / 3))
|
|
628
|
+
# a few grid steps per cell at least, where the grid budget allows
|
|
629
|
+
probe = np.stack(np.meshgrid(*[np.linspace(lo[i], hi[i], 12) for i in range(3)], indexing="ij"),
|
|
630
|
+
-1).reshape(-1, 3)
|
|
631
|
+
h = max(h, 0.0) if h > 0 else 1.0
|
|
632
|
+
h = min(max(h, float(np.min(cell_fn(probe))) / 6.0), max(h, 1e-3))
|
|
633
|
+
lo = lo - 2 * h
|
|
634
|
+
dims = np.ceil((hi + 2 * h - lo) / h).astype(int) + 1
|
|
635
|
+
return lo, h, dims
|
|
636
|
+
|
|
637
|
+
def anchor(self):
|
|
638
|
+
"""The point where u = x / cell: the grid's centre node."""
|
|
639
|
+
n = int(np.prod(self.dims))
|
|
640
|
+
ijk = np.asarray(np.unravel_index(n // 2, tuple(self.dims)))
|
|
641
|
+
return self.lo + ijk * self.h
|
|
642
|
+
|
|
643
|
+
def __call__(self, P, cell):
|
|
644
|
+
from . import fieldops
|
|
645
|
+
|
|
646
|
+
if fieldops.available() and len(P):
|
|
647
|
+
return fieldops.warp(P, self.lo, self.h, self._grids(), cell)
|
|
648
|
+
idx = ((P - self.lo) / self.h).T
|
|
649
|
+
n = (self.dims - 1)[:, None]
|
|
650
|
+
cl = np.clip(idx, 0, n)
|
|
651
|
+
out = np.stack([ndimage.map_coordinates(u, cl, order=1, mode="nearest") for u in self.u], axis=1)
|
|
652
|
+
# past the grid: carry on at the local rate
|
|
653
|
+
return out + (idx - cl).T * self.h / np.broadcast_to(np.asarray(cell, float), (len(P),))[:, None]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class _ConformalMap:
|
|
657
|
+
"""Lattice coordinates that follow a surface (nTop's conformal lattice):
|
|
658
|
+
u3 counts cell layers in from the surface (its signed distance over the
|
|
659
|
+
through-thickness cell, exact, so every layer is parallel to the skin
|
|
660
|
+
and the first one sits on it), and (u1, u2) run along the surface. The
|
|
661
|
+
tangent directions are a reference axis projected onto the tangent
|
|
662
|
+
plane and its cross with the normal (where the normal turns towards the
|
|
663
|
+
reference, a second axis takes over smoothly). Round a closed surface (a
|
|
664
|
+
tube, a boss, the section of a link) the tangent field circulates and no
|
|
665
|
+
coordinate follows it, so (u1, u2) are phases, defined modulo one cell,
|
|
666
|
+
which is all a periodic cell reads: stripe patterns (_stripe_phase). Where
|
|
667
|
+
a whole number of cells cannot fit round a loop the pattern puts a phase
|
|
668
|
+
defect deep inside, not a shear in every cell at the skin.
|
|
669
|
+
|
|
670
|
+
`surface_fn(P)` is the surface's signed distance, `cell_fn(P)` the
|
|
671
|
+
in-plane cell (mm, a field grades it), `normal_cell` the cell through
|
|
672
|
+
the thickness (mm; default the cell)."""
|
|
673
|
+
|
|
674
|
+
def __init__(self, surface_fn, cell_fn, lo, hi, axis=None, normal_cell=None, max_cells=40_000,
|
|
675
|
+
layer_smoothing=0.0, frame_field=True):
|
|
676
|
+
# a phase needs only a few grid steps a cell (_phase_at unwraps
|
|
677
|
+
# between nodes); the solve is a sparse LU, so the grid stays small
|
|
678
|
+
lo = np.asarray(lo, float)
|
|
679
|
+
hi = np.asarray(hi, float)
|
|
680
|
+
probe = np.stack(np.meshgrid(*[np.linspace(lo[i], hi[i], 12) for i in range(3)], indexing="ij"),
|
|
681
|
+
-1).reshape(-1, 3)
|
|
682
|
+
h = max(float(np.min(cell_fn(probe))) / 3.5, float((np.prod(hi - lo + 4.0) / max_cells) ** (1 / 3)))
|
|
683
|
+
ext = hi - lo
|
|
684
|
+
lo = lo - 2 * h
|
|
685
|
+
dims = np.ceil((hi + 2 * h - lo) / h).astype(int) + 1
|
|
686
|
+
axes = [lo[i] + np.arange(dims[i]) * h for i in range(3)]
|
|
687
|
+
G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
|
|
688
|
+
c = np.asarray(cell_fn(G), float).reshape(tuple(dims))
|
|
689
|
+
if np.any(c <= 0):
|
|
690
|
+
raise ValueError("lattice cell must be positive everywhere")
|
|
691
|
+
d = np.asarray(surface_fn(G), float).reshape(tuple(dims))
|
|
692
|
+
n = np.stack(np.gradient(d, h), -1)
|
|
693
|
+
n /= np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-9)
|
|
694
|
+
if axis is None:
|
|
695
|
+
a = np.eye(3)[int(np.argmax(ext))]
|
|
696
|
+
b = np.eye(3)[int(np.argsort(ext)[1])]
|
|
697
|
+
if frame_field:
|
|
698
|
+
# the part's own axes (the normals' principal directions),
|
|
699
|
+
# the ones nearest the box's: a box turned on the bed still
|
|
700
|
+
# gets cells square to its faces on every side
|
|
701
|
+
gm = np.linalg.norm(np.stack(np.gradient(d, h), -1), axis=-1)
|
|
702
|
+
on = (np.abs(d) < h) & (gm > 0.95)
|
|
703
|
+
if on.sum() > 10:
|
|
704
|
+
N = np.einsum("ni,nj->ij", n[on], n[on])
|
|
705
|
+
E = np.linalg.eigh(N)[1]
|
|
706
|
+
ia = int(np.argmax(np.abs(E.T @ a)))
|
|
707
|
+
a = E[:, ia] * np.sign(E[:, ia] @ a)
|
|
708
|
+
rest = [k for k in range(3) if k != ia]
|
|
709
|
+
ib = rest[int(np.argmax(np.abs(E[:, rest].T @ b)))]
|
|
710
|
+
b = E[:, ib] * np.sign(E[:, ib] @ b or 1.0)
|
|
711
|
+
else:
|
|
712
|
+
a = np.asarray(_axis_vector(axis), float)
|
|
713
|
+
b = np.eye(3)[int(np.argmin(np.abs(a)))]
|
|
714
|
+
b = b - (b @ a) * a
|
|
715
|
+
b /= np.linalg.norm(b)
|
|
716
|
+
w = np.abs(n @ a)[..., None]
|
|
717
|
+
sw = np.clip((w - 0.8) / 0.15, 0, 1)
|
|
718
|
+
sw = sw * sw * (3 - 2 * sw)
|
|
719
|
+
ref = (1 - sw) * a + sw * b
|
|
720
|
+
t1 = ref - (ref * n).sum(-1, keepdims=True) * n
|
|
721
|
+
t1 /= np.maximum(np.linalg.norm(t1, axis=-1, keepdims=True), 1e-9)
|
|
722
|
+
t2 = np.cross(n, t1)
|
|
723
|
+
# what matters is the cells at and just under the skin: weight
|
|
724
|
+
# there, so a cell count that has to change with depth (fewer cells
|
|
725
|
+
# round a smaller circle) changes deep inside, and the box outside
|
|
726
|
+
# the solid barely counts
|
|
727
|
+
depth = np.maximum(-d, 0.0) / c
|
|
728
|
+
wgt = np.where(d > 0, np.exp(-(d / h) ** 2) + 1e-3, np.exp(-depth / 1.5))
|
|
729
|
+
self.frame_field = bool(frame_field)
|
|
730
|
+
self.lo_grid = lo
|
|
731
|
+
if self.frame_field:
|
|
732
|
+
self.phase = self._frame_field_phases(d, n, t1, t2, a, c, h, wgt, normal_cell)
|
|
733
|
+
else:
|
|
734
|
+
self.phase = [_PhaseGrid(*_quiet_fill(*_stripe_phase(t / c[..., None], h, weight=wgt, amplitude=True)),
|
|
735
|
+
lo, h) for t in (t1, t2)]
|
|
736
|
+
self.lo, self.h, self.dims = lo, h, dims
|
|
737
|
+
self.normal_cell = normal_cell
|
|
738
|
+
self.layer_smoothing = float(layer_smoothing)
|
|
739
|
+
self.surface_fn = surface_fn
|
|
740
|
+
|
|
741
|
+
def __call__(self, P, cell, surface=None, coherence=False):
|
|
742
|
+
"""(N, 3) coordinates, period 1 a cell (the two tangent ones are
|
|
743
|
+
phases, modulo 1). `surface` is the surface's distance at P when the
|
|
744
|
+
caller has it already. With `coherence`, also the lesser of the two
|
|
745
|
+
phases' coherence (_PhaseGrid), low round a defect."""
|
|
746
|
+
cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
|
|
747
|
+
both = [phi(P, coherence=True) for phi in self.phase]
|
|
748
|
+
if self.frame_field:
|
|
749
|
+
U = np.stack([b[0] for b in both], axis=1)
|
|
750
|
+
if coherence:
|
|
751
|
+
return U, np.min([b[1] for b in both], axis=0)
|
|
752
|
+
return U
|
|
753
|
+
dist = np.asarray(self.surface_fn(P) if surface is None else surface, float)
|
|
754
|
+
dist = self._rounded_layers(P, dist, cell)
|
|
755
|
+
cn = cell if self.normal_cell is None else self.normal_cell
|
|
756
|
+
U = np.stack([b[0] for b in both] + [-dist / cn], axis=1)
|
|
757
|
+
if coherence:
|
|
758
|
+
return U, np.minimum(both[0][1], both[1][1])
|
|
759
|
+
return U
|
|
760
|
+
|
|
761
|
+
def _frame_field_phases(self, d, n, t1, t2, a, c, h, wgt, normal_cell):
|
|
762
|
+
"""Three phases that follow a 3D frame field, so the cells turn a
|
|
763
|
+
box's edges and corners. The two-stripe frame takes its layer
|
|
764
|
+
coordinate from the surface's distance, which is a different axis on
|
|
765
|
+
each face of a box: across an edge, the tangent that follows the
|
|
766
|
+
edge on one face is the layer direction on the next, so along every
|
|
767
|
+
edge and miter two of the three coordinates run parallel and the
|
|
768
|
+
cells shear (over 40% of a 12 mm box's cells; 6.7% of its open void
|
|
769
|
+
patches folded). Here every grid node carries an orthonormal frame,
|
|
770
|
+
defined up to the cube's symmetries (which axis is which, and their
|
|
771
|
+
signs): the surface normal (of the nearest skin point) and the
|
|
772
|
+
smoothest cross field on the layers (_cross_field). The frames are
|
|
773
|
+
labelled consistently by one synchronization solve
|
|
774
|
+
(_label_frames), so on a box the x faces' layer direction and the y
|
|
775
|
+
faces' along-edge direction are the same label, and both are just
|
|
776
|
+
the box's x. Each label gets a stripe pattern (_stripe_phase) pinned
|
|
777
|
+
near the skin to the distance over the cell where that label is the
|
|
778
|
+
normal (a layer of nodes on every face) and following the frame
|
|
779
|
+
elsewhere: on a box, the box's own straight cells, a whole number
|
|
780
|
+
between opposite faces."""
|
|
781
|
+
th = _cross_field(n, t1, t2, np.exp(-(d / c) ** 2))
|
|
782
|
+
c4, s4 = np.cos(th)[..., None], np.sin(th)[..., None]
|
|
783
|
+
t1, t2 = c4 * t1 + s4 * t2, c4 * t2 - s4 * t1
|
|
784
|
+
F = np.stack([n, t1, t2], -1) # columns: the frame's axes
|
|
785
|
+
gn = np.linalg.norm(np.stack(np.gradient(d, h), -1), axis=-1)
|
|
786
|
+
# where the distance's normal is muddled (its medial axis: a box's
|
|
787
|
+
# miters, the middle of a tube, where the two faces' normals average
|
|
788
|
+
# to a diagonal) the frame of the nearest clean node; outside the
|
|
789
|
+
# solid the nearest one inside, so an open lattice runs on straight
|
|
790
|
+
# past the faces instead of turning round the rounded distance
|
|
791
|
+
# beyond an edge
|
|
792
|
+
clean = (gn > 0.95) & (d < 0.05 * h)
|
|
793
|
+
if clean.any() and not clean.all():
|
|
794
|
+
idx = ndimage.distance_transform_edt(~clean, return_distances=False, return_indices=True)
|
|
795
|
+
F = F[tuple(idx)]
|
|
796
|
+
A = _label_frames(F, weight=np.exp(-(d / c) ** 2))
|
|
797
|
+
# order the labels: the layer direction last, the one along `a` first
|
|
798
|
+
band = np.exp(-(np.maximum(-d, 0) / c) ** 2) * (d < 0.5 * h)
|
|
799
|
+
al = np.abs(np.einsum("...ik,...i->...k", A, n))
|
|
800
|
+
layer = int(np.argmax((al * band[..., None]).reshape(-1, 3).sum(0)))
|
|
801
|
+
rest = [k for k in range(3) if k != layer]
|
|
802
|
+
along = np.abs(np.einsum("...ik,i->...k", A, a)).reshape(-1, 3).sum(0)
|
|
803
|
+
rest.sort(key=lambda k: -along[k])
|
|
804
|
+
A = A[..., rest + [layer]]
|
|
805
|
+
self.frames = A
|
|
806
|
+
out = []
|
|
807
|
+
for k in range(3):
|
|
808
|
+
ak = A[..., k]
|
|
809
|
+
s_k = np.einsum("...i,...i->...", ak, n)
|
|
810
|
+
ck = c if normal_cell is None else c + (float(normal_cell) - c) * s_k ** 2
|
|
811
|
+
# pin where this label is the normal: within about a cell of the
|
|
812
|
+
# skin (and on outside, where an open lattice runs past the
|
|
813
|
+
# faces), to phase = +-distance / cell, zero on the skin
|
|
814
|
+
q = np.clip((np.abs(s_k) - 0.85) / 0.1, 0, 1)
|
|
815
|
+
q = q * q * (3 - 2 * q) * (gn > 0.95)
|
|
816
|
+
dep = np.where(d < 0, -d / ck, 0.5 * d / ck)
|
|
817
|
+
alpha = q * np.clip((1.25 - dep) / 0.5, 0, 1)
|
|
818
|
+
target = np.sign(s_k) * d / ck
|
|
819
|
+
T = ak / ck[..., None]
|
|
820
|
+
out.append(_PhaseGrid(*_quiet_fill(*_stripe_phase(T, h, weight=wgt, amplitude=True,
|
|
821
|
+
anchor=(alpha, target))), self.lo_grid, h))
|
|
822
|
+
return out
|
|
823
|
+
|
|
824
|
+
def skin_shear(self, cell_fn, n=6000, seed=0):
|
|
825
|
+
"""The fraction of sheared cells (smallest over largest singular
|
|
826
|
+
value of the coordinates' Jacobian, in cells, under 0.3) in the first
|
|
827
|
+
cell under the skin: what the frame is judged by."""
|
|
828
|
+
hi = self.lo + (self.dims - 1) * self.h
|
|
829
|
+
rng = np.random.default_rng(seed)
|
|
830
|
+
got = []
|
|
831
|
+
for _ in range(8):
|
|
832
|
+
P = rng.uniform(self.lo, hi, (8 * n, 3))
|
|
833
|
+
c = np.asarray(cell_fn(P), float)
|
|
834
|
+
d = np.asarray(self.surface_fn(P), float)
|
|
835
|
+
got.append(P[(d < 0) & (d > -c)])
|
|
836
|
+
if sum(len(x) for x in got) >= n:
|
|
837
|
+
break
|
|
838
|
+
P = np.concatenate(got)[:n]
|
|
839
|
+
if not len(P):
|
|
840
|
+
return 0.0
|
|
841
|
+
c = np.asarray(cell_fn(P), float)
|
|
842
|
+
e = 1e-3
|
|
843
|
+
J = np.stack([(self(P + e * v, c) - self(P - e * v, c)) / (2 * e) for v in np.eye(3)], 2) * c[:, None, None]
|
|
844
|
+
sv = np.linalg.svd(J, compute_uv=False)
|
|
845
|
+
return float((sv[:, 2] / np.maximum(sv[:, 0], 1e-12) < 0.3).mean())
|
|
846
|
+
|
|
847
|
+
# a symmetric stencil (6 axis + 8 diagonal unit directions): its mean
|
|
848
|
+
# of a linear function is the centre value, so it moves nothing where
|
|
849
|
+
# the distance is linear
|
|
850
|
+
_STENCIL = np.concatenate([np.vstack([np.eye(3), -np.eye(3)]),
|
|
851
|
+
np.array([[a, b, c] for a in (-1, 1) for b in (-1, 1) for c in (-1, 1)]) / np.sqrt(3)])
|
|
852
|
+
_STENCIL_W = np.r_[np.full(6, 1 / 15), np.full(8, 9 / 120)]
|
|
853
|
+
|
|
854
|
+
def _rounded_layers(self, P, dist, cell):
|
|
855
|
+
"""The surface distance with its folds rounded: the exact distance
|
|
856
|
+
kinks on the surface's medial axis (two faces of a box, the far
|
|
857
|
+
sides of a link's section), and a strut there meets its own mirror
|
|
858
|
+
image; near a corner the touch is tangential, so the part's void
|
|
859
|
+
surface pinches (a non-manifold edge no grid parts) and its STEP
|
|
860
|
+
export fails. The distance is replaced by its mean over a sphere of
|
|
861
|
+
radius `layer_smoothing` x depth (capped at a cell): exact at the
|
|
862
|
+
skin (radius 0) and wherever the distance is linear (one face in
|
|
863
|
+
reach), rounded where faces meet, so layers deeper in follow a
|
|
864
|
+
filleted offset of the surface instead of its sharp medial axis.
|
|
865
|
+
Opt in (frame "layerSmoothing"): it costs 14 more surface
|
|
866
|
+
evaluations a point, and the pinches it was meant for are parted by
|
|
867
|
+
the mesher's pinch splitter (ibrep.split_pinches) anyway."""
|
|
868
|
+
beta = self.layer_smoothing
|
|
869
|
+
if beta <= 0:
|
|
870
|
+
return dist
|
|
871
|
+
r = np.minimum(beta * np.maximum(-dist, 0.0), np.asarray(cell, float))
|
|
872
|
+
on = np.flatnonzero(r > 1e-6)
|
|
873
|
+
if not len(on):
|
|
874
|
+
return dist
|
|
875
|
+
S = self._STENCIL
|
|
876
|
+
Q = (P[on, None, :] + r[on, None, None] * S[None]).reshape(-1, 3)
|
|
877
|
+
avg = (np.asarray(self.surface_fn(Q), float).reshape(len(on), len(S)) * self._STENCIL_W).sum(1)
|
|
878
|
+
out = dist.copy()
|
|
879
|
+
out[on] = avg
|
|
880
|
+
return out
|
|
881
|
+
|
|
882
|
+
@staticmethod
|
|
883
|
+
def defect_fill(coh, lo=0.1, hi=0.6):
|
|
884
|
+
"""1 where the cells are regular, falling to 0 round a phase defect:
|
|
885
|
+
there the cells shrink to nothing (the phase winds round the defect
|
|
886
|
+
line), so strut and sheet distances pile into a knot of sub-mm
|
|
887
|
+
features that neither the mesher nor STEP patches resolve. The
|
|
888
|
+
lattice's unit distance is scaled by this, so a defect prints as a
|
|
889
|
+
small solid node, continuously, instead.
|
|
890
|
+
|
|
891
|
+
The ramp reaches out to coherence 0.6: with it ending at 0.35, 70%
|
|
892
|
+
of the folded void patches of an open 12 mm conformal octet block
|
|
893
|
+
(cell 6) sat round defects (69% of patches folded below 0.35, 38%
|
|
894
|
+
at 0.35-0.6, 3.5% above 0.9, about what straight cells fold), and
|
|
895
|
+
its STEP failed where they met the faces. 0.1-0.6 halves the folds
|
|
896
|
+
(12% -> 6.7% on a corner of the block) for 7% more lattice mass on
|
|
897
|
+
that block, where defects fill much of the volume (a 2-cell box);
|
|
898
|
+
0.3-0.7 folds 5.9% for 17%, 0.5-0.9 6.6% for 46%."""
|
|
899
|
+
q = np.clip((coh - lo) / (hi - lo), 0.0, 1.0)
|
|
900
|
+
return q * q * (3 - 2 * q)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _stripe_phase(T, h, weight=None, amplitude=False, anchor=None):
|
|
904
|
+
"""The phase (cycles, 0-1, on the grid) that advances by T . e h along
|
|
905
|
+
every grid edge as nearly as it can: Knoeppel et al.'s stripe patterns,
|
|
906
|
+
psi = exp(2 pi i phase) the lowest eigenvector of the connection
|
|
907
|
+
Laplacian sum |psi_j - exp(2 pi i w_ij) psi_i|^2. Where the target
|
|
908
|
+
cannot be followed (a whole number of cells has to fit round a loop)
|
|
909
|
+
the pattern puts a phase defect, a line where |psi| -> 0, instead of
|
|
910
|
+
shearing every cell. `weight` (dims) says where following the target
|
|
911
|
+
matters (each edge counts the mean of its ends'): a cell count that has
|
|
912
|
+
to change with depth is settled where the weight is low."""
|
|
913
|
+
import scipy.sparse as sp
|
|
914
|
+
from scipy.sparse.linalg import eigsh
|
|
915
|
+
|
|
916
|
+
dims = T.shape[:3]
|
|
917
|
+
n = int(np.prod(dims))
|
|
918
|
+
ids = np.arange(n).reshape(dims)
|
|
919
|
+
I, J, W, K = [], [], [], []
|
|
920
|
+
wgt = np.ones(dims) if weight is None else weight
|
|
921
|
+
for a in range(3):
|
|
922
|
+
sl0 = [slice(None)] * 3
|
|
923
|
+
sl1 = [slice(None)] * 3
|
|
924
|
+
sl0[a] = slice(0, -1)
|
|
925
|
+
sl1[a] = slice(1, None)
|
|
926
|
+
I.append(ids[tuple(sl0)].ravel())
|
|
927
|
+
J.append(ids[tuple(sl1)].ravel())
|
|
928
|
+
W.append((0.5 * (T[tuple(sl0) + (a,)] + T[tuple(sl1) + (a,)]) * h).ravel())
|
|
929
|
+
K.append((0.5 * (wgt[tuple(sl0)] + wgt[tuple(sl1)])).ravel())
|
|
930
|
+
I, J, W, K = np.concatenate(I), np.concatenate(J), np.concatenate(W), np.concatenate(K)
|
|
931
|
+
deg = np.bincount(I, K, minlength=n) + np.bincount(J, K, minlength=n)
|
|
932
|
+
rot = K * np.exp(2j * np.pi * W)
|
|
933
|
+
# psi_j ~ rot psi_i: L_jj += 1, L_ii += 1, L_ji -= rot, L_ij -= conj(rot)
|
|
934
|
+
L = sp.csr_matrix((np.concatenate([-rot, -np.conj(rot)]), (np.concatenate([J, I]), np.concatenate([I, J]))),
|
|
935
|
+
shape=(n, n)) + sp.diags(deg.astype(complex))
|
|
936
|
+
# shift-invert Lanczos on one sparse LU: the grid is a few steps a cell,
|
|
937
|
+
# so the factorization takes seconds
|
|
938
|
+
psi0 = np.exp(2j * np.pi * _lsq_potential(T, h).ravel())
|
|
939
|
+
alpha = None if anchor is None else np.asarray(anchor[0], float).ravel()
|
|
940
|
+
if alpha is not None and alpha.max() > 0.5:
|
|
941
|
+
# pinned (phase = target where alpha > 0): one linear solve,
|
|
942
|
+
# (L + mu A) psi = mu A exp(2 pi i target), which also fixes the
|
|
943
|
+
# pattern's free global shift
|
|
944
|
+
from scipy.sparse.linalg import splu
|
|
945
|
+
|
|
946
|
+
mu = 4.0 * float(deg.mean())
|
|
947
|
+
rhs = mu * alpha * np.exp(2j * np.pi * np.asarray(anchor[1], float).ravel())
|
|
948
|
+
vec = splu((L + sp.diags(mu * alpha + 1e-9 * float(deg.mean()))).tocsc()).solve(rhs)[:, None]
|
|
949
|
+
else:
|
|
950
|
+
_, vec = eigsh(L.tocsc(), k=1, sigma=-1e-6 * float(deg.mean()), which="LM", v0=psi0, tol=1e-8)
|
|
951
|
+
phase = (np.angle(vec[:, 0]) / (2 * np.pi) % 1.0).reshape(dims)
|
|
952
|
+
if not amplitude:
|
|
953
|
+
return phase
|
|
954
|
+
# how well the pattern follows the target at each node: the mean of
|
|
955
|
+
# its edges' misfits as phasors, 1 where every edge advances as
|
|
956
|
+
# asked, ~0.3 for noise (|psi| itself is no guide: the eigenvector
|
|
957
|
+
# concentrates in a few places and is round-off small elsewhere)
|
|
958
|
+
ph = phase.ravel()
|
|
959
|
+
e = np.exp(2j * np.pi * (ph[J] - ph[I] - W))
|
|
960
|
+
cnt = np.bincount(I, minlength=n) + np.bincount(J, minlength=n)
|
|
961
|
+
coh = np.abs(np.bincount(I, e.real, minlength=n) + np.bincount(J, e.real, minlength=n)
|
|
962
|
+
+ 1j * (np.bincount(I, e.imag, minlength=n) + np.bincount(J, e.imag, minlength=n)))
|
|
963
|
+
coh = (coh / np.maximum(cnt, 1)).reshape(dims)
|
|
964
|
+
return phase, coh, np.angle(psi0).reshape(dims) / (2 * np.pi) % 1.0
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _cross_field(n, t1, t2, weight=None):
|
|
968
|
+
"""The smoothest field of tangent crosses (four directions 90 degrees
|
|
969
|
+
apart in the plane normal to n at every node), as its angle from t1
|
|
970
|
+
towards t2: Knoeppel et al.'s globally optimal direction fields, the
|
|
971
|
+
lowest eigenvector of the connection Laplacian on z = exp(4 i angle),
|
|
972
|
+
each grid edge carrying the turn between its ends' tangent bases once
|
|
973
|
+
the first's normal is rotated onto the second's (so a cross runs over a
|
|
974
|
+
box's edge onto the next face as that face's own square). Singular
|
|
975
|
+
points (a tube's end face, a sphere) go where they cost least. Edges
|
|
976
|
+
between opposed normals (across a medial axis) count for nothing."""
|
|
977
|
+
import scipy.sparse as sp
|
|
978
|
+
from scipy.sparse.linalg import eigsh
|
|
979
|
+
|
|
980
|
+
dims = n.shape[:3]
|
|
981
|
+
nn = int(np.prod(dims))
|
|
982
|
+
ids = np.arange(nn).reshape(dims)
|
|
983
|
+
N, T1, T2 = n.reshape(-1, 3), t1.reshape(-1, 3), t2.reshape(-1, 3)
|
|
984
|
+
wg = np.ones(nn) if weight is None else np.asarray(weight, float).ravel()
|
|
985
|
+
# no node cut off (an isolated node would be the lowest mode)
|
|
986
|
+
wg = np.maximum(wg, 1e-6 * float(wg.max()))
|
|
987
|
+
I, J, R, K = [], [], [], []
|
|
988
|
+
for ax in range(3):
|
|
989
|
+
sl0 = [slice(None)] * 3
|
|
990
|
+
sl1 = [slice(None)] * 3
|
|
991
|
+
sl0[ax] = slice(0, -1)
|
|
992
|
+
sl1[ax] = slice(1, None)
|
|
993
|
+
i, j = ids[tuple(sl0)].ravel(), ids[tuple(sl1)].ravel()
|
|
994
|
+
ni, nj = N[i], N[j]
|
|
995
|
+
cs = np.clip((ni * nj).sum(1), -1.0, 1.0)
|
|
996
|
+
# Rodrigues: the least rotation taking ni to nj, applied to t1_i
|
|
997
|
+
k = np.cross(ni, nj)
|
|
998
|
+
v = T1[i]
|
|
999
|
+
kv = np.cross(k, v)
|
|
1000
|
+
rot = v + kv + np.cross(k, kv) / np.maximum(1.0 + cs, 1e-6)[:, None]
|
|
1001
|
+
ang = np.arctan2((rot * T2[j]).sum(1), (rot * T1[j]).sum(1))
|
|
1002
|
+
I.append(i)
|
|
1003
|
+
J.append(j)
|
|
1004
|
+
R.append(ang)
|
|
1005
|
+
K.append(np.sqrt(wg[i] * wg[j]) * np.clip(cs, 0.0, 1.0))
|
|
1006
|
+
I, J, R, K = np.concatenate(I), np.concatenate(J), np.concatenate(R), np.concatenate(K)
|
|
1007
|
+
deg = np.bincount(I, K, minlength=nn) + np.bincount(J, K, minlength=nn)
|
|
1008
|
+
rot = K * np.exp(4j * R)
|
|
1009
|
+
L = sp.csr_matrix((np.concatenate([-rot, -np.conj(rot)]), (np.concatenate([J, I]), np.concatenate([I, J]))),
|
|
1010
|
+
shape=(nn, nn)) + sp.diags(deg.astype(complex) + 1e-9 * float(deg.mean()))
|
|
1011
|
+
# against the nodes' weight (a mass matrix), so a node hardly tied to
|
|
1012
|
+
# the rest is no cheap mode of its own
|
|
1013
|
+
M = sp.diags(deg.astype(complex) + 1e-9 * float(deg.mean()))
|
|
1014
|
+
_, vec = eigsh(L.tocsc(), k=1, M=M.tocsc(), sigma=-1e-6, which="LM", v0=np.ones(nn, complex), tol=1e-8)
|
|
1015
|
+
z = vec[:, 0]
|
|
1016
|
+
# the eigenvector's global phase turns every cross alike: pick the one
|
|
1017
|
+
# nearest the reference bases (a box's own axes)
|
|
1018
|
+
z = z * np.exp(-1j * np.angle((wg * z / np.maximum(np.abs(z), 1e-300)).sum()))
|
|
1019
|
+
return (np.angle(z) / 4.0).reshape(dims)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
_PERMS = np.array([(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)])
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _match_frames(Fi, Fj):
|
|
1026
|
+
"""Fj's axes reordered and signed to match Fi's (both (..., 3, 3), axes
|
|
1027
|
+
as columns), the cube's symmetry that brings them closest, and how well
|
|
1028
|
+
they match (3 when the frames are the same up to that symmetry)."""
|
|
1029
|
+
M = np.einsum("...ik,...il->...kl", Fi, Fj) # M[k, l] = a_ik . a_jl
|
|
1030
|
+
sc = np.stack([np.abs(M[..., 0, p[0]]) + np.abs(M[..., 1, p[1]]) + np.abs(M[..., 2, p[2]]) for p in _PERMS], -1)
|
|
1031
|
+
best = np.argmax(sc, -1)
|
|
1032
|
+
perm = _PERMS[best] # (..., 3)
|
|
1033
|
+
sgn = np.sign(np.take_along_axis(M, perm[..., :, None], -1)[..., 0])
|
|
1034
|
+
sgn[sgn == 0] = 1.0
|
|
1035
|
+
G = np.take_along_axis(Fj, np.broadcast_to(perm[..., None, :], Fj.shape), -1) * sgn[..., None, :]
|
|
1036
|
+
return G, np.take_along_axis(sc, best[..., None], -1)[..., 0]
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _polar(M):
|
|
1040
|
+
"""The rotation (or reflection) nearest M: U V^T of its SVD."""
|
|
1041
|
+
U, _, Vt = np.linalg.svd(M)
|
|
1042
|
+
return U @ Vt
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _label_frames(F, weight=None):
|
|
1046
|
+
"""The frames (dims + (3, 3)) relabelled consistently: each one's axes
|
|
1047
|
+
reordered and signed (a symmetry of the cube, R_i) so neighbours match
|
|
1048
|
+
column to column. Choosing R_i is a synchronization problem: maximize
|
|
1049
|
+
sum_ij w_ij tr((F_i R_i)^T F_j R_j) (Singer's synchronization). Its
|
|
1050
|
+
spectral relaxation (the top three eigenvectors of the block matrix
|
|
1051
|
+
w_ij P_ij^T, P_ij the symmetry that best matches j's axes to i's, whose
|
|
1052
|
+
node blocks are R_i up to one global rotation) settles every node at
|
|
1053
|
+
once, rounded to the nearest symmetry. Round a singular line whose loop permutes the
|
|
1054
|
+
axes no labelling is consistent: the relaxation puts the mismatch where
|
|
1055
|
+
the weight is least (deep inside, away from the skin), unlike growing
|
|
1056
|
+
the labels over a tree, which leaves it wherever the fronts meet."""
|
|
1057
|
+
import scipy.sparse as sp
|
|
1058
|
+
from scipy.sparse.linalg import lobpcg
|
|
1059
|
+
|
|
1060
|
+
dims = F.shape[:3]
|
|
1061
|
+
nn = int(np.prod(dims))
|
|
1062
|
+
ids = np.arange(nn).reshape(dims)
|
|
1063
|
+
Ff = F.reshape(-1, 3, 3)
|
|
1064
|
+
wg = np.ones(nn) if weight is None else np.maximum(np.asarray(weight, float).ravel(), 1e-6)
|
|
1065
|
+
rows, cols, vals = [], [], []
|
|
1066
|
+
deg = np.zeros(nn)
|
|
1067
|
+
kk, ll = np.meshgrid(np.arange(3), np.arange(3), indexing="ij")
|
|
1068
|
+
for ax in range(3):
|
|
1069
|
+
sl0 = [slice(None)] * 3
|
|
1070
|
+
sl1 = [slice(None)] * 3
|
|
1071
|
+
sl0[ax] = slice(0, -1)
|
|
1072
|
+
sl1[ax] = slice(1, None)
|
|
1073
|
+
i, j = ids[tuple(sl0)].ravel(), ids[tuple(sl1)].ravel()
|
|
1074
|
+
w = np.sqrt(wg[i] * wg[j])
|
|
1075
|
+
# the block is the symmetry that matches j's axes to i's (not the
|
|
1076
|
+
# frames' own small turn, which the relaxation would undo by
|
|
1077
|
+
# labelling every frame by the nearest world axis)
|
|
1078
|
+
G, _ = _match_frames(Ff[i], Ff[j])
|
|
1079
|
+
B = w[:, None, None] * np.einsum("nik,nil->nkl", G, Ff[j]) # P^T, where G = F_j P
|
|
1080
|
+
r = (3 * i[:, None, None] + kk[None]).ravel()
|
|
1081
|
+
c_ = (3 * j[:, None, None] + ll[None]).ravel()
|
|
1082
|
+
rows += [r, c_]
|
|
1083
|
+
cols += [c_, r]
|
|
1084
|
+
vals += [B.ravel(), B.ravel()]
|
|
1085
|
+
np.add.at(deg, i, w)
|
|
1086
|
+
np.add.at(deg, j, w)
|
|
1087
|
+
S = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(3 * nn, 3 * nn))
|
|
1088
|
+
dm = np.repeat(1.0 / np.sqrt(np.maximum(deg, 1e-12)), 3)
|
|
1089
|
+
S = sp.diags(dm) @ S @ sp.diags(dm)
|
|
1090
|
+
# the top eigenvalue is threefold wherever the labels can agree (the
|
|
1091
|
+
# global rotation), which a single-vector Krylov solve finds only once:
|
|
1092
|
+
# a block solve (LOBPCG), started from the frames as they are labelled
|
|
1093
|
+
X0 = np.tile(np.eye(3), (nn, 1)) / dm[:, None]
|
|
1094
|
+
X0 = X0 + 1e-3 * np.random.default_rng(0).standard_normal(X0.shape) * float(np.abs(X0).mean())
|
|
1095
|
+
import warnings
|
|
1096
|
+
|
|
1097
|
+
with warnings.catch_warnings():
|
|
1098
|
+
# the rounding below needs the blocks to a few percent, not 1e-6
|
|
1099
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1100
|
+
_, V = lobpcg(S, X0, largest=True, tol=1e-4, maxiter=200)
|
|
1101
|
+
V = (V * dm[:, None]).reshape(nn, 3, 3) # node blocks ~ R_i Q
|
|
1102
|
+
# the global rotation Q: the best-weighted node keeps its labels
|
|
1103
|
+
Q = _polar(V[int(np.argmax(wg))])
|
|
1104
|
+
M = np.swapaxes(V @ Q.T, 1, 2) # rows: labels, columns: the frame's axes
|
|
1105
|
+
M /= np.maximum(np.linalg.norm(M, axis=(1, 2), keepdims=True), 1e-300)
|
|
1106
|
+
# each block rounded to the nearest signed permutation
|
|
1107
|
+
sc = np.stack([np.abs(M[:, 0, p[0]]) + np.abs(M[:, 1, p[1]]) + np.abs(M[:, 2, p[2]]) for p in _PERMS], -1)
|
|
1108
|
+
perm = _PERMS[np.argmax(sc, -1)]
|
|
1109
|
+
sgn = np.sign(np.take_along_axis(M, perm[:, :, None], -1)[..., 0])
|
|
1110
|
+
sgn[sgn == 0] = 1.0
|
|
1111
|
+
# R_i maps label k to the frame's axis perm[k] (sign sgn[k]): A_i = F_i R_i
|
|
1112
|
+
A = np.take_along_axis(Ff, np.broadcast_to(perm[:, None, :], Ff.shape), -1) * sgn[:, None, :]
|
|
1113
|
+
return A.reshape(dims + (3, 3))
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _quiet_fill(phase, amp, smooth, low=(0.3, 0.55)):
|
|
1117
|
+
"""The stripe pattern where it has amplitude, a smooth phase where it
|
|
1118
|
+
has none. The eigenvector lives where the tangent field can be followed
|
|
1119
|
+
(at the skin); where it cannot (deep inside, round the surface's medial
|
|
1120
|
+
axis) |psi| decays to round-off and its angle is noise: continuous, but
|
|
1121
|
+
cells a fraction of a millimetre across that neither the mesher nor the
|
|
1122
|
+
STEP patches resolve (the STL and STEP of a 12 mm conformal block
|
|
1123
|
+
differed by ~1 mm there). Below `low` the pattern's phasor is crossed
|
|
1124
|
+
smoothly into the least-squares potential's (smooth everywhere, no
|
|
1125
|
+
defects, sheared round loops), so the core holds whole, regular cells
|
|
1126
|
+
and the change-over is a few defects in the crossing shell. Returns
|
|
1127
|
+
(phase, amplitude) for _PhaseGrid."""
|
|
1128
|
+
s = np.clip((amp - low[0]) / (low[1] - low[0]), 0.0, 1.0)
|
|
1129
|
+
s = s * s * (3 - 2 * s)
|
|
1130
|
+
z = s * np.exp(2j * np.pi * phase) + (1 - s) * np.exp(2j * np.pi * smooth)
|
|
1131
|
+
return np.angle(z) / (2 * np.pi) % 1.0, np.clip(np.abs(z), 0.0, 1.0)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
class _PhaseGrid:
|
|
1135
|
+
"""A phase (cycles, mod 1) on a grid, read continuously everywhere but
|
|
1136
|
+
at the pattern's own defects. Unwrapping the eight corners of a voxel
|
|
1137
|
+
against one of them (_phase_at) is exact where the phase is nearly
|
|
1138
|
+
linear, but two voxels that share a face unwrap against different
|
|
1139
|
+
corners, so where the corners spread over a large part of a cycle (near
|
|
1140
|
+
a defect, where the stripes bend hard, on the medial axis of the
|
|
1141
|
+
surface) the two sides disagree by a whole cycle times a weight: the
|
|
1142
|
+
lattice jumps by up to a cell's fraction at voxel faces, ~1 mm at a
|
|
1143
|
+
4 mm cell. Here every corner predicts the phase at P from its own value
|
|
1144
|
+
and gradient (phi_i + g_i . (P - x_i)), and the predictions are blended
|
|
1145
|
+
as unit phasors, weighted trilinearly and by the pattern's amplitude:
|
|
1146
|
+
each term is continuous in P and the trilinear weights of the corners
|
|
1147
|
+
off a face vanish on it, so the angle of the sum is continuous across
|
|
1148
|
+
faces, edges and corners; it is the exact linear phase wherever the
|
|
1149
|
+
corners agree, and only where the sum vanishes (a defect line, where
|
|
1150
|
+
the stripe pattern itself has a dislocation) has it no value."""
|
|
1151
|
+
|
|
1152
|
+
def __init__(self, phase, amp, lo, h):
|
|
1153
|
+
self.phi = phase
|
|
1154
|
+
self.lo = np.asarray(lo, float)
|
|
1155
|
+
self.h = float(h)
|
|
1156
|
+
g = []
|
|
1157
|
+
for a in range(3):
|
|
1158
|
+
d = np.zeros_like(phase)
|
|
1159
|
+
n = phase.shape[a]
|
|
1160
|
+
sl = lambda i, j: tuple(slice(i, j) if b == a else slice(None) for b in range(3))
|
|
1161
|
+
if n >= 3:
|
|
1162
|
+
fwd = np.diff(phase, axis=a)
|
|
1163
|
+
fwd -= np.round(fwd)
|
|
1164
|
+
# central differences inside, one-sided at the ends
|
|
1165
|
+
d[sl(1, -1)] = 0.5 * (fwd[sl(0, -1)] + fwd[sl(1, None)])
|
|
1166
|
+
d[sl(0, 1)] = fwd[sl(0, 1)]
|
|
1167
|
+
d[sl(-1, None)] = fwd[sl(-1, None)]
|
|
1168
|
+
elif n == 2:
|
|
1169
|
+
fwd = np.diff(phase, axis=a)
|
|
1170
|
+
fwd -= np.round(fwd)
|
|
1171
|
+
d[:] = fwd
|
|
1172
|
+
g.append(d / self.h)
|
|
1173
|
+
self.grad = np.stack(g, -1)
|
|
1174
|
+
self.amp = np.asarray(amp, float)
|
|
1175
|
+
|
|
1176
|
+
def __call__(self, P, coherence=False):
|
|
1177
|
+
"""The phase at P (cycles, not wrapped); with `coherence`, also how
|
|
1178
|
+
far the corners agree (|sum of weighted phasors| / sum of weights:
|
|
1179
|
+
1 where the phase is linear, -> 0 at a defect)."""
|
|
1180
|
+
P = np.asarray(P, float)
|
|
1181
|
+
dims = np.asarray(self.phi.shape)
|
|
1182
|
+
x = np.clip((P - self.lo) / self.h, 0, dims - 1 - 1e-9)
|
|
1183
|
+
i0 = np.floor(x).astype(int)
|
|
1184
|
+
f = x - i0
|
|
1185
|
+
i1 = np.minimum(i0 + 1, dims - 1)
|
|
1186
|
+
acc = np.zeros(len(P), complex)
|
|
1187
|
+
wsum = np.zeros(len(P))
|
|
1188
|
+
base = None
|
|
1189
|
+
for cx in (0, 1):
|
|
1190
|
+
for cy in (0, 1):
|
|
1191
|
+
for cz in (0, 1):
|
|
1192
|
+
ii = ((i1 if cx else i0)[:, 0], (i1 if cy else i0)[:, 1], (i1 if cz else i0)[:, 2])
|
|
1193
|
+
w = ((f[:, 0] if cx else 1 - f[:, 0]) * (f[:, 1] if cy else 1 - f[:, 1])
|
|
1194
|
+
* (f[:, 2] if cz else 1 - f[:, 2])) * self.amp[ii]
|
|
1195
|
+
xi = self.lo + np.stack(ii, 1) * self.h
|
|
1196
|
+
pred = self.phi[ii] + np.einsum("ij,ij->i", self.grad[ii], P - xi)
|
|
1197
|
+
if base is None:
|
|
1198
|
+
base = pred
|
|
1199
|
+
acc += w * np.exp(2j * np.pi * (pred - base))
|
|
1200
|
+
wsum += w
|
|
1201
|
+
# the angle about the first corner's prediction: no wrap where the
|
|
1202
|
+
# corners agree, so the value runs on smoothly past whole cycles
|
|
1203
|
+
out = base + np.angle(acc) / (2 * np.pi)
|
|
1204
|
+
if coherence:
|
|
1205
|
+
return out, np.abs(acc) / np.maximum(wsum, 1e-300)
|
|
1206
|
+
return out
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _phase_at(phi, lo, h, P):
|
|
1210
|
+
"""Trilinear interpolation of a phase grid (cycles, mod 1): the eight
|
|
1211
|
+
corners are unwrapped against the first, so a phase that advances by
|
|
1212
|
+
less than half a cycle a step interpolates exactly where it is linear."""
|
|
1213
|
+
dims = np.asarray(phi.shape)
|
|
1214
|
+
x = np.clip((P - lo) / h, 0, dims - 1 - 1e-9)
|
|
1215
|
+
i0 = np.floor(x).astype(int)
|
|
1216
|
+
f = x - i0
|
|
1217
|
+
i1 = np.minimum(i0 + 1, dims - 1)
|
|
1218
|
+
base = phi[i0[:, 0], i0[:, 1], i0[:, 2]]
|
|
1219
|
+
out = np.zeros(len(P))
|
|
1220
|
+
for cx in (0, 1):
|
|
1221
|
+
for cy in (0, 1):
|
|
1222
|
+
for cz in (0, 1):
|
|
1223
|
+
v = phi[(i1 if cx else i0)[:, 0], (i1 if cy else i0)[:, 1], (i1 if cz else i0)[:, 2]] - base
|
|
1224
|
+
v -= np.round(v)
|
|
1225
|
+
out += ((f[:, 0] if cx else 1 - f[:, 0]) * (f[:, 1] if cy else 1 - f[:, 1])
|
|
1226
|
+
* (f[:, 2] if cz else 1 - f[:, 2])) * v
|
|
1227
|
+
return base + out
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _axis_vector(ax):
|
|
1231
|
+
if isinstance(ax, str):
|
|
1232
|
+
return {"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax]
|
|
1233
|
+
a = np.asarray(ax, float)
|
|
1234
|
+
return a / np.linalg.norm(a)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _frame_coords(P, frame, cell):
|
|
1238
|
+
"""Lattice coordinates, period 1 a cell, of a cylindrical or spherical
|
|
1239
|
+
frame with a constant cell: whole cells round each closed direction."""
|
|
1240
|
+
c = float(np.asarray(cell, float).ravel()[0])
|
|
1241
|
+
if frame["type"] == "cylindrical":
|
|
1242
|
+
Q, radius = _frame_points(P, frame)
|
|
1243
|
+
R = radius or float(np.median(Q[:, 0]))
|
|
1244
|
+
n = max(1, round(2 * np.pi * R / c))
|
|
1245
|
+
return np.stack([Q[:, 0] / c, Q[:, 1] * n / (2 * np.pi), Q[:, 2] / c], axis=1)
|
|
1246
|
+
if frame["type"] == "spherical":
|
|
1247
|
+
Q = P - np.asarray(frame.get("center", (0, 0, 0)), float)
|
|
1248
|
+
r = np.linalg.norm(Q, axis=1)
|
|
1249
|
+
az = np.arctan2(Q[:, 1], Q[:, 0])
|
|
1250
|
+
pol = np.arccos(np.clip(Q[:, 2] / np.maximum(r, 1e-12), -1, 1))
|
|
1251
|
+
R = float(frame.get("radius", 0) or np.median(r))
|
|
1252
|
+
n_az = max(1, round(2 * np.pi * R / c))
|
|
1253
|
+
n_pol = max(1, round(np.pi * R / c))
|
|
1254
|
+
return np.stack([r / c, az * n_az / (2 * np.pi), pol * n_pol / np.pi], axis=1)
|
|
1255
|
+
raise ValueError(f"lattice frame type {frame['type']!r} has no constant-cell coordinates")
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
# ---- smooth booleans with a field radius -----------------------------------------
|
|
1259
|
+
|
|
1260
|
+
def _smin(a, b, k):
|
|
1261
|
+
"""Polynomial smooth minimum with a per-point radius k (0 = hard)."""
|
|
1262
|
+
if np.ndim(k) == 0:
|
|
1263
|
+
# one radius (the usual case; 0 for every hard boolean): no masks
|
|
1264
|
+
k = float(k)
|
|
1265
|
+
if k <= 1e-9:
|
|
1266
|
+
return np.minimum(a, b)
|
|
1267
|
+
h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0)
|
|
1268
|
+
return b * (1 - h) + a * h - k * h * (1 - h)
|
|
1269
|
+
k = np.broadcast_to(np.asarray(k, float), np.shape(a))
|
|
1270
|
+
hard = k <= 1e-9
|
|
1271
|
+
kk = np.where(hard, 1.0, k)
|
|
1272
|
+
h = np.clip(0.5 + 0.5 * (b - a) / kk, 0.0, 1.0)
|
|
1273
|
+
s = b * (1 - h) + a * h - kk * h * (1 - h)
|
|
1274
|
+
return np.where(hard, np.minimum(a, b), s)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
# ---- groups: reusable sub-graphs ---------------------------------------------------
|
|
1278
|
+
|
|
1279
|
+
# a material body's field keys (FieldGraph._material_bodies)
|
|
1280
|
+
_BODY_KEYS = ("solid", "where", "above", "below", "mix")
|
|
1281
|
+
|
|
1282
|
+
_REF_KEYS = ("input", "value", "thickness", "distance", "blend", "cell", "density", "offset")
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _rename_spec(spec, names):
|
|
1286
|
+
"""A copy of a node spec with every reference in `names` (old -> new)
|
|
1287
|
+
renamed: field parameters, inputs, vars, an analyzed part, expr text,
|
|
1288
|
+
and inline nodes inside them."""
|
|
1289
|
+
import re
|
|
1290
|
+
|
|
1291
|
+
s = json.loads(json.dumps(spec))
|
|
1292
|
+
|
|
1293
|
+
def ref(v):
|
|
1294
|
+
if isinstance(v, str):
|
|
1295
|
+
return names.get(v, v)
|
|
1296
|
+
if isinstance(v, dict) and "op" in v:
|
|
1297
|
+
return _rename_spec(v, names)
|
|
1298
|
+
return v
|
|
1299
|
+
|
|
1300
|
+
for k in _REF_KEYS:
|
|
1301
|
+
if k in s and not (s.get("op") == "constant" and k == "value"):
|
|
1302
|
+
s[k] = ref(s[k])
|
|
1303
|
+
if isinstance(s.get("inputs"), list):
|
|
1304
|
+
s["inputs"] = [ref(v) for v in s["inputs"]]
|
|
1305
|
+
elif s.get("op") == "group" and isinstance(s.get("inputs"), dict):
|
|
1306
|
+
s["inputs"] = {k: ref(v) for k, v in s["inputs"].items()}
|
|
1307
|
+
if isinstance(s.get("vars"), dict):
|
|
1308
|
+
s["vars"] = {k: ref(v) for k, v in s["vars"].items()}
|
|
1309
|
+
if s.get("op") == "result" and isinstance(s.get("part"), str):
|
|
1310
|
+
s["part"] = names.get(s["part"], s["part"])
|
|
1311
|
+
if s.get("op") == "lattice" and isinstance(s.get("frame"), dict) and "surface" in s["frame"]:
|
|
1312
|
+
s["frame"]["surface"] = ref(s["frame"]["surface"])
|
|
1313
|
+
if s.get("op") == "expr" and isinstance(s.get("expr"), str):
|
|
1314
|
+
# names the expr reads directly (not through vars) follow too, as vars so any name works
|
|
1315
|
+
used = {m for m in re.findall(r"[A-Za-z_]\w*", s["expr"]) if m in names}
|
|
1316
|
+
for m in used:
|
|
1317
|
+
s.setdefault("vars", {})
|
|
1318
|
+
if m not in s["vars"]:
|
|
1319
|
+
s["vars"][m] = names[m]
|
|
1320
|
+
return s
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def expand_groups(doc, _depth=0):
|
|
1324
|
+
"""The graph with every `group` node replaced by a copy of its
|
|
1325
|
+
definition from `groups`: the group's nodes come in as
|
|
1326
|
+
<instance>__<node>, its params as constant nodes <instance>__<param>
|
|
1327
|
+
(the instance's own values over the definition's defaults), its inputs
|
|
1328
|
+
are the instance's nodes, and the group's output takes the instance's
|
|
1329
|
+
name, so the rest of the graph refers to it as before. Groups may use
|
|
1330
|
+
groups (eight deep)."""
|
|
1331
|
+
nodes = doc.get("nodes")
|
|
1332
|
+
if not isinstance(nodes, dict) or not any(isinstance(v, dict) and v.get("op") == "group" for v in nodes.values()):
|
|
1333
|
+
return doc
|
|
1334
|
+
if _depth > 8:
|
|
1335
|
+
raise ValueError("groups nest more than eight deep (does a group use itself?)")
|
|
1336
|
+
groups = doc.get("groups") or {}
|
|
1337
|
+
out = {}
|
|
1338
|
+
for name, spec in nodes.items():
|
|
1339
|
+
if not (isinstance(spec, dict) and spec.get("op") == "group"):
|
|
1340
|
+
out[name] = spec
|
|
1341
|
+
continue
|
|
1342
|
+
gname = spec.get("group")
|
|
1343
|
+
if gname not in groups:
|
|
1344
|
+
raise ValueError(f"node {name!r}: no group {gname!r} (have {', '.join(groups) or 'none'})")
|
|
1345
|
+
g = groups[gname]
|
|
1346
|
+
gnodes = g.get("nodes") or {}
|
|
1347
|
+
if not gnodes:
|
|
1348
|
+
raise ValueError(f"group {gname!r} has no nodes")
|
|
1349
|
+
gout = g.get("output") or list(gnodes)[-1]
|
|
1350
|
+
if gout not in gnodes:
|
|
1351
|
+
raise ValueError(f"group {gname!r}: output {gout!r} is not one of its nodes")
|
|
1352
|
+
params = {**(g.get("params") or {}), **(spec.get("params") or {})}
|
|
1353
|
+
unknown = set(spec.get("params") or {}) - set(g.get("params") or {})
|
|
1354
|
+
if unknown:
|
|
1355
|
+
raise ValueError(f"node {name!r}: group {gname!r} has no param {sorted(unknown)[0]!r} "
|
|
1356
|
+
f"(has {', '.join(g.get('params') or {}) or 'none'})")
|
|
1357
|
+
given = spec.get("inputs") or {}
|
|
1358
|
+
missing = [i for i in g.get("inputs") or [] if i not in given]
|
|
1359
|
+
if missing:
|
|
1360
|
+
raise ValueError(f"node {name!r}: group {gname!r} needs input {missing[0]!r}")
|
|
1361
|
+
extra = set(given) - set(g.get("inputs") or [])
|
|
1362
|
+
if extra:
|
|
1363
|
+
raise ValueError(f"node {name!r}: group {gname!r} has no input {sorted(extra)[0]!r}")
|
|
1364
|
+
names = {n: (name if n == gout else f"{name}__{n}") for n in gnodes}
|
|
1365
|
+
names.update({p: f"{name}__{p}" for p in params})
|
|
1366
|
+
names.update({i: given[i] for i in g.get("inputs") or []})
|
|
1367
|
+
for p, v in params.items():
|
|
1368
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
1369
|
+
raise ValueError(f"node {name!r}: param {p!r} must be a number")
|
|
1370
|
+
out[f"{name}__{p}"] = {"op": "constant", "value": float(v)}
|
|
1371
|
+
for n, ns in gnodes.items():
|
|
1372
|
+
out[names[n]] = _rename_spec(ns, names)
|
|
1373
|
+
return expand_groups({**doc, "nodes": out}, _depth + 1)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
# ---- the graph -----------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
@dataclass
|
|
1379
|
+
class _Node:
|
|
1380
|
+
name: str
|
|
1381
|
+
op: str
|
|
1382
|
+
spec: dict
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
class FieldGraph:
|
|
1386
|
+
"""A graph of named field nodes. `FieldGraph(doc)` from the JSON form
|
|
1387
|
+
(see the module docstring and schema.FIELD_SCHEMA); `sources` names
|
|
1388
|
+
in-memory data a `result` or `grid` node can refer to by `source`
|
|
1389
|
+
(verify_part fields, a temperature array), and `meshes` in-memory
|
|
1390
|
+
trimeshes a `mesh` node can name by `source`.
|
|
1391
|
+
|
|
1392
|
+
graph.field() -> callable(P) of the output node
|
|
1393
|
+
graph.evaluate(P, "wall")
|
|
1394
|
+
graph.bounds() -> (lo, hi) of the output's solid
|
|
1395
|
+
graph.mesh() -> smooth watertight trimesh (field_mesh)"""
|
|
1396
|
+
|
|
1397
|
+
def __init__(self, doc, base_dir=None, sources=None, meshes=None):
|
|
1398
|
+
if isinstance(doc, str):
|
|
1399
|
+
doc = json.loads(doc)
|
|
1400
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("nodes"), dict) or not doc["nodes"]:
|
|
1401
|
+
raise ValueError("a field graph needs a non-empty 'nodes' object")
|
|
1402
|
+
doc = expand_groups(doc)
|
|
1403
|
+
self.doc = doc
|
|
1404
|
+
self.base_dir = base_dir
|
|
1405
|
+
self.sources = dict(sources or {})
|
|
1406
|
+
self.meshes = dict(meshes or {})
|
|
1407
|
+
self.nodes: dict[str, _Node] = {}
|
|
1408
|
+
self._anon = 0
|
|
1409
|
+
for name, spec in doc["nodes"].items():
|
|
1410
|
+
if name in ("x", "y", "z") or name in _CONSTS or name in _FUNCS:
|
|
1411
|
+
raise ValueError(f"node name {name!r} is reserved (x, y, z, pi, e and the expr functions)")
|
|
1412
|
+
self._add(name, spec)
|
|
1413
|
+
self.output = doc.get("output") or list(doc["nodes"])[-1]
|
|
1414
|
+
if self.output not in self.nodes:
|
|
1415
|
+
raise ValueError(f"output {self.output!r} is not a node (have {', '.join(doc['nodes'])})")
|
|
1416
|
+
self.part_node = self.output # the output as written (drains move `output` on)
|
|
1417
|
+
self._built = {}
|
|
1418
|
+
self.plan = None # the sharp CSG plan of the last mesh(), when it had one
|
|
1419
|
+
self.analyses = {} # verify reports behind `result` nodes that ran a study
|
|
1420
|
+
self.bodies = self._material_bodies(doc.get("materials"))
|
|
1421
|
+
self._check_refs()
|
|
1422
|
+
|
|
1423
|
+
# -- construction --
|
|
1424
|
+
|
|
1425
|
+
def _add(self, name, spec):
|
|
1426
|
+
if not isinstance(spec, dict) or "op" not in spec:
|
|
1427
|
+
raise ValueError(f"node {name!r} must be an object with an 'op' ({', '.join(OPS)})")
|
|
1428
|
+
if spec["op"] not in OPS:
|
|
1429
|
+
import difflib
|
|
1430
|
+
|
|
1431
|
+
near = difflib.get_close_matches(str(spec["op"]), OPS, 1)
|
|
1432
|
+
raise ValueError(f"node {name!r}: unknown op {spec['op']!r}"
|
|
1433
|
+
+ (f"; did you mean {near[0]!r}?" if near else f" ({', '.join(OPS)})"))
|
|
1434
|
+
self.nodes[name] = _Node(name, spec["op"], spec)
|
|
1435
|
+
return name
|
|
1436
|
+
|
|
1437
|
+
def _ref(self, v, owner, key):
|
|
1438
|
+
"""A parameter: number, node name, or inline node -> node name or float."""
|
|
1439
|
+
if isinstance(v, bool):
|
|
1440
|
+
raise ValueError(f"node {owner!r}: {key} must be a number or a field, got {v!r}")
|
|
1441
|
+
if isinstance(v, (int, float)):
|
|
1442
|
+
return float(v)
|
|
1443
|
+
if isinstance(v, str):
|
|
1444
|
+
if v not in self.nodes:
|
|
1445
|
+
raise ValueError(f"node {owner!r}: {key} refers to {v!r}, which is not a node")
|
|
1446
|
+
return v
|
|
1447
|
+
if isinstance(v, dict):
|
|
1448
|
+
self._anon += 1
|
|
1449
|
+
return self._add(f"{owner}.{key}#{self._anon}", v)
|
|
1450
|
+
raise ValueError(f"node {owner!r}: {key} must be a number, a node name or a node object, got {v!r}")
|
|
1451
|
+
|
|
1452
|
+
def _check_refs(self):
|
|
1453
|
+
# resolve every reference once (inline nodes get names) and refuse cycles
|
|
1454
|
+
self.deps = {}
|
|
1455
|
+
names = list(self.nodes)
|
|
1456
|
+
i = 0
|
|
1457
|
+
while i < len(names):
|
|
1458
|
+
nd = self.nodes[names[i]]
|
|
1459
|
+
self.deps[nd.name] = self._deps(nd)
|
|
1460
|
+
names += [n for n in self.deps[nd.name] if n not in names]
|
|
1461
|
+
i += 1
|
|
1462
|
+
state = {}
|
|
1463
|
+
|
|
1464
|
+
def visit(n, path):
|
|
1465
|
+
if state.get(n) == 1:
|
|
1466
|
+
raise ValueError("field graph has a cycle: " + " -> ".join(path + [n]))
|
|
1467
|
+
if state.get(n) == 2:
|
|
1468
|
+
return
|
|
1469
|
+
state[n] = 1
|
|
1470
|
+
for d in self.deps[n]:
|
|
1471
|
+
visit(d, path + [n])
|
|
1472
|
+
state[n] = 2
|
|
1473
|
+
|
|
1474
|
+
for n in list(self.nodes):
|
|
1475
|
+
visit(n, [])
|
|
1476
|
+
|
|
1477
|
+
_FIELD_KEYS = ("input", "value", "thickness", "distance", "blend", "cell", "density", "offset")
|
|
1478
|
+
|
|
1479
|
+
def _material_bodies(self, spec):
|
|
1480
|
+
"""The graph's `materials`: [{name, material, solid, where, above,
|
|
1481
|
+
below, mix, extruder, color}], each field key resolved to a node name
|
|
1482
|
+
or a number (inline nodes get names materials.<body>.<key>#n), in
|
|
1483
|
+
precedence order (see material_bodies)."""
|
|
1484
|
+
if spec is None:
|
|
1485
|
+
return []
|
|
1486
|
+
if not isinstance(spec, list) or not spec:
|
|
1487
|
+
raise ValueError("materials must be a non-empty list of bodies")
|
|
1488
|
+
out, seen = [], set()
|
|
1489
|
+
for i, b in enumerate(spec):
|
|
1490
|
+
if not isinstance(b, dict):
|
|
1491
|
+
raise ValueError(f"materials[{i}] must be an object")
|
|
1492
|
+
name = b.get("name")
|
|
1493
|
+
if not isinstance(name, str) or not name.strip():
|
|
1494
|
+
raise ValueError(f"materials[{i}] needs a 'name'")
|
|
1495
|
+
if name in seen:
|
|
1496
|
+
raise ValueError(f"materials: two bodies are called {name!r}")
|
|
1497
|
+
seen.add(name)
|
|
1498
|
+
mat = b.get("material")
|
|
1499
|
+
if "mix" in b:
|
|
1500
|
+
if not (isinstance(mat, list) and len(mat) == 2):
|
|
1501
|
+
raise ValueError(f"material body {name!r}: a mix body's material is a pair [A, B] "
|
|
1502
|
+
"(mix 0 is all A, 1 all B)")
|
|
1503
|
+
elif mat is None or isinstance(mat, list):
|
|
1504
|
+
raise ValueError(f"material body {name!r}: needs one 'material' (a pair only with 'mix')")
|
|
1505
|
+
if "where" in b and "above" not in b and "below" not in b:
|
|
1506
|
+
raise ValueError(f"material body {name!r}: 'where' needs 'above' and/or 'below'")
|
|
1507
|
+
if ("above" in b or "below" in b) and "where" not in b:
|
|
1508
|
+
raise ValueError(f"material body {name!r}: 'above'/'below' threshold the 'where' field; "
|
|
1509
|
+
"give one")
|
|
1510
|
+
owner = f"materials.{name}"
|
|
1511
|
+
body = {"name": name, "material": mat, "index": i}
|
|
1512
|
+
for key in _BODY_KEYS:
|
|
1513
|
+
if key in b:
|
|
1514
|
+
body[key] = self._ref(b[key], owner, key)
|
|
1515
|
+
for key in ("extruder", "color", "voxel"):
|
|
1516
|
+
if key in b:
|
|
1517
|
+
body[key] = b[key]
|
|
1518
|
+
out.append(body)
|
|
1519
|
+
return out
|
|
1520
|
+
|
|
1521
|
+
def _deps(self, nd):
|
|
1522
|
+
s = nd.spec
|
|
1523
|
+
refs = {}
|
|
1524
|
+
for key in self._FIELD_KEYS:
|
|
1525
|
+
if key in s and not (nd.op == "constant" and key == "value"):
|
|
1526
|
+
refs[key] = self._ref(s[key], nd.name, key)
|
|
1527
|
+
if "inputs" in s:
|
|
1528
|
+
if not isinstance(s["inputs"], list) or not s["inputs"]:
|
|
1529
|
+
raise ValueError(f"node {nd.name!r}: inputs must be a non-empty list")
|
|
1530
|
+
refs["inputs"] = [self._ref(v, nd.name, f"inputs[{i}]") for i, v in enumerate(s["inputs"])]
|
|
1531
|
+
if nd.op == "lattice" and isinstance(s.get("frame"), dict) and s["frame"].get("type") == "conformal":
|
|
1532
|
+
if "surface" not in s["frame"]:
|
|
1533
|
+
raise ValueError(f"node {nd.name!r}: a conformal frame needs 'surface' (the solid it follows)")
|
|
1534
|
+
refs["surface"] = self._ref(s["frame"]["surface"], nd.name, "frame.surface")
|
|
1535
|
+
if isinstance(refs["surface"], float):
|
|
1536
|
+
raise ValueError(f"node {nd.name!r}: frame.surface must be a solid node")
|
|
1537
|
+
if nd.op == "expr":
|
|
1538
|
+
if not isinstance(s.get("expr"), str):
|
|
1539
|
+
raise ValueError(f"node {nd.name!r}: expr needs an 'expr' string")
|
|
1540
|
+
used, fn = _compile_expr(s["expr"])
|
|
1541
|
+
shadow = [k for k in (s.get("vars") or {}) if k in ("x", "y", "z") or k in _CONSTS or k in _FUNCS]
|
|
1542
|
+
if shadow:
|
|
1543
|
+
# the expr would read the constant or coordinate, never the var
|
|
1544
|
+
raise ValueError(f"node {nd.name!r}: var name {shadow[0]!r} is reserved (x, y, z, pi, e and the "
|
|
1545
|
+
f"expr functions); rename it")
|
|
1546
|
+
local = {k: self._ref(v, nd.name, f"vars.{k}") for k, v in (s.get("vars") or {}).items()}
|
|
1547
|
+
for u in used - set(local) - {"x", "y", "z"}:
|
|
1548
|
+
if u not in self.nodes:
|
|
1549
|
+
raise ValueError(f"node {nd.name!r}: expr reads {u!r}, which is not a node, a var or x/y/z")
|
|
1550
|
+
local[u] = u
|
|
1551
|
+
refs["vars"] = local
|
|
1552
|
+
refs["fn"] = fn
|
|
1553
|
+
nd.refs = refs
|
|
1554
|
+
out = []
|
|
1555
|
+
if nd.op == "result" and s.get("part") in self.nodes:
|
|
1556
|
+
out.append(s["part"]) # analyzed from this graph: a dependency too
|
|
1557
|
+
for v in refs.values():
|
|
1558
|
+
for x in (v if isinstance(v, list) else v.values() if isinstance(v, dict) else [v]):
|
|
1559
|
+
if isinstance(x, str):
|
|
1560
|
+
out.append(x)
|
|
1561
|
+
return out
|
|
1562
|
+
|
|
1563
|
+
# -- evaluation --
|
|
1564
|
+
|
|
1565
|
+
def field(self, name=None):
|
|
1566
|
+
name = name or self.output
|
|
1567
|
+
|
|
1568
|
+
def f(P):
|
|
1569
|
+
out = self.evaluate(P, name)
|
|
1570
|
+
if not hasattr(f, "chunk"):
|
|
1571
|
+
# compiled (fieldjit): no big temporaries per point, so the
|
|
1572
|
+
# mesher's threads can split even a small batch (most calls
|
|
1573
|
+
# are 50-500k points, which numpy's 250k chunks run serially)
|
|
1574
|
+
f.chunk = _JIT_CHUNK if self.__dict__.get("_jit", {}).get(name) is not None else None
|
|
1575
|
+
return out
|
|
1576
|
+
if self._torch_field() is not None and self._tf.batch:
|
|
1577
|
+
f.batch = self._tf.batch
|
|
1578
|
+
return f
|
|
1579
|
+
|
|
1580
|
+
def evaluate(self, P, name=None):
|
|
1581
|
+
tf = self._torch_field()
|
|
1582
|
+
if tf is not None:
|
|
1583
|
+
return tf(P, name)
|
|
1584
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
1585
|
+
cf = self._compiled(name or self.output)
|
|
1586
|
+
if cf is not None:
|
|
1587
|
+
return cf(P)
|
|
1588
|
+
memo = {}
|
|
1589
|
+
return np.broadcast_to(self._eval(name or self.output, P, memo), (len(P),)).astype(float)
|
|
1590
|
+
|
|
1591
|
+
def _compiled(self, name):
|
|
1592
|
+
"""fieldjit.CompiledField for the node (the whole graph under it as
|
|
1593
|
+
one compiled pass), or None: numba missing, LOADPATH_FIELDJIT=0, or
|
|
1594
|
+
the node itself does not compile."""
|
|
1595
|
+
from . import fieldjit
|
|
1596
|
+
|
|
1597
|
+
if not fieldjit.available():
|
|
1598
|
+
return None
|
|
1599
|
+
cache = self.__dict__.setdefault("_jit", {})
|
|
1600
|
+
if name in cache:
|
|
1601
|
+
return cache[name]
|
|
1602
|
+
with self.__dict__.setdefault("_jit_lock", threading.RLock()):
|
|
1603
|
+
if name not in cache:
|
|
1604
|
+
cache[name] = fieldjit.compile_graph(self, name)
|
|
1605
|
+
return cache[name]
|
|
1606
|
+
|
|
1607
|
+
def _torch_field(self):
|
|
1608
|
+
"""fieldtorch.TorchField on the device LOADPATH_FIELD_DEVICE picks
|
|
1609
|
+
(a GPU by default when there is one), or None for numpy."""
|
|
1610
|
+
if not hasattr(self, "_tf"):
|
|
1611
|
+
from .fieldtorch import TorchField, field_device
|
|
1612
|
+
|
|
1613
|
+
dev = field_device()
|
|
1614
|
+
self._tf = TorchField(self, dev) if dev is not None else None
|
|
1615
|
+
return self._tf
|
|
1616
|
+
|
|
1617
|
+
def _val(self, r, P, memo):
|
|
1618
|
+
if isinstance(r, float):
|
|
1619
|
+
return r
|
|
1620
|
+
return self._eval(r, P, memo)
|
|
1621
|
+
|
|
1622
|
+
def _eval(self, name, P, memo):
|
|
1623
|
+
key = (name, id(P))
|
|
1624
|
+
if key in memo:
|
|
1625
|
+
return memo[key][1]
|
|
1626
|
+
out = self._compute(self.nodes[name], P, memo)
|
|
1627
|
+
memo[key] = (P, out) # keep P alive so its id stays unique
|
|
1628
|
+
return out
|
|
1629
|
+
|
|
1630
|
+
def seed(self, prebuilt):
|
|
1631
|
+
"""Use resources built by another graph of the same nodes (an
|
|
1632
|
+
analysis, a meshed field, a loaded grid): {name: (resource,
|
|
1633
|
+
analysis or None)}. The caller vouches that each node and what it
|
|
1634
|
+
depends on are unchanged; the editor keys them by the node's closure."""
|
|
1635
|
+
for name, (res, analysis) in (prebuilt or {}).items():
|
|
1636
|
+
if name in self.nodes:
|
|
1637
|
+
self._built[name] = res
|
|
1638
|
+
if analysis is not None:
|
|
1639
|
+
self.analyses[name] = analysis
|
|
1640
|
+
return self
|
|
1641
|
+
|
|
1642
|
+
def _resource(self, nd, make):
|
|
1643
|
+
if nd.name not in self._built:
|
|
1644
|
+
self._built[nd.name] = make()
|
|
1645
|
+
return self._built[nd.name]
|
|
1646
|
+
|
|
1647
|
+
def _path(self, path):
|
|
1648
|
+
if self.base_dir is None:
|
|
1649
|
+
return path
|
|
1650
|
+
root = os.path.realpath(self.base_dir)
|
|
1651
|
+
full = os.path.realpath(os.path.join(root, path))
|
|
1652
|
+
if os.path.commonpath([root, full]) != root:
|
|
1653
|
+
raise ValueError(f"path {path!r} must stay inside the graph's folder")
|
|
1654
|
+
if not os.path.exists(full):
|
|
1655
|
+
raise ValueError(f"no file {path!r} in {root} (paths resolve from the graph's folder; "
|
|
1656
|
+
"--base-dir picks another)")
|
|
1657
|
+
return full
|
|
1658
|
+
|
|
1659
|
+
def _compute(self, nd, P, memo):
|
|
1660
|
+
s, r, op = nd.spec, nd.refs, nd.op
|
|
1661
|
+
v = lambda key, default=None: self._val(r[key], P, memo) if key in r else default # noqa: E731
|
|
1662
|
+
if op == "constant":
|
|
1663
|
+
return float(s["value"])
|
|
1664
|
+
if op == "coordinate":
|
|
1665
|
+
ax = s.get("axis", "z")
|
|
1666
|
+
if isinstance(ax, str):
|
|
1667
|
+
return P[:, "xyz".index(ax)].copy()
|
|
1668
|
+
a = np.asarray(ax, float)
|
|
1669
|
+
a = a / np.linalg.norm(a)
|
|
1670
|
+
return (P - np.asarray(s.get("origin", (0, 0, 0)), float)) @ a
|
|
1671
|
+
if op == "distance":
|
|
1672
|
+
return self._distance(nd, P, memo)
|
|
1673
|
+
if op == "shape":
|
|
1674
|
+
from .shapes import shape_from_spec
|
|
1675
|
+
|
|
1676
|
+
if s["shape"].get("type") == "mesh":
|
|
1677
|
+
raise ValueError(f"node {nd.name!r}: use op 'mesh' for meshes (exact surface distance)")
|
|
1678
|
+
shp = self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir))
|
|
1679
|
+
return shp.sdf(P)
|
|
1680
|
+
if op == "mesh":
|
|
1681
|
+
mf = self._resource(nd, lambda: self._mesh_field(nd))
|
|
1682
|
+
return mf(P)
|
|
1683
|
+
if op in ("result", "grid", "image"):
|
|
1684
|
+
smp = self._resource(nd, lambda: self._samples(nd))
|
|
1685
|
+
return smp(P)
|
|
1686
|
+
if op == "remap":
|
|
1687
|
+
return _remap(v("input"), s, nd.name)
|
|
1688
|
+
if op == "expr":
|
|
1689
|
+
env = {"x": P[:, 0], "y": P[:, 1], "z": P[:, 2]}
|
|
1690
|
+
for k, ref in r["vars"].items():
|
|
1691
|
+
env[k] = self._val(ref, P, memo)
|
|
1692
|
+
return np.asarray(r["fn"](env), float)
|
|
1693
|
+
if op in ("union", "intersect", "subtract"):
|
|
1694
|
+
vals = [self._val(x, P, memo) for x in r["inputs"]]
|
|
1695
|
+
k = v("blend", 0.0)
|
|
1696
|
+
if op == "union":
|
|
1697
|
+
out = vals[0]
|
|
1698
|
+
for b in vals[1:]:
|
|
1699
|
+
out = _smin(out, b, k)
|
|
1700
|
+
return out
|
|
1701
|
+
if op == "intersect":
|
|
1702
|
+
out = vals[0]
|
|
1703
|
+
for b in vals[1:]:
|
|
1704
|
+
out = -_smin(-out, -b, k)
|
|
1705
|
+
return out
|
|
1706
|
+
out = vals[0]
|
|
1707
|
+
for b in vals[1:]:
|
|
1708
|
+
out = -_smin(-out, b, k)
|
|
1709
|
+
return out
|
|
1710
|
+
if op == "shell":
|
|
1711
|
+
f = v("input")
|
|
1712
|
+
t = np.asarray(v("thickness"), float)
|
|
1713
|
+
side = s.get("side", "inside")
|
|
1714
|
+
if side == "inside":
|
|
1715
|
+
return np.maximum(f, -(f + t))
|
|
1716
|
+
if side == "outside":
|
|
1717
|
+
return np.maximum(-f, f - t)
|
|
1718
|
+
if side == "center":
|
|
1719
|
+
return np.abs(f) - 0.5 * t
|
|
1720
|
+
raise ValueError(f"node {nd.name!r}: shell side must be inside, outside or center")
|
|
1721
|
+
if op == "offset":
|
|
1722
|
+
return v("input") - v("distance")
|
|
1723
|
+
if op == "lattice":
|
|
1724
|
+
return self._lattice(nd, P, memo)
|
|
1725
|
+
if op == "transform":
|
|
1726
|
+
return self._transform(nd, P, memo)
|
|
1727
|
+
raise AssertionError(op)
|
|
1728
|
+
|
|
1729
|
+
def _distance(self, nd, P, memo):
|
|
1730
|
+
s = nd.spec
|
|
1731
|
+
if "input" in nd.refs:
|
|
1732
|
+
d = np.abs(self._val(nd.refs["input"], P, memo))
|
|
1733
|
+
elif "points" in s:
|
|
1734
|
+
pts = np.asarray(s["points"], float).reshape(-1, 3)
|
|
1735
|
+
from scipy.spatial import cKDTree
|
|
1736
|
+
|
|
1737
|
+
d = cKDTree(pts).query(P)[0]
|
|
1738
|
+
elif "segment" in s:
|
|
1739
|
+
a, b = (np.asarray(x, float) for x in s["segment"])
|
|
1740
|
+
ab = b - a
|
|
1741
|
+
t = np.clip(((P - a) @ ab) / max(ab @ ab, 1e-300), 0.0, 1.0)
|
|
1742
|
+
d = np.linalg.norm(P - (a + t[:, None] * ab), axis=1)
|
|
1743
|
+
elif "plane" in s:
|
|
1744
|
+
n = np.asarray(s["plane"]["normal"], float)
|
|
1745
|
+
n = n / np.linalg.norm(n)
|
|
1746
|
+
d = np.abs((P - np.asarray(s["plane"]["point"], float)) @ n)
|
|
1747
|
+
elif "shape" in s:
|
|
1748
|
+
from .shapes import shape_from_spec
|
|
1749
|
+
|
|
1750
|
+
shp = self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir))
|
|
1751
|
+
d = np.abs(shp.sdf(P))
|
|
1752
|
+
else:
|
|
1753
|
+
raise ValueError(f"node {nd.name!r}: distance needs input, points, segment, plane or shape")
|
|
1754
|
+
if s.get("signed") and "input" in nd.refs:
|
|
1755
|
+
d = self._val(nd.refs["input"], P, memo)
|
|
1756
|
+
return d
|
|
1757
|
+
|
|
1758
|
+
def _mesh_field(self, nd):
|
|
1759
|
+
import trimesh
|
|
1760
|
+
|
|
1761
|
+
s = nd.spec
|
|
1762
|
+
if "source" in s:
|
|
1763
|
+
if s["source"] not in self.meshes:
|
|
1764
|
+
raise ValueError(f"node {nd.name!r}: no in-memory mesh named {s['source']!r}")
|
|
1765
|
+
mesh = self.meshes[s["source"]]
|
|
1766
|
+
else:
|
|
1767
|
+
if "path" not in s:
|
|
1768
|
+
raise ValueError(f"node {nd.name!r}: mesh needs a 'path' (STL/3MF/OBJ) or a 'source'")
|
|
1769
|
+
mesh = trimesh.load(self._path(s["path"]), force="mesh")
|
|
1770
|
+
return MeshField(mesh, pitch=s.get("pitch"), band=s.get("band"))
|
|
1771
|
+
|
|
1772
|
+
_PLANES = {"xy": (0, 1, 2), "xz": (0, 2, 1), "yz": (1, 2, 0)}
|
|
1773
|
+
|
|
1774
|
+
def _image(self, nd):
|
|
1775
|
+
"""A picture as a scalar field: `path` (PNG, JPEG, ...) laid on
|
|
1776
|
+
`plane` (xy, xz or yz) with its lower-left corner at `origin` and
|
|
1777
|
+
`size` [w, h] mm, and carried straight through along the third axis.
|
|
1778
|
+
Values are the `channel` (luma, r, g, b or a) from 0 to 1, `invert`
|
|
1779
|
+
flips them; bilinear between pixels, the edge pixel carries on past
|
|
1780
|
+
the picture. Pictures over 1024 pixels a side are resized to it."""
|
|
1781
|
+
from PIL import Image
|
|
1782
|
+
|
|
1783
|
+
s = nd.spec
|
|
1784
|
+
if "path" not in s or "size" not in s:
|
|
1785
|
+
raise ValueError(f"node {nd.name!r}: an image needs a 'path' and a 'size' [w, h] in mm")
|
|
1786
|
+
plane = s.get("plane", "xy")
|
|
1787
|
+
if plane not in self._PLANES:
|
|
1788
|
+
raise ValueError(f"node {nd.name!r}: plane must be xy, xz or yz")
|
|
1789
|
+
im = Image.open(self._path(s["path"]))
|
|
1790
|
+
im.load()
|
|
1791
|
+
im = im.convert("RGBA")
|
|
1792
|
+
if max(im.size) > 1024:
|
|
1793
|
+
k = 1024 / max(im.size)
|
|
1794
|
+
im = im.resize((max(2, round(im.size[0] * k)), max(2, round(im.size[1] * k))), Image.BOX)
|
|
1795
|
+
a = np.asarray(im, float) / 255.0 # rows top to bottom
|
|
1796
|
+
ch = s.get("channel", "luma")
|
|
1797
|
+
if ch == "luma":
|
|
1798
|
+
v = a[..., 0] * 0.2126 + a[..., 1] * 0.7152 + a[..., 2] * 0.0722
|
|
1799
|
+
elif ch in ("r", "g", "b", "a"):
|
|
1800
|
+
v = a[..., "rgba".index(ch)]
|
|
1801
|
+
else:
|
|
1802
|
+
raise ValueError(f"node {nd.name!r}: channel must be luma, r, g, b or a")
|
|
1803
|
+
if s.get("invert"):
|
|
1804
|
+
v = 1.0 - v
|
|
1805
|
+
v = v[::-1].T # [u, v]: u right, v up
|
|
1806
|
+
H, W = v.shape[1], v.shape[0]
|
|
1807
|
+
w, h = (float(x) for x in s["size"])
|
|
1808
|
+
if w <= 0 or h <= 0:
|
|
1809
|
+
raise ValueError(f"node {nd.name!r}: size must be positive")
|
|
1810
|
+
u0, v0 = (float(x) for x in s.get("origin", (0.0, 0.0)))
|
|
1811
|
+
iu, iv, ia = self._PLANES[plane]
|
|
1812
|
+
# a regular grid in world axes, two samples deep along the projection axis (the same everywhere)
|
|
1813
|
+
grid = np.repeat(v[:, :, None], 2, axis=2)
|
|
1814
|
+
order = np.argsort([iu, iv, ia])
|
|
1815
|
+
grid = np.transpose(grid, order)
|
|
1816
|
+
origin, pitch = np.zeros(3), np.zeros(3)
|
|
1817
|
+
origin[iu], pitch[iu] = u0 + 0.5 * w / W, w / W
|
|
1818
|
+
origin[iv], pitch[iv] = v0 + 0.5 * h / H, h / H
|
|
1819
|
+
origin[ia], pitch[ia] = -5e5, 1e6
|
|
1820
|
+
return _Samples(grid=grid, origin=origin, pitch=pitch)
|
|
1821
|
+
|
|
1822
|
+
def _samples(self, nd):
|
|
1823
|
+
s = nd.spec
|
|
1824
|
+
if nd.op == "image":
|
|
1825
|
+
return self._image(nd)
|
|
1826
|
+
if "source" in s:
|
|
1827
|
+
if s["source"] not in self.sources:
|
|
1828
|
+
raise ValueError(f"node {nd.name!r}: no in-memory source named {s['source']!r}")
|
|
1829
|
+
data = self.sources[s["source"]]
|
|
1830
|
+
elif "study" in s:
|
|
1831
|
+
data = self._analyze(nd)
|
|
1832
|
+
elif "path" in s:
|
|
1833
|
+
p = self._path(s["path"])
|
|
1834
|
+
data = dict(np.load(p)) if p.endswith(".npz") else json.load(open(p))
|
|
1835
|
+
else:
|
|
1836
|
+
raise ValueError(f"node {nd.name!r}: {nd.op} needs a 'path' (.npz or .json), a 'study' and "
|
|
1837
|
+
"'part' to analyze, or a 'source'")
|
|
1838
|
+
if isinstance(data, dict) and "cases" in data and "nodes" in data: # verify_part fields
|
|
1839
|
+
pts, vals = result_samples(data, s.get("quantity", "vonMises"), s.get("case"))
|
|
1840
|
+
return _Samples(pts, self._scaled(nd, vals))
|
|
1841
|
+
if isinstance(data, dict) and "grid" in data or nd.op == "grid":
|
|
1842
|
+
key = s.get("quantity", "values")
|
|
1843
|
+
if "origin" not in data or "pitch" not in data:
|
|
1844
|
+
raise ValueError(f"node {nd.name!r}: a grid needs origin, pitch and a 3D '{key}' array")
|
|
1845
|
+
return _Samples(grid=data.get("grid", data.get(key)), origin=data["origin"], pitch=data["pitch"])
|
|
1846
|
+
key = s.get("quantity", "values")
|
|
1847
|
+
if key not in data:
|
|
1848
|
+
raise ValueError(f"node {nd.name!r}: no {key!r} in the result (have {', '.join(data)})")
|
|
1849
|
+
return _Samples(data["points"], self._scaled(nd, np.asarray(data[key], float)))
|
|
1850
|
+
|
|
1851
|
+
@staticmethod
|
|
1852
|
+
def _scaled(nd, vals):
|
|
1853
|
+
"""`scale`: a number to divide by, or "peak" for the 99.5th
|
|
1854
|
+
percentile of the samples, so a remap can be written in fractions of
|
|
1855
|
+
the part's own worst stress without knowing it in advance (1.0 = the
|
|
1856
|
+
peak, ignoring the few hottest nodes at a load or support)."""
|
|
1857
|
+
sc = nd.spec.get("scale")
|
|
1858
|
+
if sc is None:
|
|
1859
|
+
return vals
|
|
1860
|
+
if sc == "peak":
|
|
1861
|
+
sc = float(np.percentile(vals, 99.5))
|
|
1862
|
+
if not float(sc) > 0:
|
|
1863
|
+
raise ValueError(f"node {nd.name!r}: scale must be positive or 'peak'")
|
|
1864
|
+
return vals / float(sc)
|
|
1865
|
+
|
|
1866
|
+
def _analyze(self, nd):
|
|
1867
|
+
"""verify_part fields of `part` under `study`: the stress a part
|
|
1868
|
+
actually carries, every load case. `study` is a JSON path or
|
|
1869
|
+
"example:<name>" (a packaged example); `part` a mesh path or the name
|
|
1870
|
+
of a node of this graph, meshed for the analysis (at `pitch`, default
|
|
1871
|
+
1 mm), so a graph can grade itself from its own solid's stress."""
|
|
1872
|
+
import trimesh
|
|
1873
|
+
|
|
1874
|
+
from .verify import verify_part
|
|
1875
|
+
|
|
1876
|
+
s = nd.spec
|
|
1877
|
+
if "part" not in s:
|
|
1878
|
+
raise ValueError(f"node {nd.name!r}: a result from a study needs the 'part' it analyzes")
|
|
1879
|
+
ref = s["study"] if s["study"].startswith("example:") else self._path(s["study"])
|
|
1880
|
+
study = load_study(ref, self.base_dir, self.doc.get("material"))
|
|
1881
|
+
if s["part"] in self.nodes:
|
|
1882
|
+
mesh = self.mesh(pitch=float(s.get("pitch", 1.0)), name=s["part"], project=1)
|
|
1883
|
+
else:
|
|
1884
|
+
mesh = trimesh.load(self._path(s["part"]), force="mesh")
|
|
1885
|
+
from . import quick
|
|
1886
|
+
|
|
1887
|
+
rep = verify_part(study, mesh, max_cells=quick.result_cells(int(s.get("cells", 120_000))), fields=True,
|
|
1888
|
+
refine=quick.result_refine(3))
|
|
1889
|
+
self.analyses[nd.name] = {k: v for k, v in rep.items() if k != "fields"}
|
|
1890
|
+
return rep["fields"]
|
|
1891
|
+
|
|
1892
|
+
def _lattice(self, nd, P, memo):
|
|
1893
|
+
s, r = nd.spec, nd.refs
|
|
1894
|
+
if isinstance(s.get("orient"), dict) and "zones" in s["orient"]:
|
|
1895
|
+
return self._lattice_zones(nd, P, memo)
|
|
1896
|
+
kind = s.get("type", "gyroid")
|
|
1897
|
+
from .struts import canonical, is_strut
|
|
1898
|
+
|
|
1899
|
+
frame = s.get("frame") or {}
|
|
1900
|
+
ftype = frame.get("type", "cartesian")
|
|
1901
|
+
if is_strut(kind) or ftype in ("conformal", "spherical") or "aspect" in s:
|
|
1902
|
+
return self._lattice_general(nd, P, memo, canonical(kind) if is_strut(kind) else kind)
|
|
1903
|
+
cell = self._val(r["cell"], P, memo) if "cell" in r else 8.0
|
|
1904
|
+
Q = P
|
|
1905
|
+
if "origin" in s:
|
|
1906
|
+
Q = P - np.asarray(s["origin"], float)
|
|
1907
|
+
R = lattice_orientation(nd)
|
|
1908
|
+
if R is not None:
|
|
1909
|
+
Q = Q @ R
|
|
1910
|
+
if isinstance(r.get("cell"), str) and s.get("grading", "warp") == "warp":
|
|
1911
|
+
from .lattice import tpms
|
|
1912
|
+
|
|
1913
|
+
if s.get("frame"):
|
|
1914
|
+
raise ValueError(f"node {nd.name!r}: a graded cell (warp) works in the cartesian frame only; "
|
|
1915
|
+
"use grading: 'blend' with a cylindrical frame")
|
|
1916
|
+
warp = self._resource(nd, lambda: self._warp(nd))
|
|
1917
|
+
d = tpms(_TPMS_KIND[kind], warp(Q, cell), 1.0) * np.asarray(cell, float)
|
|
1918
|
+
else:
|
|
1919
|
+
d = _tpms_distance(kind, Q, cell, s.get("frame"))
|
|
1920
|
+
if "offset" in r:
|
|
1921
|
+
d = d - self._val(r["offset"], P, memo)
|
|
1922
|
+
if "thickness" in r:
|
|
1923
|
+
t = np.asarray(self._val(r["thickness"], P, memo), float)
|
|
1924
|
+
elif "density" in r:
|
|
1925
|
+
from .lattice import wall_for_density
|
|
1926
|
+
|
|
1927
|
+
rho = np.clip(np.asarray(self._val(r["density"], P, memo), float), 0.05, 0.95)
|
|
1928
|
+
rho, cl = np.broadcast_arrays(rho, np.asarray(cell, float))
|
|
1929
|
+
t = wall_for_density(_TPMS_KIND[kind], rho, cl)
|
|
1930
|
+
else:
|
|
1931
|
+
t = 1.0
|
|
1932
|
+
form = s.get("form", "sheet")
|
|
1933
|
+
if form == "sheet":
|
|
1934
|
+
return np.abs(d) - 0.5 * t
|
|
1935
|
+
if form in ("network", "_plus"):
|
|
1936
|
+
return d - 0.5 * t
|
|
1937
|
+
if form == "_minus": # internal (latticestep): the sheet's other side, d < -t/2 empty
|
|
1938
|
+
return -d - 0.5 * t
|
|
1939
|
+
raise ValueError(f"node {nd.name!r}: lattice form must be 'sheet' or 'network'")
|
|
1940
|
+
|
|
1941
|
+
def _lattice_zones(self, nd, P, memo):
|
|
1942
|
+
"""A lattice whose cells turn from zone to zone (orient.zones): each
|
|
1943
|
+
zone's lattice, turned by its own rotation; where zones meet (their
|
|
1944
|
+
weights, orient_weights) both lattices are there, each worn away
|
|
1945
|
+
as its share falls."""
|
|
1946
|
+
import copy as _copy
|
|
1947
|
+
|
|
1948
|
+
zones = nd.spec["orient"]["zones"]
|
|
1949
|
+
seam = float(nd.spec["orient"].get("seam") or 0.0)
|
|
1950
|
+
plate = None
|
|
1951
|
+
if seam > 0:
|
|
1952
|
+
# a solid plate `seam` mm thick on each face between zones: the
|
|
1953
|
+
# cells of both zones end on it the way they end on a skin.
|
|
1954
|
+
# Without one, two directions of thin struts meet at a few small
|
|
1955
|
+
# joints, and that seam read 1.5-4x too stiff (lattice_seams bench)
|
|
1956
|
+
plate = seam_distance([z["at"] for z in zones], P) - 0.5 * seam
|
|
1957
|
+
if nd.spec.get("_seamOnly"):
|
|
1958
|
+
return plate
|
|
1959
|
+
w = orient_weights(nd, P)
|
|
1960
|
+
wmax = w.max(axis=1)
|
|
1961
|
+
cell = nd.spec.get("cell", 8.0)
|
|
1962
|
+
reach = 0.5 * float(cell if isinstance(cell, (int, float)) else 8.0)
|
|
1963
|
+
out = np.full(len(P), np.inf)
|
|
1964
|
+
for i, z in enumerate(zones):
|
|
1965
|
+
share = w[:, i] / wmax
|
|
1966
|
+
near = share > 0.05
|
|
1967
|
+
if not near.any():
|
|
1968
|
+
continue
|
|
1969
|
+
sub = _copy.copy(nd)
|
|
1970
|
+
sub.spec = {**nd.spec, "orient": list(z.get("rotate", (0.0, 0.0, 0.0)))}
|
|
1971
|
+
# each zone's lattice, worn away (by up to half a cell) as its
|
|
1972
|
+
# share falls: where two zones meet both are there (a union, so
|
|
1973
|
+
# the struts on either side stay joined), away from it only one.
|
|
1974
|
+
# Averaging the two fields instead left gaps in the seam
|
|
1975
|
+
f = np.asarray(self._lattice(sub, P, memo), float)
|
|
1976
|
+
out = np.minimum(out, np.where(near, f + reach * (1.0 - share), np.inf))
|
|
1977
|
+
return out if plate is None else np.minimum(out, plate)
|
|
1978
|
+
|
|
1979
|
+
def lattice_coords(self, nd, P, memo=None):
|
|
1980
|
+
"""(U, cell): a lattice node's coordinates at P (period 1 a cell)
|
|
1981
|
+
and its local cell (mm, per point), whatever its frame and grading.
|
|
1982
|
+
Voronoi foams have no coordinates (None)."""
|
|
1983
|
+
memo = {} if memo is None else memo
|
|
1984
|
+
s, r = nd.spec, nd.refs
|
|
1985
|
+
cell = self._val(r["cell"], P, memo) if "cell" in r else 8.0
|
|
1986
|
+
cell = np.broadcast_to(np.asarray(cell, float), (len(P),))
|
|
1987
|
+
Q = P - np.asarray(s["origin"], float) if "origin" in s else P
|
|
1988
|
+
R = lattice_orientation(nd)
|
|
1989
|
+
if R is not None:
|
|
1990
|
+
Q = Q @ R # the cell's axes are R's columns
|
|
1991
|
+
frame = s.get("frame") or {}
|
|
1992
|
+
ftype = frame.get("type", "cartesian")
|
|
1993
|
+
aspect = np.asarray(s.get("aspect", (1, 1, 1)), float)
|
|
1994
|
+
if ftype == "conformal":
|
|
1995
|
+
cmap = self._resource(nd, lambda: self._conformal(nd))
|
|
1996
|
+
U, coh = cmap(Q, cell, surface=self._val(r["surface"], P, memo), coherence=True)
|
|
1997
|
+
memo[("_defect_fill", nd.name)] = _ConformalMap.defect_fill(coh)
|
|
1998
|
+
elif ftype in ("cylindrical", "spherical"):
|
|
1999
|
+
if isinstance(r.get("cell"), str):
|
|
2000
|
+
raise ValueError(f"node {nd.name!r}: a {ftype} frame takes a constant cell; grade the struts' "
|
|
2001
|
+
"thickness instead, or use a conformal frame")
|
|
2002
|
+
U = _frame_coords(Q, frame, cell[:1])
|
|
2003
|
+
elif isinstance(r.get("cell"), str):
|
|
2004
|
+
warp = self._resource(nd, lambda: self._warp(nd))
|
|
2005
|
+
U = warp(Q, cell)
|
|
2006
|
+
else:
|
|
2007
|
+
U = Q / cell[:, None]
|
|
2008
|
+
if "aspect" in s:
|
|
2009
|
+
U = U / aspect
|
|
2010
|
+
return U, cell
|
|
2011
|
+
|
|
2012
|
+
def _lattice_general(self, nd, P, memo, kind):
|
|
2013
|
+
"""Strut lattices (any frame), and TPMS in the conformal and
|
|
2014
|
+
spherical frames or with a stretched cell: distance in cell units
|
|
2015
|
+
through lattice_coords, scaled back to mm by the local cell."""
|
|
2016
|
+
from . import struts
|
|
2017
|
+
|
|
2018
|
+
s, r = nd.spec, nd.refs
|
|
2019
|
+
strut = struts.is_strut(kind)
|
|
2020
|
+
if not strut and _TPMS_KIND.get(kind) is None:
|
|
2021
|
+
raise ValueError(f"node {nd.name!r}: lattice type must be a TPMS ({', '.join(sorted(set(_TPMS_KIND.values())))})"
|
|
2022
|
+
f" or a strut cell ({', '.join(struts.STRUT_KINDS)})")
|
|
2023
|
+
form = s.get("form", "beam" if strut else "sheet")
|
|
2024
|
+
if strut and form != "beam":
|
|
2025
|
+
raise ValueError(f"node {nd.name!r}: a strut lattice's form is 'beam' (got {form!r})")
|
|
2026
|
+
blend = np.asarray(self._val(r["blend"], P, memo), float) if "blend" in r else 0.0
|
|
2027
|
+
if kind == "voronoi":
|
|
2028
|
+
if (s.get("frame") or {}).get("type", "cartesian") != "cartesian" or "aspect" in s or "orient" in s:
|
|
2029
|
+
raise ValueError(f"node {nd.name!r}: a voronoi foam is cartesian (no frame, aspect or orient)")
|
|
2030
|
+
vor = self._resource(nd, lambda: self._voronoi(nd))
|
|
2031
|
+
Q = P - np.asarray(s["origin"], float) if "origin" in s else P
|
|
2032
|
+
cell = np.broadcast_to(np.asarray(self._val(r["cell"], P, memo) if "cell" in r else 8.0, float),
|
|
2033
|
+
(len(P),))
|
|
2034
|
+
d_c = vor.distance(Q, blend) # mm
|
|
2035
|
+
scale = 1.0
|
|
2036
|
+
else:
|
|
2037
|
+
U, cell = self.lattice_coords(nd, P, memo)
|
|
2038
|
+
if strut:
|
|
2039
|
+
aspect = np.asarray(s.get("aspect", (1, 1, 1)), float) if "aspect" in s else None
|
|
2040
|
+
d_c = struts.unit_cell_distance(kind, U, np.asarray(blend, float) / cell, aspect=aspect)
|
|
2041
|
+
else:
|
|
2042
|
+
from .lattice import tpms
|
|
2043
|
+
|
|
2044
|
+
d_c = tpms(_TPMS_KIND[kind], U, 1.0)
|
|
2045
|
+
if "aspect" in s:
|
|
2046
|
+
d_c = d_c * float(np.min(s["aspect"]))
|
|
2047
|
+
fill = memo.pop(("_defect_fill", nd.name), None)
|
|
2048
|
+
if fill is not None:
|
|
2049
|
+
d_c = d_c * fill
|
|
2050
|
+
scale = cell
|
|
2051
|
+
d = d_c * scale
|
|
2052
|
+
if "offset" in r:
|
|
2053
|
+
d = d - self._val(r["offset"], P, memo)
|
|
2054
|
+
bl = float(np.mean(blend)) if np.ndim(blend) else float(blend)
|
|
2055
|
+
if "thickness" in r:
|
|
2056
|
+
t = np.asarray(self._val(r["thickness"], P, memo), float)
|
|
2057
|
+
elif "density" in r:
|
|
2058
|
+
rho = np.clip(np.asarray(self._val(r["density"], P, memo), float), 0.02 if strut else 0.05, 0.95)
|
|
2059
|
+
rho, cl = np.broadcast_arrays(rho, np.asarray(cell, float))
|
|
2060
|
+
if strut:
|
|
2061
|
+
t = struts.strut_diameter_for_density(kind, rho, cl, blend=bl / float(np.mean(cl)))
|
|
2062
|
+
else:
|
|
2063
|
+
from .lattice import wall_for_density
|
|
2064
|
+
|
|
2065
|
+
t = wall_for_density(_TPMS_KIND[kind], rho, cl)
|
|
2066
|
+
else:
|
|
2067
|
+
t = 1.0
|
|
2068
|
+
if strut:
|
|
2069
|
+
return d - 0.5 * t
|
|
2070
|
+
if form == "sheet":
|
|
2071
|
+
return np.abs(d) - 0.5 * t
|
|
2072
|
+
if form in ("network", "_plus"):
|
|
2073
|
+
return d - 0.5 * t
|
|
2074
|
+
if form == "_minus": # internal (latticestep): the sheet's other side, d < -t/2 empty
|
|
2075
|
+
return -d - 0.5 * t
|
|
2076
|
+
raise ValueError(f"node {nd.name!r}: lattice form must be 'sheet' or 'network'")
|
|
2077
|
+
|
|
2078
|
+
def _conformal(self, nd, max_cells=40_000):
|
|
2079
|
+
"""The surface-following map over the lattice's domain."""
|
|
2080
|
+
frame = nd.spec["frame"]
|
|
2081
|
+
cell_fn, lo, hi = self._warp_domain(nd)
|
|
2082
|
+
off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
|
|
2083
|
+
surf = nd.refs["surface"]
|
|
2084
|
+
mode = frame.get("frameField", "auto")
|
|
2085
|
+
|
|
2086
|
+
def build(ff):
|
|
2087
|
+
return _ConformalMap(lambda G: self.evaluate(G + off, surf), cell_fn, lo, hi, axis=frame.get("axis"),
|
|
2088
|
+
normal_cell=frame.get("normalCell"), max_cells=max_cells,
|
|
2089
|
+
layer_smoothing=frame.get("layerSmoothing", 0.0), frame_field=ff)
|
|
2090
|
+
|
|
2091
|
+
if mode != "auto":
|
|
2092
|
+
return build(bool(mode))
|
|
2093
|
+
# auto: the frame field turns a box's edges and corners (sheared
|
|
2094
|
+
# cells 13-46% -> 0.1% on boxes, turned, rounded or slabs), but on a
|
|
2095
|
+
# rounded part its cross field puts singular points on flat faces
|
|
2096
|
+
# the two stripes ran straight across (the tutorial forearm's skin:
|
|
2097
|
+
# 10% sheared with stripes, 14% with the field). Both are built and
|
|
2098
|
+
# the one with fewer sheared cells in the first cell under the skin
|
|
2099
|
+
# is kept
|
|
2100
|
+
maps = [build(True), build(False)]
|
|
2101
|
+
scores = [m.skin_shear(cell_fn) for m in maps]
|
|
2102
|
+
best = maps[int(np.argmin([sc + (0.01 if k else 0.0) for k, sc in enumerate(scores)]))]
|
|
2103
|
+
best.shear_scores = {"frameField": round(scores[0], 4), "stripes": round(scores[1], 4)}
|
|
2104
|
+
return best
|
|
2105
|
+
|
|
2106
|
+
def _voronoi(self, nd):
|
|
2107
|
+
from .struts import VoronoiStruts
|
|
2108
|
+
|
|
2109
|
+
cell_fn, lo, hi = self._warp_domain(nd)
|
|
2110
|
+
return VoronoiStruts(lo, hi, cell_fn, seed=int(nd.spec.get("seed", 0)))
|
|
2111
|
+
|
|
2112
|
+
def _warp(self, nd, max_cells=400_000):
|
|
2113
|
+
"""The cell-grading map over the lattice's domain: its own bounds,
|
|
2114
|
+
else the graph's, else the output solid's box."""
|
|
2115
|
+
cell_fn, lo, hi = self._warp_domain(nd)
|
|
2116
|
+
return _CellWarp(cell_fn, lo, hi, max_cells=max_cells)
|
|
2117
|
+
|
|
2118
|
+
def _warp_domain(self, nd):
|
|
2119
|
+
"""(cell field, lo, hi) the warp is solved over, in the lattice's own
|
|
2120
|
+
coordinates (less its origin)."""
|
|
2121
|
+
if "bounds" in nd.spec:
|
|
2122
|
+
lo, hi = nd.spec["bounds"]["min"], nd.spec["bounds"]["max"]
|
|
2123
|
+
elif "bounds" in self.doc:
|
|
2124
|
+
lo, hi = self.doc["bounds"]["min"], self.doc["bounds"]["max"]
|
|
2125
|
+
else:
|
|
2126
|
+
b = self.bounds()
|
|
2127
|
+
if b is None:
|
|
2128
|
+
raise ValueError(f"node {nd.name!r}: a graded, conformal or voronoi lattice needs a domain: give the lattice or the "
|
|
2129
|
+
"graph 'bounds'")
|
|
2130
|
+
lo, hi = b
|
|
2131
|
+
off = np.asarray(nd.spec.get("origin", (0, 0, 0)), float)
|
|
2132
|
+
cell_ref = nd.refs.get("cell", 8.0)
|
|
2133
|
+
if isinstance(cell_ref, float):
|
|
2134
|
+
return (lambda G, c=cell_ref: np.full(len(G), c)), np.asarray(lo, float) - off, np.asarray(hi, float) - off
|
|
2135
|
+
return lambda G: self.evaluate(G + off, cell_ref), np.asarray(lo, float) - off, np.asarray(hi, float) - off
|
|
2136
|
+
|
|
2137
|
+
def _transform(self, nd, P, memo):
|
|
2138
|
+
Q = self.transform_points(nd, P)
|
|
2139
|
+
scale = float(nd.spec.get("scale", 1.0))
|
|
2140
|
+
out = self._val(nd.refs["input"], Q, memo)
|
|
2141
|
+
return out * scale
|
|
2142
|
+
|
|
2143
|
+
def transform_points(self, nd, P):
|
|
2144
|
+
"""Where a transform node samples its input for points P."""
|
|
2145
|
+
s = nd.spec
|
|
2146
|
+
Q = P
|
|
2147
|
+
if "translate" in s:
|
|
2148
|
+
Q = Q - np.asarray(s["translate"], float)
|
|
2149
|
+
if "rotate" in s:
|
|
2150
|
+
rot = s["rotate"]
|
|
2151
|
+
ax = rot.get("axis", "z")
|
|
2152
|
+
a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str) else ax,
|
|
2153
|
+
float)
|
|
2154
|
+
a = a / np.linalg.norm(a)
|
|
2155
|
+
c = np.asarray(rot.get("center", (0, 0, 0)), float)
|
|
2156
|
+
th = -math.radians(float(rot["degrees"]))
|
|
2157
|
+
K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
|
|
2158
|
+
R = np.eye(3) + math.sin(th) * K + (1 - math.cos(th)) * K @ K
|
|
2159
|
+
Q = (Q - c) @ R.T + c
|
|
2160
|
+
scale = float(s.get("scale", 1.0))
|
|
2161
|
+
if scale != 1.0:
|
|
2162
|
+
Q = Q / scale
|
|
2163
|
+
return Q
|
|
2164
|
+
|
|
2165
|
+
# -- bounds --
|
|
2166
|
+
|
|
2167
|
+
def bounds(self, name=None):
|
|
2168
|
+
"""(lo, hi) of a solid node, or None when it is unbounded (a bare
|
|
2169
|
+
lattice). Booleans combine their inputs' boxes; shells and offsets
|
|
2170
|
+
grow them by the largest thickness they could have (a field one is
|
|
2171
|
+
estimated from its values over the input's box)."""
|
|
2172
|
+
b = self._bounds(name or self.output, set())
|
|
2173
|
+
return None if b is None else (np.asarray(b[0], float), np.asarray(b[1], float))
|
|
2174
|
+
|
|
2175
|
+
def _bounds(self, name, seen):
|
|
2176
|
+
if name in seen:
|
|
2177
|
+
return None
|
|
2178
|
+
seen = seen | {name}
|
|
2179
|
+
nd = self.nodes[name]
|
|
2180
|
+
s, r = nd.spec, nd.refs
|
|
2181
|
+
if "bounds" in s:
|
|
2182
|
+
return np.asarray(s["bounds"]["min"], float), np.asarray(s["bounds"]["max"], float)
|
|
2183
|
+
if nd.op == "shape":
|
|
2184
|
+
from .shapes import shape_from_spec
|
|
2185
|
+
|
|
2186
|
+
return self._resource(nd, lambda: shape_from_spec(s["shape"], self.base_dir)).bounds()
|
|
2187
|
+
if nd.op == "mesh":
|
|
2188
|
+
mf = self._resource(nd, lambda: self._mesh_field(nd))
|
|
2189
|
+
return mf.lo.copy(), mf.hi.copy()
|
|
2190
|
+
sub = [self._bounds(x, seen) for x in r.get("inputs", [])]
|
|
2191
|
+
if nd.op == "union":
|
|
2192
|
+
if any(b is None for b in sub):
|
|
2193
|
+
return None
|
|
2194
|
+
lo, hi = np.min([b[0] for b in sub], axis=0), np.max([b[1] for b in sub], axis=0)
|
|
2195
|
+
# a smooth union bulges past both inputs by up to blend / 4
|
|
2196
|
+
g = 0.25 * max(self._max_over(r.get("blend"), (lo, hi)), 0.0)
|
|
2197
|
+
return lo - g, hi + g
|
|
2198
|
+
if nd.op == "intersect":
|
|
2199
|
+
sub = [b for b in sub if b is not None]
|
|
2200
|
+
if not sub:
|
|
2201
|
+
return None
|
|
2202
|
+
lo, hi = np.max([b[0] for b in sub], axis=0), np.min([b[1] for b in sub], axis=0)
|
|
2203
|
+
return lo, np.maximum(hi, lo)
|
|
2204
|
+
if nd.op == "subtract":
|
|
2205
|
+
return sub[0]
|
|
2206
|
+
if nd.op in ("shell", "offset") and isinstance(r.get("input"), str):
|
|
2207
|
+
b = self._bounds(r["input"], seen)
|
|
2208
|
+
if b is None:
|
|
2209
|
+
return None
|
|
2210
|
+
grow = self._max_over(r.get("thickness" if nd.op == "shell" else "distance"), b)
|
|
2211
|
+
if nd.op == "shell" and s.get("side", "inside") == "inside":
|
|
2212
|
+
grow = 0.0
|
|
2213
|
+
g = max(grow, 0.0) + 1e-6
|
|
2214
|
+
return b[0] - g, b[1] + g
|
|
2215
|
+
if nd.op == "transform" and isinstance(r.get("input"), str):
|
|
2216
|
+
b = self._bounds(r["input"], seen)
|
|
2217
|
+
if b is None:
|
|
2218
|
+
return None
|
|
2219
|
+
# a rotated box's box: transform its corners
|
|
2220
|
+
corners = np.array([[b[i][0], b[j][1], b[k][2]] for i in (0, 1) for j in (0, 1) for k in (0, 1)])
|
|
2221
|
+
sc = float(s.get("scale", 1.0))
|
|
2222
|
+
Q = corners * sc
|
|
2223
|
+
if "rotate" in s:
|
|
2224
|
+
rot = s["rotate"]
|
|
2225
|
+
ax = rot.get("axis", "z")
|
|
2226
|
+
a = np.asarray({"x": (1, 0, 0), "y": (0, 1, 0), "z": (0, 0, 1)}[ax] if isinstance(ax, str)
|
|
2227
|
+
else ax, float)
|
|
2228
|
+
a = a / np.linalg.norm(a)
|
|
2229
|
+
c = np.asarray(rot.get("center", (0, 0, 0)), float)
|
|
2230
|
+
th = math.radians(float(rot["degrees"]))
|
|
2231
|
+
K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
|
|
2232
|
+
R = np.eye(3) + math.sin(th) * K + (1 - math.cos(th)) * K @ K
|
|
2233
|
+
Q = (Q - c) @ R.T + c
|
|
2234
|
+
Q = Q + np.asarray(s.get("translate", (0, 0, 0)), float)
|
|
2235
|
+
return Q.min(axis=0), Q.max(axis=0)
|
|
2236
|
+
return None
|
|
2237
|
+
|
|
2238
|
+
def _max_over(self, ref, b, n=24):
|
|
2239
|
+
if ref is None:
|
|
2240
|
+
return 0.0
|
|
2241
|
+
if isinstance(ref, float):
|
|
2242
|
+
return ref
|
|
2243
|
+
axes = [np.linspace(b[0][i], b[1][i], n) for i in range(3)]
|
|
2244
|
+
G = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
|
|
2245
|
+
return float(np.max(self.evaluate(G, ref))) * 1.1
|
|
2246
|
+
|
|
2247
|
+
# -- meshing --
|
|
2248
|
+
|
|
2249
|
+
def mesh(self, pitch=None, bounds=None, project=None, name=None, progress=None, sharp=None, creases=None):
|
|
2250
|
+
"""`sharp` (default the graph's mesh.sharp, else true): hard booleans
|
|
2251
|
+
with plain shapes and meshes are done exactly (sharp.py), so bores,
|
|
2252
|
+
flat faces and keep-outs keep their edges; false meshes the whole
|
|
2253
|
+
field, every crease rounded over about one pitch.
|
|
2254
|
+
|
|
2255
|
+
`creases` (default the graph's mesh.creases, else true): hard
|
|
2256
|
+
booleans between field surfaces (a lattice clipped by a body, two
|
|
2257
|
+
bodies unioned without a blend, a shell's rim) get their creases back
|
|
2258
|
+
on the meshed field (crease.py)."""
|
|
2259
|
+
m = self.doc.get("mesh") or {}
|
|
2260
|
+
pitch = pitch or m.get("pitch")
|
|
2261
|
+
project = m.get("project", 3) if project is None else project
|
|
2262
|
+
sharp = m.get("sharp", True) if sharp is None else sharp
|
|
2263
|
+
creases = m.get("creases", True) if creases is None else creases
|
|
2264
|
+
if bounds is None and "bounds" in self.doc:
|
|
2265
|
+
bounds = (self.doc["bounds"]["min"], self.doc["bounds"]["max"])
|
|
2266
|
+
if bounds is None:
|
|
2267
|
+
bounds = self.bounds(name)
|
|
2268
|
+
if bounds is None:
|
|
2269
|
+
raise ValueError(f"{name or self.output!r} is unbounded (a lattice with nothing to clip it): "
|
|
2270
|
+
"intersect it with a solid or give the graph 'bounds'")
|
|
2271
|
+
from . import quick
|
|
2272
|
+
pitch = quick.mesh_pitch(pitch, bounds)
|
|
2273
|
+
fallback = None
|
|
2274
|
+
if sharp:
|
|
2275
|
+
from .sharp import has_exact, mesh_plan, plan
|
|
2276
|
+
|
|
2277
|
+
p = plan(self, name)
|
|
2278
|
+
if has_exact(p):
|
|
2279
|
+
# set after meshing: a result node meshes another node on the way
|
|
2280
|
+
try:
|
|
2281
|
+
mesh = mesh_plan(p, bounds[0], bounds[1], pitch=pitch, project=project, progress=progress,
|
|
2282
|
+
decimate=m.get("decimate"), creases=self if creases else None)
|
|
2283
|
+
except ValueError as exc:
|
|
2284
|
+
# a field leaf whose mesh the exact booleans can't take
|
|
2285
|
+
# (a fine lattice's mesh that isn't a valid manifold even
|
|
2286
|
+
# after repair): mesh the whole field instead, the hard
|
|
2287
|
+
# edges rounded over a pitch, rather than fail the part
|
|
2288
|
+
if "not a valid manifold" not in str(exc):
|
|
2289
|
+
raise
|
|
2290
|
+
fallback = str(exc)
|
|
2291
|
+
else:
|
|
2292
|
+
self.plan = p
|
|
2293
|
+
return mesh
|
|
2294
|
+
tree = None
|
|
2295
|
+
if creases:
|
|
2296
|
+
from .crease import CreaseTree
|
|
2297
|
+
|
|
2298
|
+
tree = CreaseTree.from_graph(self, name)
|
|
2299
|
+
mesh = field_mesh(self.field(name), bounds[0], bounds[1], pitch=pitch, project=project, progress=progress,
|
|
2300
|
+
decimate=m.get("decimate"), creases=tree)
|
|
2301
|
+
if fallback:
|
|
2302
|
+
mesh.metadata["sharpFallback"] = fallback
|
|
2303
|
+
self.plan = None
|
|
2304
|
+
return mesh
|
|
2305
|
+
|
|
2306
|
+
|
|
2307
|
+
def _run_streamed(doc, g, out_dir, drains, process, progress, t0):
|
|
2308
|
+
"""run_field with mesh.stream: the STL goes to disk slab by slab
|
|
2309
|
+
(field_stl) and is never held, so the checks that need the whole mesh
|
|
2310
|
+
(printability, drains' mesh escape, bodies, the render) are skipped."""
|
|
2311
|
+
import time
|
|
2312
|
+
|
|
2313
|
+
if not out_dir:
|
|
2314
|
+
raise ValueError("mesh.stream writes the STL as it goes: give an output folder")
|
|
2315
|
+
m = doc.get("mesh") or {}
|
|
2316
|
+
bounds = (doc["bounds"]["min"], doc["bounds"]["max"]) if "bounds" in doc else g.bounds()
|
|
2317
|
+
if bounds is None:
|
|
2318
|
+
raise ValueError(f"{g.output!r} is unbounded (a lattice with nothing to clip it): "
|
|
2319
|
+
"intersect it with a solid or give the graph 'bounds'")
|
|
2320
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
2321
|
+
name = doc.get("name", "field")
|
|
2322
|
+
path = os.path.join(out_dir, f"{name}.stl")
|
|
2323
|
+
st = field_stl(g.field(), bounds[0], bounds[1], path, pitch=m.get("pitch"), project=m.get("project", 3),
|
|
2324
|
+
progress=progress, decimate=m.get("decimate"))
|
|
2325
|
+
rep = {"name": name, "output": g.output, "nodes": len(g.nodes), "streamed": True,
|
|
2326
|
+
**{k: st[k] for k in ("pitch", "faces", "watertight", "volume", "area", "slabs", "maxSlabFaces",
|
|
2327
|
+
"droppedShells", "sampling")}, "stl": path}
|
|
2328
|
+
if m.get("decimate"):
|
|
2329
|
+
rep["decimation"] = {"tolerance": float(m["decimate"]), "facesBefore": st["facesBeforeDecimation"]}
|
|
2330
|
+
if process is not None:
|
|
2331
|
+
rep["process"] = process.kind
|
|
2332
|
+
if drains is not None:
|
|
2333
|
+
rep["drains"] = drains
|
|
2334
|
+
if "material" in doc:
|
|
2335
|
+
from .materials import material_from_spec
|
|
2336
|
+
|
|
2337
|
+
mat = material_from_spec(doc["material"])
|
|
2338
|
+
rep["mass"] = rep["volume"] * mat.density / 1000.0
|
|
2339
|
+
rep["material"] = mat.name
|
|
2340
|
+
rep["skipped"] = ["printability", "bodies", "render"]
|
|
2341
|
+
rep["seconds"] = round(time.time() - t0, 1)
|
|
2342
|
+
return rep
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
def _remap(x, s, name):
|
|
2346
|
+
"""`from` [a, b] -> `to` [c, d] through `curve` (a name, or points
|
|
2347
|
+
[[u, v], ...] over the unit square drawn in the editor); clamped unless
|
|
2348
|
+
clamp: false. With more than two points, `from` (ascending) and `to`
|
|
2349
|
+
are a piecewise-linear table (always clamped at its ends), e.g. a
|
|
2350
|
+
density per layer from impact.grade_for_impact."""
|
|
2351
|
+
if len(s.get("from", ())) > 2 or len(s.get("to", ())) > 2:
|
|
2352
|
+
xs, ys = np.asarray(s["from"], float), np.asarray(s["to"], float)
|
|
2353
|
+
if len(xs) != len(ys) or np.any(np.diff(xs) <= 0):
|
|
2354
|
+
raise ValueError(f"node {name!r}: a remap table needs equal-length 'from' (ascending) and 'to'")
|
|
2355
|
+
return np.interp(np.asarray(x, float), xs, ys)
|
|
2356
|
+
a, b = (float(u) for u in s.get("from", (0.0, 1.0)))
|
|
2357
|
+
c, d = (float(u) for u in s.get("to", (0.0, 1.0)))
|
|
2358
|
+
if a == b:
|
|
2359
|
+
raise ValueError(f"node {name!r}: remap 'from' needs two different values")
|
|
2360
|
+
u = (np.asarray(x, float) - a) / (b - a)
|
|
2361
|
+
if s.get("clamp", True):
|
|
2362
|
+
u = np.clip(u, 0.0, 1.0)
|
|
2363
|
+
curve = s.get("curve", "linear")
|
|
2364
|
+
if isinstance(curve, (list, tuple)):
|
|
2365
|
+
# a drawn curve: points (u, v) over the unit square, u ascending from 0 to 1, straight between them
|
|
2366
|
+
pts = np.asarray(curve, float)
|
|
2367
|
+
if pts.ndim != 2 or pts.shape[1] != 2 or len(pts) < 2 or np.any(np.diff(pts[:, 0]) <= 0):
|
|
2368
|
+
raise ValueError(f"node {name!r}: a remap curve as points needs [[u, v], ...] with u ascending")
|
|
2369
|
+
return c + (d - c) * np.interp(np.clip(u, pts[0, 0], pts[-1, 0]), pts[:, 0], pts[:, 1])
|
|
2370
|
+
if curve == "smoothstep":
|
|
2371
|
+
uc = np.clip(u, 0.0, 1.0)
|
|
2372
|
+
u = uc * uc * (3 - 2 * uc)
|
|
2373
|
+
elif curve == "smootherstep":
|
|
2374
|
+
uc = np.clip(u, 0.0, 1.0)
|
|
2375
|
+
u = uc ** 3 * (uc * (6 * uc - 15) + 10)
|
|
2376
|
+
elif curve == "sqrt":
|
|
2377
|
+
u = np.sqrt(np.clip(u, 0.0, None))
|
|
2378
|
+
elif curve == "square":
|
|
2379
|
+
u = u * u
|
|
2380
|
+
elif curve != "linear":
|
|
2381
|
+
raise ValueError(f"node {name!r}: remap curve must be linear, smoothstep, smootherstep, sqrt or square")
|
|
2382
|
+
return c + (d - c) * u
|
|
2383
|
+
|
|
2384
|
+
|
|
2385
|
+
# ---- meshing ---------------------------------------------------------------------
|
|
2386
|
+
|
|
2387
|
+
def field_mesh(field, lo, hi, pitch=None, project=3, pad=2, progress=None, sparse=True, decimate=None, creases=None):
|
|
2388
|
+
"""Smooth, watertight trimesh of field(P) < 0 over [lo, hi] (mm).
|
|
2389
|
+
|
|
2390
|
+
Marching cubes on a grid at `pitch` (default: the box over 4M cells,
|
|
2391
|
+
and at most the 60M budget), then `project` rounds of relaxing the
|
|
2392
|
+
vertices along the surface and Newton-projecting them onto the field's
|
|
2393
|
+
zero level (see _relax). Marching cubes alone puts vertices on the
|
|
2394
|
+
grid's edges, up to ~pitch/3 off a curved surface; after the
|
|
2395
|
+
projection every vertex is on the surface to 1e-5 mm or so, which is
|
|
2396
|
+
what removes the grid from the part.
|
|
2397
|
+
|
|
2398
|
+
`sparse` (default) samples the field only in blocks near its zero
|
|
2399
|
+
level (narrowband.py): the same marching-cubes mesh as the dense grid,
|
|
2400
|
+
with far fewer field evaluations and memory for one batch of blocks,
|
|
2401
|
+
so the cell budget applies to the sampled cells, not the box.
|
|
2402
|
+
|
|
2403
|
+
`decimate` (mm) thins the triangles wherever the mesh stays that close
|
|
2404
|
+
to the surface (decimate.py): flat and gently curved regions lose most
|
|
2405
|
+
of theirs, creases and small features keep them.
|
|
2406
|
+
|
|
2407
|
+
`creases` (a crease.CreaseTree of the field: its hard min/max over
|
|
2408
|
+
smooth leaves) puts the creases between field surfaces back after the
|
|
2409
|
+
projection: the edges that cross from one leaf's surface to another's
|
|
2410
|
+
are split on the true intersection curve (crease.py)."""
|
|
2411
|
+
import trimesh
|
|
2412
|
+
from skimage.measure import marching_cubes
|
|
2413
|
+
|
|
2414
|
+
lo = np.asarray(lo, float)
|
|
2415
|
+
hi = np.asarray(hi, float)
|
|
2416
|
+
ext = hi - lo
|
|
2417
|
+
if pitch is None:
|
|
2418
|
+
pitch = float((np.prod(ext) / 4e6) ** (1 / 3))
|
|
2419
|
+
pitch = float(pitch)
|
|
2420
|
+
lo = lo - pad * pitch - pitch * 0.013717 # off-grid, as sdf_kit.to_mesh
|
|
2421
|
+
dims = np.ceil((hi + pad * pitch - lo) / pitch).astype(int) + 1
|
|
2422
|
+
cells = int(np.prod(dims))
|
|
2423
|
+
if sparse:
|
|
2424
|
+
from .narrowband import narrowband_mc, watertight
|
|
2425
|
+
|
|
2426
|
+
verts, faces, stats = narrowband_mc(field, lo, dims, pitch, budget=_CELL_BUDGET, progress=progress)
|
|
2427
|
+
if not len(faces):
|
|
2428
|
+
raise ValueError("the field has no inside over these bounds (nothing is below zero)")
|
|
2429
|
+
if not watertight(faces, len(verts)) and stats.get("lipschitz"):
|
|
2430
|
+
# the per-leaf Lipschitz bounds let a crossing through: the
|
|
2431
|
+
# global one (the steepest slope anywhere) for every leaf
|
|
2432
|
+
verts, faces, stats = narrowband_mc(field, lo, dims, pitch, lipschitz=stats["lipschitz"],
|
|
2433
|
+
budget=_CELL_BUDGET, progress=progress)
|
|
2434
|
+
stats["fallback"] = "global"
|
|
2435
|
+
if not watertight(faces, len(verts)):
|
|
2436
|
+
# and if even that let one through: sample every block
|
|
2437
|
+
verts, faces, stats = narrowband_mc(field, lo, dims, pitch, lipschitz=np.inf, budget=_CELL_BUDGET,
|
|
2438
|
+
progress=progress)
|
|
2439
|
+
stats["fallback"] = True
|
|
2440
|
+
return _finish(verts, faces[:, ::-1], field, pitch, project, stats, decimate, creases)
|
|
2441
|
+
if cells > _CELL_BUDGET:
|
|
2442
|
+
p = float((np.prod(ext + 2 * pad * pitch) / _CELL_BUDGET) ** (1 / 3))
|
|
2443
|
+
raise ValueError(f"grid {dims[0]}x{dims[1]}x{dims[2]} = {cells / 1e6:.1f}M cells exceeds the "
|
|
2444
|
+
f"{_CELL_BUDGET // 1_000_000}M budget: use pitch >= {math.ceil(p * 100) / 100:g}")
|
|
2445
|
+
F = np.empty(tuple(dims), np.float32)
|
|
2446
|
+
ys = lo[1] + np.arange(dims[1]) * pitch
|
|
2447
|
+
zs = lo[2] + np.arange(dims[2]) * pitch
|
|
2448
|
+
Y, Z = np.meshgrid(ys, zs, indexing="ij")
|
|
2449
|
+
per = max(1, _CHUNK // (dims[1] * dims[2]))
|
|
2450
|
+
for i0 in range(0, dims[0], per):
|
|
2451
|
+
i1 = min(dims[0], i0 + per)
|
|
2452
|
+
xs = lo[0] + np.arange(i0, i1) * pitch
|
|
2453
|
+
P = np.stack([np.repeat(xs, Y.size), np.tile(Y.ravel(), len(xs)), np.tile(Z.ravel(), len(xs))], axis=1)
|
|
2454
|
+
F[i0:i1] = np.asarray(field(P), np.float32).reshape(i1 - i0, dims[1], dims[2])
|
|
2455
|
+
if progress:
|
|
2456
|
+
progress({"stage": "field", "done": i1, "of": int(dims[0])})
|
|
2457
|
+
F[0], F[-1], F[:, 0], F[:, -1], F[:, :, 0], F[:, :, -1] = (pitch,) * 6
|
|
2458
|
+
F[F == 0.0] = -1e-4 * pitch
|
|
2459
|
+
if F.min() >= 0:
|
|
2460
|
+
raise ValueError("the field has no inside over these bounds (nothing is below zero)")
|
|
2461
|
+
verts, faces, _, _ = marching_cubes(F, 0.0, spacing=(pitch,) * 3)
|
|
2462
|
+
del F
|
|
2463
|
+
m = trimesh.Trimesh(verts + lo, faces[:, ::-1], process=True)
|
|
2464
|
+
return _finish(np.asarray(m.vertices), np.asarray(m.faces), field, pitch, project, None, decimate, creases)
|
|
2465
|
+
|
|
2466
|
+
|
|
2467
|
+
def field_stl(field, lo, hi, path, pitch=None, project=3, pad=2, progress=None, block=32, lipschitz=None,
|
|
2468
|
+
decimate=None):
|
|
2469
|
+
"""field(P) < 0 straight to a binary STL at `path`, one x slab of
|
|
2470
|
+
narrow-band blocks at a time, for parts whose mesh does not fit in
|
|
2471
|
+
memory (lattices at fine pitch: tens of millions of triangles). Memory
|
|
2472
|
+
is one slab (`block` cells thick), whatever the part; there is no cell
|
|
2473
|
+
budget. Returns the report: faces, volume, area, watertight, sampling.
|
|
2474
|
+
|
|
2475
|
+
The same marching cubes, relaxation and projection as field_mesh, slab
|
|
2476
|
+
by slab. A vertex on the plane two slabs share is relaxed along the
|
|
2477
|
+
seam only and projected by the first slab, and reused, bit for bit, by the second, so the STL
|
|
2478
|
+
closes across the seam; the open edges each slab leaves on its two
|
|
2479
|
+
planes are matched against its neighbours', which checks the whole
|
|
2480
|
+
file is watertight without ever holding it. Small closed shells inside
|
|
2481
|
+
one slab, under (2 pitch)^3, are dropped as field_mesh drops debris. A
|
|
2482
|
+
crossing the Lipschitz skip missed shows as an unmatched edge, and the
|
|
2483
|
+
export starts over with every block sampled. `decimate` (mm) thins each
|
|
2484
|
+
slab as field_mesh does, its seam vertices left where they are."""
|
|
2485
|
+
lo, dims, pitch = _grid(lo, hi, pitch, pad)
|
|
2486
|
+
try:
|
|
2487
|
+
return _stream_stl(field, lo, dims, pitch, path, project, progress, block, lipschitz, decimate)
|
|
2488
|
+
except _Unmatched:
|
|
2489
|
+
rep = _stream_stl(field, lo, dims, pitch, path, project, progress, block, np.inf, decimate)
|
|
2490
|
+
rep["sampling"]["fallback"] = True
|
|
2491
|
+
return rep
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
class _Unmatched(Exception):
|
|
2495
|
+
pass
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
_STL_DTYPE = np.dtype([("n", "<f4", (3,)), ("v", "<f4", (3, 3)), ("a", "<u2")])
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
def _stream_stl(field, lo, dims, pitch, path, project, progress, block, lipschitz, decimate=None):
|
|
2502
|
+
import time
|
|
2503
|
+
|
|
2504
|
+
from .narrowband import march_blocks, merge_blocks, shell_volumes
|
|
2505
|
+
|
|
2506
|
+
t0 = time.time()
|
|
2507
|
+
stats = {}
|
|
2508
|
+
rep = {"faces": 0, "volume": 0.0, "area": 0.0, "slabs": 0, "maxSlabFaces": 0, "droppedShells": 0,
|
|
2509
|
+
"facesBeforeDecimation": 0}
|
|
2510
|
+
carry, carry_edges, carry_x = {}, set(), None
|
|
2511
|
+
tol = (2.0 * pitch) ** 3
|
|
2512
|
+
|
|
2513
|
+
def keys(V):
|
|
2514
|
+
return [r.tobytes() for r in np.ascontiguousarray(V)]
|
|
2515
|
+
|
|
2516
|
+
def slab(blocks, fh):
|
|
2517
|
+
nonlocal carry, carry_edges, carry_x
|
|
2518
|
+
x0 = int(blocks[0][0][0])
|
|
2519
|
+
x1 = x0 + int(blocks[0][1][0]) - 1
|
|
2520
|
+
if carry_x != x0 and carry_edges:
|
|
2521
|
+
raise _Unmatched() # the last slab left edges open and nothing meets them
|
|
2522
|
+
nv, Vs, Fs = 0, [], []
|
|
2523
|
+
for _, _, v, fa in blocks:
|
|
2524
|
+
Vs.append(v)
|
|
2525
|
+
Fs.append(fa + nv)
|
|
2526
|
+
nv += len(v)
|
|
2527
|
+
V, F = merge_blocks(np.concatenate(Vs), np.concatenate(Fs), block)
|
|
2528
|
+
del Vs, Fs
|
|
2529
|
+
F = F[:, ::-1]
|
|
2530
|
+
start = V[:, 0] == x0
|
|
2531
|
+
end = V[:, 0] == x1
|
|
2532
|
+
# open edges: allowed only on the two planes, and matched across them
|
|
2533
|
+
n = len(V)
|
|
2534
|
+
a, b = F.ravel(), np.roll(F, -1, axis=1).ravel()
|
|
2535
|
+
e = np.minimum(a, b) * n + np.maximum(a, b)
|
|
2536
|
+
u, cnt = np.unique(e, return_counts=True)
|
|
2537
|
+
if (cnt > 2).any():
|
|
2538
|
+
raise _Unmatched()
|
|
2539
|
+
op = u[cnt == 1]
|
|
2540
|
+
ea, eb = op // n, op % n
|
|
2541
|
+
at_start = start[ea] & start[eb]
|
|
2542
|
+
at_end = end[ea] & end[eb]
|
|
2543
|
+
if not (at_start | at_end).all():
|
|
2544
|
+
raise _Unmatched()
|
|
2545
|
+
kv = {}
|
|
2546
|
+
|
|
2547
|
+
def key(i):
|
|
2548
|
+
if i not in kv:
|
|
2549
|
+
kv[i] = V[i].tobytes()
|
|
2550
|
+
return kv[i]
|
|
2551
|
+
|
|
2552
|
+
mine = {frozenset((key(i), key(j))) for i, j in zip(ea[at_start], eb[at_start])}
|
|
2553
|
+
if mine != (carry_edges if carry_x == x0 else set()):
|
|
2554
|
+
raise _Unmatched()
|
|
2555
|
+
carry_edges = {frozenset((key(i), key(j))) for i, j in zip(ea[at_end], eb[at_end])}
|
|
2556
|
+
# debris: a small shell that touches neither plane is whole and here
|
|
2557
|
+
lab, vol = shell_volumes(V, F)
|
|
2558
|
+
seam = np.zeros(len(vol), bool)
|
|
2559
|
+
seam[np.unique(lab[np.flatnonzero((start | end)[F].any(axis=1))])] = True
|
|
2560
|
+
drop = ~seam & (np.abs(vol) * pitch ** 3 <= tol)
|
|
2561
|
+
if drop.any():
|
|
2562
|
+
rep["droppedShells"] += int(drop.sum())
|
|
2563
|
+
F = F[~drop[lab]]
|
|
2564
|
+
Vmm = lo + V * pitch
|
|
2565
|
+
si = np.flatnonzero(start)
|
|
2566
|
+
if len(si):
|
|
2567
|
+
got = [carry.get(k) for k in keys(V[si])]
|
|
2568
|
+
if any(g is None for g in got):
|
|
2569
|
+
raise _Unmatched()
|
|
2570
|
+
Vmm[si] = np.array(got)
|
|
2571
|
+
end_keys = keys(V[end])
|
|
2572
|
+
if project:
|
|
2573
|
+
V0 = Vmm
|
|
2574
|
+
Vmm = _relax(Vmm, F, field, pitch, int(project), pinned=start, seam=end)
|
|
2575
|
+
if _UNFOLD:
|
|
2576
|
+
# the previous slab's seam is fixed: never sent back
|
|
2577
|
+
Vmm, nb = _unfold(V0, Vmm, F, pitch, fixed=start, field=field)
|
|
2578
|
+
rep["unfolded"] = rep.get("unfolded", 0) + int(nb)
|
|
2579
|
+
carry = dict(zip(end_keys, Vmm[end]))
|
|
2580
|
+
if decimate:
|
|
2581
|
+
from .decimate import decimate as thin
|
|
2582
|
+
|
|
2583
|
+
Vmm, F, dst, _ = thin(Vmm, F, field, float(decimate), pitch, locked=start | end)
|
|
2584
|
+
rep["facesBeforeDecimation"] += dst["facesBefore"]
|
|
2585
|
+
carry_x = x1
|
|
2586
|
+
# write
|
|
2587
|
+
tri = Vmm[F]
|
|
2588
|
+
nrm = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
2589
|
+
ln = np.linalg.norm(nrm, axis=1)
|
|
2590
|
+
rep["area"] += float(ln.sum() / 2)
|
|
2591
|
+
rep["volume"] += float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6)
|
|
2592
|
+
out = np.zeros(len(F), _STL_DTYPE)
|
|
2593
|
+
out["n"] = nrm / np.maximum(ln, 1e-300)[:, None]
|
|
2594
|
+
out["v"] = tri
|
|
2595
|
+
fh.write(out.tobytes())
|
|
2596
|
+
rep["faces"] += len(F)
|
|
2597
|
+
rep["slabs"] += 1
|
|
2598
|
+
rep["maxSlabFaces"] = max(rep["maxSlabFaces"], len(F))
|
|
2599
|
+
|
|
2600
|
+
with open(path, "wb") as fh:
|
|
2601
|
+
fh.write(b"loadpath field_stl".ljust(80, b" ") + b"\0\0\0\0")
|
|
2602
|
+
cur = []
|
|
2603
|
+
for blk in march_blocks(field, lo, dims, pitch, block=block, lipschitz=lipschitz, progress=progress,
|
|
2604
|
+
stats=stats):
|
|
2605
|
+
if cur and blk[0][0] != cur[0][0][0]:
|
|
2606
|
+
slab(cur, fh)
|
|
2607
|
+
cur = []
|
|
2608
|
+
cur.append(blk)
|
|
2609
|
+
if cur:
|
|
2610
|
+
slab(cur, fh)
|
|
2611
|
+
if carry_edges:
|
|
2612
|
+
raise _Unmatched()
|
|
2613
|
+
if not rep["faces"]:
|
|
2614
|
+
raise ValueError("the field has no inside over these bounds (nothing is below zero)")
|
|
2615
|
+
fh.seek(80)
|
|
2616
|
+
fh.write(np.uint32(rep["faces"]).tobytes())
|
|
2617
|
+
if rep["volume"] < 0:
|
|
2618
|
+
# wound inside out: flip every triangle in place
|
|
2619
|
+
mm = np.memmap(path, dtype=_STL_DTYPE, mode="r+", offset=84, shape=(rep["faces"],))
|
|
2620
|
+
for i in range(0, len(mm), 1_000_000):
|
|
2621
|
+
c = mm[i:i + 1_000_000]
|
|
2622
|
+
c["v"] = c["v"][:, ::-1]
|
|
2623
|
+
c["n"] = -c["n"]
|
|
2624
|
+
mm.flush()
|
|
2625
|
+
del mm
|
|
2626
|
+
rep["volume"] = -rep["volume"]
|
|
2627
|
+
rep.update({"watertight": True, "pitch": pitch, "sampling": stats, "path": path,
|
|
2628
|
+
"seconds": round(time.time() - t0, 1)})
|
|
2629
|
+
return rep
|
|
2630
|
+
|
|
2631
|
+
|
|
2632
|
+
def _grid(lo, hi, pitch, pad):
|
|
2633
|
+
"""field_mesh's sampling grid: (lo, dims, pitch), off-grid by a hair."""
|
|
2634
|
+
lo = np.asarray(lo, float)
|
|
2635
|
+
hi = np.asarray(hi, float)
|
|
2636
|
+
if pitch is None:
|
|
2637
|
+
pitch = float((np.prod(hi - lo) / 4e6) ** (1 / 3))
|
|
2638
|
+
pitch = float(pitch)
|
|
2639
|
+
lo = lo - pad * pitch - pitch * 0.013717 # off-grid, as sdf_kit.to_mesh
|
|
2640
|
+
dims = np.ceil((hi + pad * pitch - lo) / pitch).astype(int) + 1
|
|
2641
|
+
return lo, dims, pitch
|
|
2642
|
+
|
|
2643
|
+
|
|
2644
|
+
def _finish(V, F, field, pitch, project, stats=None, decimate=None, creases=None):
|
|
2645
|
+
"""Relax and project the marching-cubes mesh, orient it, drop debris
|
|
2646
|
+
(shells under (2 pitch)^3), as a trimesh. The bookkeeping is numpy on
|
|
2647
|
+
the arrays: trimesh's own (process, split, volume) took longer than
|
|
2648
|
+
the meshing on a lattice of a few million faces."""
|
|
2649
|
+
import trimesh
|
|
2650
|
+
|
|
2651
|
+
from .narrowband import shell_volumes, signed_volume
|
|
2652
|
+
|
|
2653
|
+
V = np.ascontiguousarray(V, float)
|
|
2654
|
+
F = np.ascontiguousarray(F, np.int64) # (marching cubes' faces come reversed, as a view)
|
|
2655
|
+
unfolded = None
|
|
2656
|
+
if project:
|
|
2657
|
+
V0 = V
|
|
2658
|
+
V = _relax(V, F, field, pitch, int(project))
|
|
2659
|
+
if _UNFOLD:
|
|
2660
|
+
V, unfolded = _unfold(V0, V, F, pitch, field=field)
|
|
2661
|
+
locked, sharp_st = None, None
|
|
2662
|
+
if creases is not None and project:
|
|
2663
|
+
from .crease import sharpen
|
|
2664
|
+
|
|
2665
|
+
sharp_st = {}
|
|
2666
|
+
if signed_volume(V, F) < 0: # sharpen checks faces against the surface normals
|
|
2667
|
+
F = F[:, ::-1].copy()
|
|
2668
|
+
V, F, locked = sharpen(V, F, creases, pitch, field=field, stats=sharp_st)
|
|
2669
|
+
dec = None
|
|
2670
|
+
if decimate:
|
|
2671
|
+
from .decimate import decimate as thin
|
|
2672
|
+
|
|
2673
|
+
V, F, dec, kept = thin(V, F, field, float(decimate), pitch, locked=locked)
|
|
2674
|
+
try:
|
|
2675
|
+
lab, vol = shell_volumes(V, F)
|
|
2676
|
+
tol = (2.0 * pitch) ** 3
|
|
2677
|
+
if vol.sum() < 0:
|
|
2678
|
+
F, vol = F[:, ::-1].copy(), -vol
|
|
2679
|
+
small = np.abs(vol) <= tol
|
|
2680
|
+
if len(vol) > 1 and small.any() and not small.all():
|
|
2681
|
+
F = F[~small[lab]]
|
|
2682
|
+
used = np.zeros(len(V), bool)
|
|
2683
|
+
used[F] = True
|
|
2684
|
+
new = np.cumsum(used) - 1
|
|
2685
|
+
V, F = V[used], new[F]
|
|
2686
|
+
except Exception: # noqa: BLE001 - debris filtering is best-effort
|
|
2687
|
+
pass
|
|
2688
|
+
if signed_volume(V, F) < 0:
|
|
2689
|
+
F = F[:, ::-1].copy()
|
|
2690
|
+
mesh = trimesh.Trimesh(V, F, process=False)
|
|
2691
|
+
mesh.metadata["pitch"] = pitch
|
|
2692
|
+
if unfolded is not None:
|
|
2693
|
+
mesh.metadata["unfolded"] = int(unfolded)
|
|
2694
|
+
if stats:
|
|
2695
|
+
mesh.metadata["sampling"] = stats
|
|
2696
|
+
if dec:
|
|
2697
|
+
mesh.metadata["decimation"] = dec
|
|
2698
|
+
if sharp_st is not None:
|
|
2699
|
+
mesh.metadata["creases"] = sharp_st
|
|
2700
|
+
return mesh
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
def _gradient(field, V, h):
|
|
2704
|
+
"""(f, grad f) at V from a tetrahedral stencil (five evaluations)."""
|
|
2705
|
+
from .narrowband import peval
|
|
2706
|
+
|
|
2707
|
+
tet = np.array([[1, -1, -1], [-1, -1, 1], [-1, 1, -1], [1, 1, 1]], float) * h
|
|
2708
|
+
f0 = np.empty(len(V))
|
|
2709
|
+
g = np.empty((len(V), 3))
|
|
2710
|
+
chunk = 400_000
|
|
2711
|
+
for i in range(0, len(V), chunk):
|
|
2712
|
+
v = V[i:i + chunk]
|
|
2713
|
+
n = len(v)
|
|
2714
|
+
f = peval(field, np.concatenate([v] + [v + t for t in tet]))
|
|
2715
|
+
ft = f[n:].reshape(4, n)
|
|
2716
|
+
f0[i:i + chunk] = f[:n]
|
|
2717
|
+
g[i:i + chunk] = (tet[:, :, None] * ft[:, None, :]).sum(axis=0).T / (4 * h * h)
|
|
2718
|
+
return f0, g
|
|
2719
|
+
|
|
2720
|
+
|
|
2721
|
+
def _project(field, V, pitch, f0=None, g=None, backtrack=0):
|
|
2722
|
+
"""One Newton step of every vertex onto field = 0, step capped at
|
|
2723
|
+
pitch / 2 and kept only where |f| drops (halved up to `backtrack`
|
|
2724
|
+
times where it does not)."""
|
|
2725
|
+
from .narrowband import peval
|
|
2726
|
+
|
|
2727
|
+
if f0 is None:
|
|
2728
|
+
f0, g = _gradient(field, V, 0.05 * pitch)
|
|
2729
|
+
gg = np.maximum((g * g).sum(axis=1), 1e-12)
|
|
2730
|
+
step = -(f0 / gg)[:, None] * g
|
|
2731
|
+
ln = np.linalg.norm(step, axis=1)
|
|
2732
|
+
step *= np.minimum(1.0, 0.5 * pitch / np.maximum(ln, 1e-300))[:, None]
|
|
2733
|
+
cand = V + step
|
|
2734
|
+
f1 = peval(field, cand)
|
|
2735
|
+
ok = np.abs(f1) < np.abs(f0)
|
|
2736
|
+
out = np.where(ok[:, None], cand, V)
|
|
2737
|
+
for _ in range(backtrack):
|
|
2738
|
+
bad = np.flatnonzero(~ok)
|
|
2739
|
+
if not len(bad):
|
|
2740
|
+
break
|
|
2741
|
+
step[bad] *= 0.5
|
|
2742
|
+
c = V[bad] + step[bad]
|
|
2743
|
+
f1 = peval(field, c)
|
|
2744
|
+
good = np.abs(f1) < np.abs(f0[bad])
|
|
2745
|
+
out[bad[good]] = c[good]
|
|
2746
|
+
ok[bad[good]] = True
|
|
2747
|
+
return out
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
_RELAX_LINE = True
|
|
2751
|
+
_CLOSING = 6 # closing passes: two, then up to four more on a small tail
|
|
2752
|
+
_CLOSING_TAIL = 5000
|
|
2753
|
+
_SHARP_COS = float(np.cos(np.radians(25.0)))
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
class _Normals:
|
|
2757
|
+
"""Area-weighted unit vertex normals of a mesh whose faces stay put
|
|
2758
|
+
while its vertices move (the incidence is built once), and a mask of
|
|
2759
|
+
the vertices where some face round them leans further than `sharp` (a
|
|
2760
|
+
cosine) from the normal: edges and corners, where a mesh normal is
|
|
2761
|
+
not the field's gradient."""
|
|
2762
|
+
|
|
2763
|
+
def __init__(self, F, n, vf=None):
|
|
2764
|
+
from .meshops import vertex_faces
|
|
2765
|
+
|
|
2766
|
+
self.F, self.n = F, n
|
|
2767
|
+
# vertex -> faces (CSR), each face once per corner
|
|
2768
|
+
self.vf = vertex_faces(F, n) if vf is None else vf
|
|
2769
|
+
self.edge = None
|
|
2770
|
+
|
|
2771
|
+
def __call__(self, V, sharp):
|
|
2772
|
+
from .meshops import vertex_sum
|
|
2773
|
+
|
|
2774
|
+
F = self.F
|
|
2775
|
+
fn = face_cross(V, F)
|
|
2776
|
+
ptr, idx = self.vf
|
|
2777
|
+
N = vertex_sum(ptr, idx, fn)
|
|
2778
|
+
N /= np.maximum(np.sqrt(np.einsum("ij,ij->i", N, N))[:, None], 1e-300)
|
|
2779
|
+
if self.edge is None:
|
|
2780
|
+
# the edges and corners are found once: a relax round moves a
|
|
2781
|
+
# vertex a fraction of a pitch, not off its edge
|
|
2782
|
+
fn /= np.maximum(np.sqrt(np.einsum("ij,ij->i", fn, fn))[:, None], 1e-300)
|
|
2783
|
+
from .meshops import min_cos
|
|
2784
|
+
|
|
2785
|
+
self.edge = min_cos(self.vf, fn, N) < sharp
|
|
2786
|
+
return N, self.edge
|
|
2787
|
+
|
|
2788
|
+
|
|
2789
|
+
def _line_newton(field, V, N, pitch, h):
|
|
2790
|
+
"""One Newton step of each vertex along its line V + t N onto field = 0
|
|
2791
|
+
(central differences: three evaluations), step capped at pitch / 2 and
|
|
2792
|
+
kept only where |f| drops (a fourth)."""
|
|
2793
|
+
from .narrowband import peval
|
|
2794
|
+
|
|
2795
|
+
n = len(V)
|
|
2796
|
+
f = peval(field, np.concatenate([V, V + h * N, V - h * N]))
|
|
2797
|
+
f0, fp, fm = f[:n], f[n:2 * n], f[2 * n:]
|
|
2798
|
+
df = (fp - fm) / (2 * h)
|
|
2799
|
+
ok = np.abs(df) > 1e-6
|
|
2800
|
+
t = np.where(ok, -f0 / np.where(ok, df, 1.0), 0.0)
|
|
2801
|
+
t = np.clip(t, -0.5 * pitch, 0.5 * pitch)
|
|
2802
|
+
cand = V + t[:, None] * N
|
|
2803
|
+
ok = np.abs(peval(field, cand)) < np.abs(f0)
|
|
2804
|
+
return np.where(ok[:, None], cand, V)
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def _relax(V, F, field, pitch, rounds, pinned=None, seam=None):
|
|
2808
|
+
"""Even out the marching-cubes vertices on the surface: each round moves
|
|
2809
|
+
every vertex halfway to its neighbours' centroid, within the tangent
|
|
2810
|
+
plane of the field (so the surface does not shrink), then projects it
|
|
2811
|
+
back onto the zero level. Marching cubes leaves a sawtooth along every
|
|
2812
|
+
crease (a vertex lands on one face or the other at random); relaxed and
|
|
2813
|
+
projected, a crease comes out as a clean edge rounded over about one
|
|
2814
|
+
pitch. Exact sharp features are mesh booleans' job (sdf_kit.mesh_subtract).
|
|
2815
|
+
|
|
2816
|
+
Two closing Newton passes take every vertex the rest of the way; a
|
|
2817
|
+
vertex already within 1e-6 pitch of the zero level is left alone
|
|
2818
|
+
there, so those passes cost about one evaluation per vertex instead of
|
|
2819
|
+
six. `pinned` vertices do not move at all, and `seam` ones slide only
|
|
2820
|
+
along the seam they form (toward their seam neighbours' centroid): the
|
|
2821
|
+
streamed export's planes between slabs, which the next slab reuses
|
|
2822
|
+
as they are, so they must not lean on one side's vertices."""
|
|
2823
|
+
import scipy.sparse as sp
|
|
2824
|
+
|
|
2825
|
+
from .meshops import neighbors, vertex_faces
|
|
2826
|
+
from .narrowband import peval
|
|
2827
|
+
|
|
2828
|
+
V = np.array(V, float)
|
|
2829
|
+
n = len(V)
|
|
2830
|
+
vf = vertex_faces(F, n)
|
|
2831
|
+
# the edge adjacency: an edge counts once, from either face; a seam
|
|
2832
|
+
# vertex leans on its seam neighbours only
|
|
2833
|
+
indptr, indices = neighbors(F, n, seam=seam, vf=vf)
|
|
2834
|
+
A = sp.csr_matrix((np.ones(len(indices)), indices, indptr), shape=(n, n))
|
|
2835
|
+
deg = np.maximum(np.diff(indptr), 1)
|
|
2836
|
+
move = np.ones(n, bool) if pinned is None else ~np.asarray(pinned, bool)
|
|
2837
|
+
h = 0.05 * pitch
|
|
2838
|
+
line = getattr(field, "relax_line", _RELAX_LINE)
|
|
2839
|
+
normals = _Normals(F, n, vf) if line else None
|
|
2840
|
+
|
|
2841
|
+
def newton(idx):
|
|
2842
|
+
"""One Newton step of the vertices idx onto the zero level; the
|
|
2843
|
+
unit normals it used (rows idx; the rest zero)."""
|
|
2844
|
+
if not line:
|
|
2845
|
+
f0, g = _gradient(field, V[idx], h)
|
|
2846
|
+
V[idx] = _project(field, V[idx], pitch, f0, g)
|
|
2847
|
+
nrm = np.zeros((n, 3))
|
|
2848
|
+
nrm[idx] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
|
|
2849
|
+
return nrm
|
|
2850
|
+
# the mesh's own vertex normals stand in for the field's gradient
|
|
2851
|
+
# direction (the mesh lies on the surface to a fraction of a
|
|
2852
|
+
# pitch), and Newton runs along that line: four evaluations a
|
|
2853
|
+
# vertex instead of seven. Edges and corners (a face round the
|
|
2854
|
+
# vertex 25 degrees off its normal) take the true gradient: there
|
|
2855
|
+
# the mesh normal bisects the faces, and smoothing in its plane
|
|
2856
|
+
# rounds the edge.
|
|
2857
|
+
nrm, edge = normals(V, _SHARP_COS)
|
|
2858
|
+
ln, ed = idx[~edge[idx]], idx[edge[idx]]
|
|
2859
|
+
V[ln] = _line_newton(field, V[ln], nrm[ln], pitch, h)
|
|
2860
|
+
if len(ed):
|
|
2861
|
+
f0, g = _gradient(field, V[ed], h)
|
|
2862
|
+
V[ed] = _project(field, V[ed], pitch, f0, g)
|
|
2863
|
+
nrm[ed] = g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
|
|
2864
|
+
return nrm
|
|
2865
|
+
|
|
2866
|
+
from .lattice import single_precision
|
|
2867
|
+
|
|
2868
|
+
for _ in range(rounds):
|
|
2869
|
+
# these rounds move a vertex a fraction of a pitch toward the zero
|
|
2870
|
+
# level: single-precision lattice trig (~1e-6 mm) is plenty; the
|
|
2871
|
+
# closing passes below, to 1e-6 pitch, take it in double
|
|
2872
|
+
with single_precision():
|
|
2873
|
+
nrm = newton(np.flatnonzero(move))
|
|
2874
|
+
d = (A @ V) / deg[:, None] - V
|
|
2875
|
+
d -= (d * nrm).sum(axis=1, keepdims=True) * nrm
|
|
2876
|
+
V[move] += 0.5 * d[move]
|
|
2877
|
+
# closing passes: the vertices off the zero level by more than 1e-6
|
|
2878
|
+
# pitch, first as the rounds did, then by the true gradient (step
|
|
2879
|
+
# halved where it does not help) while few are left
|
|
2880
|
+
tol = 1e-6 * pitch
|
|
2881
|
+
idx = np.flatnonzero(move)
|
|
2882
|
+
for k in range(_CLOSING):
|
|
2883
|
+
f0 = peval(field, V[idx])
|
|
2884
|
+
idx = idx[np.abs(f0) > tol]
|
|
2885
|
+
if not len(idx) or (k >= 2 and len(idx) > _CLOSING_TAIL):
|
|
2886
|
+
break
|
|
2887
|
+
if k == 0 and line:
|
|
2888
|
+
newton(idx)
|
|
2889
|
+
else:
|
|
2890
|
+
V[idx] = _project(field, V[idx], pitch, backtrack=4 if k >= 2 else 0)
|
|
2891
|
+
return V
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
_UNFOLD = True
|
|
2895
|
+
_UNFOLD_RINGS = 0 # rings round the folded edges' faces' vertices
|
|
2896
|
+
_FLAT_FOLD = 1e-3 # an edge folded back within this * pitch of flat is a crossing
|
|
2897
|
+
|
|
2898
|
+
|
|
2899
|
+
def _sliver(V, F, sin_min=float(np.sin(np.radians(5.0)))):
|
|
2900
|
+
"""Faces with an angle under ~5 degrees."""
|
|
2901
|
+
a, b, c = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
|
|
2902
|
+
e1, e2 = b - a, c - a
|
|
2903
|
+
n = face_cross(V, F)
|
|
2904
|
+
e3 = c - b
|
|
2905
|
+
l2 = np.sort(np.stack([(e1 * e1).sum(1), (e2 * e2).sum(1), (e3 * e3).sum(1)], 1), axis=1)
|
|
2906
|
+
return np.sqrt((n * n).sum(1)) < sin_min * np.sqrt(l2[:, 1] * l2[:, 2])
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
def _unfold(V0, V, F, pitch, rounds=8, chunk=200_000, fixed=None, field=None):
|
|
2910
|
+
"""Undo the relax where it folded the mesh through itself. Marching
|
|
2911
|
+
cubes' own mesh (V0) never crosses itself, but relaxing and projecting
|
|
2912
|
+
across a wall or a wedge thinner than a pitch can push one sheet
|
|
2913
|
+
through another. Faces that cross another face (sharing no vertex)
|
|
2914
|
+
send their vertices back toward where marching cubes put them (half
|
|
2915
|
+
way, then a quarter of the way from it, then onto it), a few rounds,
|
|
2916
|
+
each looking only round what the last one moved.
|
|
2917
|
+
|
|
2918
|
+
Only faces round an edge folded past 90 degrees (and a ring round
|
|
2919
|
+
them) are searched in the first round: on the arm-link gyroid that
|
|
2920
|
+
holds every folded face, at 6% of the mesh. `fixed` vertices (a
|
|
2921
|
+
streamed slab's seam with the last one) are never sent back.
|
|
2922
|
+
Returns (V, number of vertices sent back)."""
|
|
2923
|
+
from . import selfx
|
|
2924
|
+
from .crease import _Centroids, crossing_faces
|
|
2925
|
+
|
|
2926
|
+
if selfx.available():
|
|
2927
|
+
chunk = 1 << 62 # the compiled search takes any number at once
|
|
2928
|
+
|
|
2929
|
+
def normals(X):
|
|
2930
|
+
n = face_cross(X, F)
|
|
2931
|
+
return n / np.maximum(np.sqrt(np.einsum("ij,ij->i", n, n))[:, None], 1e-300)
|
|
2932
|
+
|
|
2933
|
+
V = np.array(V, float)
|
|
2934
|
+
from . import meshops
|
|
2935
|
+
|
|
2936
|
+
# the faces round every vertex, and the two faces of every edge (as
|
|
2937
|
+
# half-edges h1, h2: corner h % 3 of face h // 3 to the next corner)
|
|
2938
|
+
vf = meshops.vertex_faces(F, len(V))
|
|
2939
|
+
nf = len(F)
|
|
2940
|
+
h1, h2 = meshops.edge_pairs(F, vf)
|
|
2941
|
+
# a fold shows as an edge whose two faces meet at over 90 degrees
|
|
2942
|
+
N = normals(V)
|
|
2943
|
+
f1, f2 = h1 // 3, h2 // 3
|
|
2944
|
+
dots = meshops.row_dots(N, N, f1, f2)
|
|
2945
|
+
sharp = dots < 0.0
|
|
2946
|
+
# an edge folded flat back on itself (the two faces overlapping in one
|
|
2947
|
+
# plane, as projecting onto a flat cut can leave them): no segment
|
|
2948
|
+
# crosses a face there, but manifold3d swaps such edges and the swap
|
|
2949
|
+
# crosses. Candidates are the edges folded past ~150 degrees anywhere.
|
|
2950
|
+
c2 = F[f2, (h2 % 3 + 2) % 3]
|
|
2951
|
+
tol = _FLAT_FOLD * pitch
|
|
2952
|
+
pair = h1
|
|
2953
|
+
|
|
2954
|
+
# the edge pair of every half-edge (-1 on an open edge)
|
|
2955
|
+
he = np.full(3 * len(F), -1, np.int64)
|
|
2956
|
+
he[h1] = np.arange(len(h1))
|
|
2957
|
+
he[h2] = np.arange(len(h1))
|
|
2958
|
+
|
|
2959
|
+
def faces_of(verts):
|
|
2960
|
+
return meshops.faces_of(vf, verts, nf)
|
|
2961
|
+
|
|
2962
|
+
def unique_verts(faces):
|
|
2963
|
+
m = np.zeros(len(V), bool)
|
|
2964
|
+
m[F[faces].ravel()] = True
|
|
2965
|
+
return np.flatnonzero(m)
|
|
2966
|
+
|
|
2967
|
+
def rings(verts, k):
|
|
2968
|
+
for _r in range(k):
|
|
2969
|
+
verts = unique_verts(faces_of(verts))
|
|
2970
|
+
return verts
|
|
2971
|
+
|
|
2972
|
+
def flat(X, faces, k=None):
|
|
2973
|
+
if k is None:
|
|
2974
|
+
k = he[(3 * np.asarray(faces)[:, None] + np.arange(3)).ravel()]
|
|
2975
|
+
m = np.zeros(len(pair), bool)
|
|
2976
|
+
m[k[k >= 0]] = True
|
|
2977
|
+
m &= ~flat0
|
|
2978
|
+
k = np.flatnonzero(m)
|
|
2979
|
+
g1, g2 = f1[k], f2[k]
|
|
2980
|
+
n1 = face_cross(X, F[g1])
|
|
2981
|
+
n2 = face_cross(X, F[g2])
|
|
2982
|
+
l1 = np.sqrt(np.einsum("ij,ij->i", n1, n1))
|
|
2983
|
+
l2 = np.sqrt(np.einsum("ij,ij->i", n2, n2))
|
|
2984
|
+
d = np.einsum("ij,ij->i", n1, n2) < -0.9 * np.maximum(l1 * l2, 1e-300)
|
|
2985
|
+
d &= np.abs(np.einsum("ij,ij->i", X[c2[k]] - X[F[g1, 0]], n1)) < tol * np.maximum(l1, 1e-300)
|
|
2986
|
+
return np.concatenate([g1[d], g2[d]])
|
|
2987
|
+
|
|
2988
|
+
# folded flat in marching cubes' own mesh too: sending vertices back
|
|
2989
|
+
# cannot undo those, so they are left alone
|
|
2990
|
+
flat0 = np.zeros(len(pair), bool)
|
|
2991
|
+
k0 = np.flatnonzero(dots < -0.9)
|
|
2992
|
+
if len(k0):
|
|
2993
|
+
hit = flat(V0, None, k0)
|
|
2994
|
+
if len(hit):
|
|
2995
|
+
fm = np.zeros(len(F), bool)
|
|
2996
|
+
fm[hit] = True
|
|
2997
|
+
flat0[k0[fm[f1[k0]] & fm[f2[k0]]]] = True
|
|
2998
|
+
|
|
2999
|
+
near = np.zeros(len(V), bool)
|
|
3000
|
+
near[F[f1[sharp]].ravel()] = True
|
|
3001
|
+
near[F[f2[sharp]].ravel()] = True
|
|
3002
|
+
for _r in range(_UNFOLD_RINGS):
|
|
3003
|
+
near[F[near[F].any(axis=1)].ravel()] = True
|
|
3004
|
+
near[F[f1[dots < -0.9]].ravel()] = True
|
|
3005
|
+
q = np.flatnonzero(near[F].any(axis=1))
|
|
3006
|
+
if not len(q):
|
|
3007
|
+
return V, 0
|
|
3008
|
+
cache = _Centroids(V, F)
|
|
3009
|
+
back = 0
|
|
3010
|
+
V1 = V.copy()
|
|
3011
|
+
level = np.zeros(len(V), np.int64)
|
|
3012
|
+
for _ in range(rounds):
|
|
3013
|
+
x = [crossing_faces(V, F, q[i:i + chunk], cache) for i in range(0, len(q), chunk)]
|
|
3014
|
+
x = np.unique(np.concatenate(x + [flat(V, q)]))
|
|
3015
|
+
if not len(x):
|
|
3016
|
+
break
|
|
3017
|
+
verts = unique_verts(x)
|
|
3018
|
+
verts = verts[np.any(V[verts] != V0[verts], axis=1)]
|
|
3019
|
+
if fixed is not None:
|
|
3020
|
+
verts = verts[~fixed[verts]]
|
|
3021
|
+
if not len(verts):
|
|
3022
|
+
break
|
|
3023
|
+
# halfway back first (closer to the surface than marching cubes'
|
|
3024
|
+
# point), then a quarter, then all the way
|
|
3025
|
+
back += int((level[verts] == 0).sum())
|
|
3026
|
+
level[verts] += 1
|
|
3027
|
+
s = np.where(level[verts] >= 3, 0.0, 0.5 ** level[verts])[:, None]
|
|
3028
|
+
V[verts] = V0[verts] + s * (V1[verts] - V0[verts])
|
|
3029
|
+
cache.update(V, F, faces_of(verts))
|
|
3030
|
+
q = faces_of(rings(verts, 2))
|
|
3031
|
+
if field is not None and back:
|
|
3032
|
+
# those vertices sit off the zero level: project them along the
|
|
3033
|
+
# true gradient, and keep the projection wherever it folds nothing
|
|
3034
|
+
moved = np.flatnonzero(level > 0)
|
|
3035
|
+
safe = V[moved].copy()
|
|
3036
|
+
for _ in range(2):
|
|
3037
|
+
V[moved] = _project(field, V[moved], pitch)
|
|
3038
|
+
mf = np.zeros(len(V), bool)
|
|
3039
|
+
mf[moved] = True
|
|
3040
|
+
faces = faces_of(moved)
|
|
3041
|
+
# nor onto another vertex (two pulled to the same point of an edge,
|
|
3042
|
+
# which an STL's vertex merge would fuse)
|
|
3043
|
+
ring = unique_verts(faces)
|
|
3044
|
+
_, inv, cnt = np.unique(np.round(V[ring] / (1e-9 * pitch)), axis=0, return_inverse=True,
|
|
3045
|
+
return_counts=True)
|
|
3046
|
+
dup = ring[cnt[inv.ravel()] > 1]
|
|
3047
|
+
dup = dup[mf[dup]]
|
|
3048
|
+
if len(dup):
|
|
3049
|
+
j = np.searchsorted(moved, dup)
|
|
3050
|
+
V[dup] = safe[j]
|
|
3051
|
+
cache.update(V, F, faces)
|
|
3052
|
+
for _ in range(8):
|
|
3053
|
+
x = [crossing_faces(V, F, faces[i:i + chunk], cache) for i in range(0, len(faces), chunk)]
|
|
3054
|
+
x = np.unique(np.concatenate(x + [flat(V, faces)]))
|
|
3055
|
+
bad = np.zeros(len(V), bool)
|
|
3056
|
+
bad[F[x].ravel()] = True
|
|
3057
|
+
# and no sliver (manifold3d flips those, and a flip can cross)
|
|
3058
|
+
bad[F[faces[_sliver(V, F[faces])]].ravel()] = True
|
|
3059
|
+
back_to = np.flatnonzero(bad[moved] & np.any(V[moved] != safe, axis=1))
|
|
3060
|
+
if not len(back_to):
|
|
3061
|
+
break
|
|
3062
|
+
V[moved[back_to]] = safe[back_to]
|
|
3063
|
+
cache.update(V, F, faces_of(moved[back_to]))
|
|
3064
|
+
faces = faces_of(rings(moved[back_to], 2))
|
|
3065
|
+
else:
|
|
3066
|
+
# not settled: no projection at all round what is still moving
|
|
3067
|
+
vm = np.zeros(len(V), bool)
|
|
3068
|
+
vm[F[faces].ravel()] = True
|
|
3069
|
+
k = vm[moved]
|
|
3070
|
+
V[moved[k]] = safe[k]
|
|
3071
|
+
return V, back
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
# ---- material bodies -------------------------------------------------------------
|
|
3075
|
+
|
|
3076
|
+
class _Cutter:
|
|
3077
|
+
"""A material body's partition region (its solid, if it is not the
|
|
3078
|
+
part, and its where/above/below thresholds, intersected), carried on
|
|
3079
|
+
outside the part so its surface never runs along the part's own: inside
|
|
3080
|
+
the part within `eps` of its surface, and outside it, the region reads
|
|
3081
|
+
its value at the point `eps` deep along the part's normal (as sharp.py's
|
|
3082
|
+
extended field), and from 3 * eps outside it is solid. Only where the
|
|
3083
|
+
region's own surface crosses the part's interior does it draw one, and
|
|
3084
|
+
that is the interface. Clipped to `box` so it is closed."""
|
|
3085
|
+
|
|
3086
|
+
# its plane meets the part's faces in manifold's split: projected by the
|
|
3087
|
+
# true gradient it lands on the plane exactly, as the interface needs
|
|
3088
|
+
relax_line = False
|
|
3089
|
+
|
|
3090
|
+
def __init__(self, g, terms, part, eps, box):
|
|
3091
|
+
self.g, self.terms, self.part = g, terms, part
|
|
3092
|
+
self.eps = float(eps)
|
|
3093
|
+
self.lo, self.hi = (np.asarray(b, float) for b in box)
|
|
3094
|
+
|
|
3095
|
+
def raw(self, P):
|
|
3096
|
+
vals = []
|
|
3097
|
+
for kind, a, b in self.terms:
|
|
3098
|
+
if kind == "solid":
|
|
3099
|
+
vals.append(self.g.evaluate(P, a))
|
|
3100
|
+
else:
|
|
3101
|
+
w = self.g.evaluate(P, a)
|
|
3102
|
+
t = b if isinstance(b, float) else self.g.evaluate(P, b)
|
|
3103
|
+
vals.append(t - w if kind == "above" else w - t)
|
|
3104
|
+
return np.max(vals, axis=0)
|
|
3105
|
+
|
|
3106
|
+
def __call__(self, P):
|
|
3107
|
+
P = np.asarray(P, float).reshape(-1, 3)
|
|
3108
|
+
u = self.part(P)
|
|
3109
|
+
e, far = self.eps, 3 * self.eps
|
|
3110
|
+
# far outside: solid from `far` out, so the crease where the region's
|
|
3111
|
+
# surface meets that (rounded over a pitch by the mesher) stays clear of the part
|
|
3112
|
+
out = far - u
|
|
3113
|
+
near = u > -e
|
|
3114
|
+
mid = near & (u < far + e)
|
|
3115
|
+
inner = ~near
|
|
3116
|
+
if inner.any():
|
|
3117
|
+
out[inner] = self.raw(P[inner])
|
|
3118
|
+
if mid.any():
|
|
3119
|
+
Pn = P[mid]
|
|
3120
|
+
h = 1e-3
|
|
3121
|
+
grad = np.stack([(self.part(Pn + h * e) - self.part(Pn - h * e)) / (2 * h) for e in np.eye(3)], axis=1)
|
|
3122
|
+
nrm = grad / np.maximum(np.linalg.norm(grad, axis=1, keepdims=True), 1e-12)
|
|
3123
|
+
Q = Pn - (u[mid] + self.eps)[:, None] * nrm
|
|
3124
|
+
v = np.maximum(self.raw(Q), -e)
|
|
3125
|
+
out[mid] = np.minimum(v, far - u[mid])
|
|
3126
|
+
if inner.any():
|
|
3127
|
+
out[inner] = np.maximum(out[inner], -self.eps)
|
|
3128
|
+
box = np.max(np.concatenate([self.lo - P, P - self.hi], axis=1), axis=1)
|
|
3129
|
+
return np.maximum(out, box)
|
|
3130
|
+
|
|
3131
|
+
|
|
3132
|
+
def _manifold(mesh):
|
|
3133
|
+
from .shapes import manifold_from_trimesh
|
|
3134
|
+
|
|
3135
|
+
try:
|
|
3136
|
+
return manifold_from_trimesh(mesh)
|
|
3137
|
+
except ValueError:
|
|
3138
|
+
import trimesh
|
|
3139
|
+
|
|
3140
|
+
m = mesh.copy()
|
|
3141
|
+
trimesh.repair.fix_winding(m)
|
|
3142
|
+
trimesh.repair.fill_holes(m)
|
|
3143
|
+
return manifold_from_trimesh(m)
|
|
3144
|
+
|
|
3145
|
+
|
|
3146
|
+
def _body_terms(g, b):
|
|
3147
|
+
"""[(kind, a, b)] that bound body `b` inside the part, or [] when it
|
|
3148
|
+
takes all that the bodies before it left."""
|
|
3149
|
+
terms = []
|
|
3150
|
+
sol = b.get("solid")
|
|
3151
|
+
if isinstance(sol, float):
|
|
3152
|
+
raise ValueError(f"material body {b['name']!r}: solid must be a solid node, not a number")
|
|
3153
|
+
if sol is not None and sol not in (g.output, g.part_node):
|
|
3154
|
+
terms.append(("solid", sol, None))
|
|
3155
|
+
for key in ("above", "below"):
|
|
3156
|
+
if key in b:
|
|
3157
|
+
terms.append((key, b["where"], b[key]))
|
|
3158
|
+
if terms and isinstance(terms[0][1], float):
|
|
3159
|
+
raise ValueError(f"material body {b['name']!r}: where must be a field, not a number")
|
|
3160
|
+
return terms
|
|
3161
|
+
|
|
3162
|
+
|
|
3163
|
+
def material_bodies(g, part, progress=None):
|
|
3164
|
+
"""Split the meshed part into the graph's material bodies (g.bodies),
|
|
3165
|
+
earlier bodies first: body i is what of the part is inside its region
|
|
3166
|
+
and not in an earlier body's, so bodies never overlap and together they
|
|
3167
|
+
are the part (less `rest`, what no body claimed).
|
|
3168
|
+
|
|
3169
|
+
The part is meshed once (it keeps its sharp edges); each region is
|
|
3170
|
+
meshed from its field (_Cutter: carried on outside the part, so its
|
|
3171
|
+
surface is only the interface) and manifold3d splits what is left of
|
|
3172
|
+
the part by it: one exact boolean gives both sides, so the two bodies
|
|
3173
|
+
on an interface share that surface exactly, and each is watertight.
|
|
3174
|
+
Returns ([(body, trimesh or None)], rest manifold, stats)."""
|
|
3175
|
+
import manifold3d as m3d
|
|
3176
|
+
|
|
3177
|
+
from .shapes import trimesh_from_manifold
|
|
3178
|
+
|
|
3179
|
+
pitch = float(part.metadata.get("pitch") or (np.prod(part.extents) / 4e6) ** (1 / 3))
|
|
3180
|
+
lo, hi = np.asarray(part.bounds[0], float), np.asarray(part.bounds[1], float)
|
|
3181
|
+
box = (lo - 3 * pitch, hi + 3 * pitch)
|
|
3182
|
+
rest = _manifold(part)
|
|
3183
|
+
pf = g.field()
|
|
3184
|
+
out, stats = [], {"pitch": pitch, "regions": 0}
|
|
3185
|
+
for b in g.bodies:
|
|
3186
|
+
terms = _body_terms(g, b)
|
|
3187
|
+
if rest.is_empty():
|
|
3188
|
+
out.append((b, None))
|
|
3189
|
+
continue
|
|
3190
|
+
if not terms:
|
|
3191
|
+
piece, rest = rest, m3d.Manifold()
|
|
3192
|
+
else:
|
|
3193
|
+
cut = _Cutter(g, terms, pf, pitch, box)
|
|
3194
|
+
dims = np.ceil((box[1] - box[0]) / pitch + 5)
|
|
3195
|
+
# thresholds of an arbitrary field (a stress) have no Lipschitz
|
|
3196
|
+
# bound for the narrow band to lean on: sample densely while it fits
|
|
3197
|
+
sparse = float(np.prod(dims)) > 16e6
|
|
3198
|
+
cm = field_mesh(cut, box[0] - pitch, box[1] + pitch, pitch=pitch, project=3, sparse=sparse,
|
|
3199
|
+
progress=progress)
|
|
3200
|
+
stats["regions"] += 1
|
|
3201
|
+
piece, rest = rest.split(_manifold(cm))
|
|
3202
|
+
out.append((b, None if piece.is_empty() else trimesh_from_manifold(piece)))
|
|
3203
|
+
return out, rest, stats
|
|
3204
|
+
|
|
3205
|
+
|
|
3206
|
+
def _mix_job(g, b, mesh, mats, out_dir, stem, opts):
|
|
3207
|
+
"""A `mix` body as a voxel jetting job: the fraction of material B on a
|
|
3208
|
+
voxel grid of the body (voxelprint.export_voxel_slices) and the mass it
|
|
3209
|
+
gives. Returns (mix report, mean density g/cm^3)."""
|
|
3210
|
+
from .voxels import mesh_solid
|
|
3211
|
+
|
|
3212
|
+
pitch = float(opts.get("pitch") or mesh.metadata.get("pitch") or 0.25)
|
|
3213
|
+
occ, origin, _ = mesh_solid(mesh, pitch, pad=0)
|
|
3214
|
+
dims = occ.shape
|
|
3215
|
+
idx = np.argwhere(occ)
|
|
3216
|
+
frac = np.zeros(dims, np.float32)
|
|
3217
|
+
if len(idx):
|
|
3218
|
+
P = origin + idx * pitch
|
|
3219
|
+
mix = b["mix"]
|
|
3220
|
+
f = np.full(len(P), mix) if isinstance(mix, float) else g.evaluate(P, mix)
|
|
3221
|
+
frac[tuple(idx.T)] = np.clip(f, 0.0, 1.0)
|
|
3222
|
+
rho = np.array([m.density for m in mats])
|
|
3223
|
+
mean_f = float(frac[occ].mean()) if occ.any() else 0.0
|
|
3224
|
+
rep = {"materials": [m.name for m in mats], "voxelPitch": pitch, "voxels": int(occ.sum()),
|
|
3225
|
+
"meanFraction": mean_f, "fractionRange": [float(frac[occ].min()), float(frac[occ].max())]
|
|
3226
|
+
if occ.any() else [0.0, 0.0]}
|
|
3227
|
+
if out_dir:
|
|
3228
|
+
try:
|
|
3229
|
+
from .voxelprint import export_voxel_slices
|
|
3230
|
+
except ImportError as exc:
|
|
3231
|
+
rep["voxelJob"] = {"skipped": f"loadpath.voxelprint.export_voxel_slices is not available ({exc})"}
|
|
3232
|
+
else:
|
|
3233
|
+
try:
|
|
3234
|
+
job = export_voxel_slices(os.path.join(out_dir, f"{stem}-voxels"), frac, origin - pitch / 2, pitch,
|
|
3235
|
+
_jetting_names(mats, opts), printer=opts.get("printer", "j850"),
|
|
3236
|
+
solid=occ, max_layers=opts.get("maxLayers"))
|
|
3237
|
+
if isinstance(job, dict):
|
|
3238
|
+
files = job.get("files") or []
|
|
3239
|
+
job = {**{k: v for k, v in job.items() if k != "files"}, "fileCount": len(files)}
|
|
3240
|
+
rep["voxelJob"] = {"dir": os.path.join(out_dir, f"{stem}-voxels"),
|
|
3241
|
+
**(job if isinstance(job, dict) else {"result": str(job)})}
|
|
3242
|
+
except Exception as exc: # noqa: BLE001 - the meshes are the deliverable
|
|
3243
|
+
rep["voxelJob"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
3244
|
+
return rep, float(rho[0] * (1 - mean_f) + rho[1] * mean_f)
|
|
3245
|
+
|
|
3246
|
+
|
|
3247
|
+
# loadpath's jetting cards as GrabCAD Voxel Print resins; other materials get a colour each
|
|
3248
|
+
_JETTING = {"vero": "VeroCyan", "agilus30": "Agilus30Blk", "resin": "VeroPureWht"}
|
|
3249
|
+
_PALETTE = ("#005a9e", "#f0f0f0", "#a62162", "#c8bd03", "#284d6c", "#1a1a1d")
|
|
3250
|
+
|
|
3251
|
+
|
|
3252
|
+
def _jetting_names(mats, opts):
|
|
3253
|
+
if opts.get("materials"):
|
|
3254
|
+
return list(opts["materials"])
|
|
3255
|
+
from .voxelprint import STRATASYS_COLORS
|
|
3256
|
+
|
|
3257
|
+
out = []
|
|
3258
|
+
for i, m in enumerate(mats):
|
|
3259
|
+
name = _JETTING.get(m.name, m.name)
|
|
3260
|
+
out.append(name if name.lower() in STRATASYS_COLORS else (name, _PALETTE[i % len(_PALETTE)]))
|
|
3261
|
+
return out
|
|
3262
|
+
|
|
3263
|
+
|
|
3264
|
+
def _safe(name):
|
|
3265
|
+
import re
|
|
3266
|
+
|
|
3267
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "-", name).strip("-") or "body"
|
|
3268
|
+
|
|
3269
|
+
|
|
3270
|
+
def _material_report(g, doc, part, out_dir, stem, progress):
|
|
3271
|
+
"""run_field's `materials`: each body's mesh, volume and mass, the
|
|
3272
|
+
interface area between each pair, the checks that they tile the part,
|
|
3273
|
+
one STL per body and a multi-material 3MF."""
|
|
3274
|
+
from .materials import material_from_spec
|
|
3275
|
+
from .narrowband import signed_volume, watertight
|
|
3276
|
+
|
|
3277
|
+
pieces, rest, stats = material_bodies(g, part, progress=progress)
|
|
3278
|
+
V0 = signed_volume(np.asarray(part.vertices), np.asarray(part.faces))
|
|
3279
|
+
bodies, mans, extruders = [], [], {}
|
|
3280
|
+
for b, mesh in pieces:
|
|
3281
|
+
mix = "mix" in b
|
|
3282
|
+
mats = [material_from_spec(m) for m in (b["material"] if mix else [b["material"]])]
|
|
3283
|
+
key = "+".join(m.name for m in mats)
|
|
3284
|
+
ext = b.get("extruder") or extruders.setdefault(key, len(extruders) + 1)
|
|
3285
|
+
r = {"name": b["name"], "material": key if not mix else [m.name for m in mats], "extruder": ext}
|
|
3286
|
+
if mesh is None:
|
|
3287
|
+
r.update(volume=0.0, mass=0.0, empty=True)
|
|
3288
|
+
bodies.append((b, r, None, mats))
|
|
3289
|
+
mans.append(None)
|
|
3290
|
+
continue
|
|
3291
|
+
Vb, Fb = np.asarray(mesh.vertices), np.asarray(mesh.faces)
|
|
3292
|
+
r.update(volume=signed_volume(Vb, Fb), area=float(mesh.area), faces=int(len(Fb)),
|
|
3293
|
+
watertight=watertight(Fb, len(Vb)))
|
|
3294
|
+
r["share"] = r["volume"] / V0 if V0 > 0 else 0.0
|
|
3295
|
+
rho = mats[0].density
|
|
3296
|
+
if mix:
|
|
3297
|
+
r["mix"], rho = _mix_job(g, b, mesh, mats, out_dir, f"{stem}-{_safe(b['name'])}", b.get("voxel") or {})
|
|
3298
|
+
r["density"] = rho
|
|
3299
|
+
r["mass"] = r["volume"] * rho / 1000.0
|
|
3300
|
+
bodies.append((b, r, mesh, mats))
|
|
3301
|
+
mans.append(_manifold(mesh))
|
|
3302
|
+
inter, overlap = [], 0.0
|
|
3303
|
+
for i in range(len(mans)):
|
|
3304
|
+
for j in range(i + 1, len(mans)):
|
|
3305
|
+
if mans[i] is None or mans[j] is None:
|
|
3306
|
+
continue
|
|
3307
|
+
both = mans[i] + mans[j]
|
|
3308
|
+
area = 0.5 * (mans[i].surface_area() + mans[j].surface_area() - both.surface_area())
|
|
3309
|
+
ov = (mans[i] ^ mans[j]).volume()
|
|
3310
|
+
overlap = max(overlap, ov)
|
|
3311
|
+
if area > 1e-6:
|
|
3312
|
+
inter.append({"bodies": [bodies[i][1]["name"], bodies[j][1]["name"]], "area": float(area)})
|
|
3313
|
+
total = sum(r["volume"] for _, r, _, _ in bodies)
|
|
3314
|
+
left = 0.0 if rest.is_empty() else float(rest.volume())
|
|
3315
|
+
rep = {"bodies": [r for _, r, _, _ in bodies], "interfaces": inter,
|
|
3316
|
+
"check": {"partVolume": V0, "bodyVolume": total, "unassignedVolume": left,
|
|
3317
|
+
"volumeError": (total + left - V0) / V0 if V0 else 0.0, "maxOverlap": float(overlap),
|
|
3318
|
+
"watertight": all(r.get("watertight", True) for _, r, _, _ in bodies)},
|
|
3319
|
+
"regionsMeshed": stats["regions"], "mass": sum(r["mass"] for _, r, _, _ in bodies)}
|
|
3320
|
+
warn = []
|
|
3321
|
+
if left > 1e-4 * max(V0, 1e-9):
|
|
3322
|
+
warn.append(f"{left:.1f} mm^3 of the part is in no material body: end with a body that has no "
|
|
3323
|
+
"solid or where, to take the rest")
|
|
3324
|
+
warn += [f"material body {r['name']!r} is empty (earlier bodies took all of its region)"
|
|
3325
|
+
for _, r, _, _ in bodies if r.get("empty")]
|
|
3326
|
+
if warn:
|
|
3327
|
+
rep["warnings"] = warn
|
|
3328
|
+
if out_dir:
|
|
3329
|
+
for _, r, mesh, _ in bodies:
|
|
3330
|
+
if mesh is not None:
|
|
3331
|
+
path = os.path.join(out_dir, f"{stem}-{_safe(r['name'])}.stl")
|
|
3332
|
+
mesh.export(path)
|
|
3333
|
+
r["stl"] = path
|
|
3334
|
+
try:
|
|
3335
|
+
from .threemf import write_multimaterial_3mf
|
|
3336
|
+
except ImportError as exc:
|
|
3337
|
+
rep["3mf"] = {"skipped": f"loadpath.threemf.write_multimaterial_3mf is not available ({exc})"}
|
|
3338
|
+
else:
|
|
3339
|
+
spec = []
|
|
3340
|
+
for b, r, mesh, mats in bodies:
|
|
3341
|
+
if mesh is None:
|
|
3342
|
+
continue
|
|
3343
|
+
d = {"name": r["name"], "mesh": mesh, "material": mats[0].name, "extruder": r["extruder"]}
|
|
3344
|
+
if "color" in b:
|
|
3345
|
+
d["color"] = b["color"]
|
|
3346
|
+
spec.append(d)
|
|
3347
|
+
path = os.path.join(out_dir, f"{stem}.3mf")
|
|
3348
|
+
try:
|
|
3349
|
+
res = write_multimaterial_3mf(path, spec, direction=doc.get("build"), name=stem,
|
|
3350
|
+
process=doc.get("process"))
|
|
3351
|
+
rep["3mf"] = path
|
|
3352
|
+
if isinstance(res, dict):
|
|
3353
|
+
rep["3mfReport"] = res
|
|
3354
|
+
except Exception as exc: # noqa: BLE001 - the STLs are written; say why the 3MF is not
|
|
3355
|
+
rep["3mf"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
3356
|
+
return rep
|
|
3357
|
+
|
|
3358
|
+
|
|
3359
|
+
# ---- the job ---------------------------------------------------------------------
|
|
3360
|
+
|
|
3361
|
+
def load_study(ref, base_dir=None, material=None):
|
|
3362
|
+
"""The study a graph analyzes its part under: a JSON path (relative to
|
|
3363
|
+
base_dir) or "example:<name>". The graph's own `material`, when it has
|
|
3364
|
+
one, replaces the study's, so the part is checked in what it will be
|
|
3365
|
+
printed in (and its process takes that card's across-layer values)."""
|
|
3366
|
+
from .spec import study_from_dict
|
|
3367
|
+
|
|
3368
|
+
if ref.startswith("example:"):
|
|
3369
|
+
from .tasks import example
|
|
3370
|
+
|
|
3371
|
+
d, sdir = example(ref.split(":", 1)[1]), base_dir
|
|
3372
|
+
else:
|
|
3373
|
+
sp = ref if os.path.isabs(ref) else os.path.join(base_dir or ".", ref)
|
|
3374
|
+
with open(sp) as fh:
|
|
3375
|
+
d = json.load(fh)
|
|
3376
|
+
sdir = os.path.dirname(sp)
|
|
3377
|
+
if material is not None:
|
|
3378
|
+
d = {**d, "material": material}
|
|
3379
|
+
return study_from_dict(d, base_dir=sdir)
|
|
3380
|
+
|
|
3381
|
+
|
|
3382
|
+
def node_doc(doc, node):
|
|
3383
|
+
"""The graph made to mesh one of its nodes instead of its output: groups
|
|
3384
|
+
expanded (so a node inside a copy is named <copy>__<node>), `output`
|
|
3385
|
+
set to `node` and the name suffixed with it, so its files don't
|
|
3386
|
+
overwrite the whole part's."""
|
|
3387
|
+
full = expand_groups(doc)
|
|
3388
|
+
if node not in (full.get("nodes") or {}):
|
|
3389
|
+
raise ValueError(f"no node {node!r} to mesh (have {', '.join(full.get('nodes') or {})})")
|
|
3390
|
+
return {**full, "output": node, "name": f"{doc.get('name', 'field')}-{node}"}
|
|
3391
|
+
|
|
3392
|
+
|
|
3393
|
+
|
|
3394
|
+
|
|
3395
|
+
def _lattice_variant(doc, g, solid, empty, base_dir, sources, meshes, progress, mesh_opts=None):
|
|
3396
|
+
"""The part as meshed (drain holes and all: drain_graph added them to
|
|
3397
|
+
g) with the lattice nodes in `solid` made solid and those in `empty`
|
|
3398
|
+
gone: the envelope a STEP uses, one lattice's clip region, or the body
|
|
3399
|
+
beside the lattices. A drain bore is an exact cylinder through the
|
|
3400
|
+
skin, blind where its void is solid."""
|
|
3401
|
+
return _lattice_variant_graph(doc, g, solid, empty, base_dir, sources, meshes, mesh_opts).mesh(progress=progress)
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
def _lattice_variant_graph(doc, g, solid, empty, base_dir, sources, meshes, mesh_opts=None, extra=None):
|
|
3405
|
+
"""The FieldGraph behind _lattice_variant (and `extra` nodes added or
|
|
3406
|
+
replacing nodes)."""
|
|
3407
|
+
full = expand_groups(doc)
|
|
3408
|
+
box = {"op": "shape", "shape": {"type": "box", "min": [-1e4] * 3, "max": [1e4] * 3}}
|
|
3409
|
+
nodes = {}
|
|
3410
|
+
for k, nd in g.nodes.items():
|
|
3411
|
+
nodes[k] = dict(box) if k in solid else {"op": "constant", "value": 1e3} if k in empty else nd.spec
|
|
3412
|
+
nodes.update(extra or {})
|
|
3413
|
+
env = {**full, "nodes": nodes, "output": g.output}
|
|
3414
|
+
env.pop("drains", None)
|
|
3415
|
+
if mesh_opts:
|
|
3416
|
+
env["mesh"] = {**(env.get("mesh") or {}), **mesh_opts}
|
|
3417
|
+
pre = {k: (g._built[k], g.analyses.get(k)) for k, nd in g.nodes.items()
|
|
3418
|
+
if nd.op == "result" and k in g._built}
|
|
3419
|
+
return FieldGraph(env, base_dir=base_dir, sources={**(sources or {}), **g.sources}, meshes=meshes).seed(pre)
|
|
3420
|
+
|
|
3421
|
+
|
|
3422
|
+
def _lattice_3mf(doc, g, lattices, out_dir, name, opts, base_dir, sources, meshes, progress):
|
|
3423
|
+
"""<name>-lattice.3mf: the part's lattices as lattices (lattice3mf.py),
|
|
3424
|
+
each clipped by its own region (the part with that lattice solid and
|
|
3425
|
+
the others gone), beside the rest of the part as a mesh. A lattice the
|
|
3426
|
+
graph blends into the body carries the blend (the level set is the
|
|
3427
|
+
smooth union of body and lattice). Meshes are decimated to `decimate`
|
|
3428
|
+
mm (default 0.02) of the true surface."""
|
|
3429
|
+
from .lattice3mf import (
|
|
3430
|
+
_grid,
|
|
3431
|
+
_quantize,
|
|
3432
|
+
blend_of,
|
|
3433
|
+
image,
|
|
3434
|
+
lattice_from_graph,
|
|
3435
|
+
write_lattice_3mf,
|
|
3436
|
+
)
|
|
3437
|
+
|
|
3438
|
+
mo = {"decimate": float(opts.get("decimate", 0.02))}
|
|
3439
|
+
body = _lattice_variant(doc, g, (), lattices, base_dir, sources, meshes, progress, mo)
|
|
3440
|
+
lat = []
|
|
3441
|
+
for k in lattices:
|
|
3442
|
+
clip = _lattice_variant(doc, g, (k,), [x for x in lattices if x != k], base_dir, sources, meshes,
|
|
3443
|
+
progress, mo)
|
|
3444
|
+
if not len(clip.faces):
|
|
3445
|
+
continue
|
|
3446
|
+
lo, hi = clip.bounds
|
|
3447
|
+
x = lattice_from_graph(g, k, lo, hi, opts.get("grid"))
|
|
3448
|
+
x["clip"] = clip
|
|
3449
|
+
b = blend_of(g, k)
|
|
3450
|
+
if isinstance(b, str): # a graded blend radius: sampled like the wall
|
|
3451
|
+
h = float(opts.get("grid") or 1.0)
|
|
3452
|
+
lo_, hi_ = lo - h, hi + h
|
|
3453
|
+
P, shape, size = _grid(lo_, hi_, h)
|
|
3454
|
+
v = np.maximum(np.broadcast_to(np.asarray(g._val(b, P, {}), float), (len(P),)), 1e-3)
|
|
3455
|
+
q, off, span = _quantize(v.reshape(shape))
|
|
3456
|
+
x.setdefault("images", {})[f"{k}-blend"] = (q, off, span)
|
|
3457
|
+
x["blend"] = image(f"{k}-blend", lo_, size)
|
|
3458
|
+
elif b is not None and float(b) > 0:
|
|
3459
|
+
x["blend"] = float(b)
|
|
3460
|
+
lat.append(x)
|
|
3461
|
+
path = os.path.join(out_dir, f"{name}-lattice.3mf")
|
|
3462
|
+
rep = write_lattice_3mf(path, body if len(body.faces) else None, lat, name=name,
|
|
3463
|
+
mesh_readers=bool(opts.get("meshReaders", False)))
|
|
3464
|
+
rep["note"] = ("lattices as lattices (implicit level sets, beam lattices), each clipped by its region; "
|
|
3465
|
+
"the body is a mesh; TPMS-to-body blend fillets are in the level set (mesh distance "
|
|
3466
|
+
"nodes), strut lattices meet the body sharp")
|
|
3467
|
+
for x, l_ in zip(rep["lattices"], lat):
|
|
3468
|
+
for k in ("cell", "thickness", "coordinates"):
|
|
3469
|
+
if k in l_:
|
|
3470
|
+
x[k] = l_[k]
|
|
3471
|
+
if "blend" in l_:
|
|
3472
|
+
x["blend"] = l_["blend"] if isinstance(l_["blend"], float) else "graded"
|
|
3473
|
+
return rep
|
|
3474
|
+
|
|
3475
|
+
def _envelope_step(doc, g, mesh, lattices, out_dir, name, opts, base_dir, sources, meshes, progress):
|
|
3476
|
+
"""STEP for a part with lattices: the part with every lattice solid (a
|
|
3477
|
+
sandwich's skin, bosses and bores exactly as printed; the lattice is in
|
|
3478
|
+
the STL/3MF), since CAD has no use for a few hundred thousand lattice
|
|
3479
|
+
facets and they do not decimate. The report says how much of that
|
|
3480
|
+
outside is the printed part's own surface (all of it under a closed
|
|
3481
|
+
skin; less where the lattice is open)."""
|
|
3482
|
+
from .sharp import mesh_distance
|
|
3483
|
+
from .step import DEFAULT_FACES, DEFAULT_PATCHES, export_step
|
|
3484
|
+
|
|
3485
|
+
em = _lattice_variant(doc, g, lattices, (), base_dir, sources, meshes, progress)
|
|
3486
|
+
st = export_step(em, os.path.join(out_dir, f"{name}.step"), faces=int(opts.get("faces", DEFAULT_FACES)),
|
|
3487
|
+
name=name, surface=opts.get("surface", "facets"),
|
|
3488
|
+
patches=int(opts.get("patches", DEFAULT_PATCHES)))
|
|
3489
|
+
st.pop("shape", None)
|
|
3490
|
+
d = mesh_distance(mesh, em.sample(20000), cap=1.0)
|
|
3491
|
+
tol = max(0.05, 0.5 * float(mesh.metadata.get("pitch") or 0.1))
|
|
3492
|
+
st["lattice"] = {"export": "solid envelope", "lattices": lattices,
|
|
3493
|
+
"trueSurface": round(float((d < tol).mean()), 4),
|
|
3494
|
+
"envelopeVolume": round(float(em.volume), 1), "partVolume": round(float(mesh.volume), 1),
|
|
3495
|
+
"note": "lattices solid in the STEP (outside, bores and faces as printed); "
|
|
3496
|
+
"the printed part, lattice and all, is the STL"
|
|
3497
|
+
+ ("; drain bores are blind there (their voids are solid)"
|
|
3498
|
+
if any(k.startswith("drain-bore-") for k in g.nodes) else "")}
|
|
3499
|
+
return st
|
|
3500
|
+
|
|
3501
|
+
def _write_graded(doc, rep, out_dir):
|
|
3502
|
+
"""With the impact block's `grade`: the graph again with its density node
|
|
3503
|
+
replaced by the graded table (<name>-graded.json beside the STL), ready
|
|
3504
|
+
to mesh and re-check with `loadpath field`. File paths in it resolve
|
|
3505
|
+
against the graph's folder: pass --base-dir if it reads any."""
|
|
3506
|
+
imp, spec = rep.get("impact") or {}, doc.get("impact") or {}
|
|
3507
|
+
dens = spec.get("density")
|
|
3508
|
+
if not imp.get("graded") or not isinstance(dens, str) or dens not in (doc.get("nodes") or {}):
|
|
3509
|
+
return
|
|
3510
|
+
gd = json.loads(json.dumps(doc))
|
|
3511
|
+
gd["name"] = f"{rep['name']}-graded"
|
|
3512
|
+
gd["nodes"][dens] = imp["graded"]["density"]
|
|
3513
|
+
gd["impact"] = {**spec, "grade": False}
|
|
3514
|
+
path = os.path.join(out_dir, f"{gd['name']}.json")
|
|
3515
|
+
with open(path, "w") as fh:
|
|
3516
|
+
json.dump(gd, fh, indent=2)
|
|
3517
|
+
imp["gradedGraph"] = path
|
|
3518
|
+
|
|
3519
|
+
|
|
3520
|
+
def run_field(doc, out_dir=None, base_dir=None, sources=None, meshes=None, progress=None, render=True,
|
|
3521
|
+
prebuilt=None, node=None):
|
|
3522
|
+
"""Build a field graph, mesh its output and report on it: volume,
|
|
3523
|
+
area, mass (with `material`), watertightness, bodies, and the printability
|
|
3524
|
+
check on the field itself (walls, overhangs, trapped voids) unless
|
|
3525
|
+
`check` is false. Writes <name>.stl (and <name>.step with `step`) to
|
|
3526
|
+
`out_dir`; with sharp booleans, `sharpEdges` says how exactly the mesh
|
|
3527
|
+
keeps the plain shapes' surfaces (sharp.edge_accuracy).
|
|
3528
|
+
`prebuilt` reuses resources another graph built (FieldGraph.seed);
|
|
3529
|
+
`node` meshes that node instead of the output (node_doc)."""
|
|
3530
|
+
import time
|
|
3531
|
+
|
|
3532
|
+
t0 = time.time()
|
|
3533
|
+
if node is not None:
|
|
3534
|
+
doc = node_doc(doc, node)
|
|
3535
|
+
g = FieldGraph(doc, base_dir=base_dir, sources=sources, meshes=meshes).seed(prebuilt)
|
|
3536
|
+
process = None
|
|
3537
|
+
if doc.get("process") is not None:
|
|
3538
|
+
from .spec import _process
|
|
3539
|
+
|
|
3540
|
+
process = _process(doc["process"])
|
|
3541
|
+
drains = None
|
|
3542
|
+
from .drains import DrainOptions, drain_graph, drains_wanted
|
|
3543
|
+
|
|
3544
|
+
if drains_wanted(doc.get("drains", "auto"), process):
|
|
3545
|
+
opts = DrainOptions.from_spec(doc.get("drains"))
|
|
3546
|
+
from . import quick
|
|
3547
|
+
|
|
3548
|
+
pitch = (doc.get("mesh") or {}).get("pitch")
|
|
3549
|
+
drains = drain_graph(g, process, opts, build=doc.get("build"), min_wall=doc.get("minWall"),
|
|
3550
|
+
pitch=pitch and quick.mesh_pitch(pitch, None), progress=progress)
|
|
3551
|
+
if (doc.get("mesh") or {}).get("stream"):
|
|
3552
|
+
return None, _run_streamed(doc, g, out_dir, drains, process, progress, t0)
|
|
3553
|
+
mesh = g.mesh(progress=progress)
|
|
3554
|
+
from .narrowband import shell_volumes, signed_volume, watertight
|
|
3555
|
+
|
|
3556
|
+
V, F = np.asarray(mesh.vertices), np.asarray(mesh.faces)
|
|
3557
|
+
rep = {"name": doc.get("name", "field"), "output": g.output, "nodes": len(g.nodes),
|
|
3558
|
+
"pitch": mesh.metadata.get("pitch"), "faces": int(len(mesh.faces)), "watertight": watertight(F, len(V)),
|
|
3559
|
+
"volume": signed_volume(V, F), "area": float(mesh.area),
|
|
3560
|
+
"bounds": [mesh.bounds[0].tolist(), mesh.bounds[1].tolist()]}
|
|
3561
|
+
if "decimation" in mesh.metadata:
|
|
3562
|
+
rep["decimation"] = mesh.metadata["decimation"]
|
|
3563
|
+
# shells: a body's outside, or the wall of a void sealed inside it (normals in)
|
|
3564
|
+
shells = shell_volumes(V, F)[1].tolist()
|
|
3565
|
+
rep["bodies"] = sum(v > 0 for v in shells)
|
|
3566
|
+
rep["enclosedVoids"] = sum(v < 0 for v in shells)
|
|
3567
|
+
rep["enclosedVoidVolume"] = -sum(v for v in shells if v < 0)
|
|
3568
|
+
if process is not None:
|
|
3569
|
+
rep["process"] = process.kind
|
|
3570
|
+
if drains is not None:
|
|
3571
|
+
# the mesh is the last word on whether anything is still sealed, and
|
|
3572
|
+
# for powder and resin processcheck's escape ball says whether it
|
|
3573
|
+
# gets out (LPBF has no card there: sealed shells only)
|
|
3574
|
+
ok = rep["enclosedVoids"] == 0
|
|
3575
|
+
if drains["holes"] or drains["filledVolume"]:
|
|
3576
|
+
from .processcheck import RULES, check_process
|
|
3577
|
+
|
|
3578
|
+
if process is not None and process.kind in RULES:
|
|
3579
|
+
try:
|
|
3580
|
+
esc = check_process(mesh, process, slicer=False)["escape"]
|
|
3581
|
+
drains["meshEscape"] = {k: esc[k] for k in ("enclosedCount", "enclosedVolumeMm3",
|
|
3582
|
+
"narrowExitCount", "narrowExitVolumeMm3")}
|
|
3583
|
+
ok = ok and esc["enclosedCount"] == 0 and esc["narrowExitCount"] == 0
|
|
3584
|
+
except Exception as exc: # noqa: BLE001 - a check never fails the job
|
|
3585
|
+
drains["meshEscape"] = {"error": str(exc)}
|
|
3586
|
+
drains["ok"] = bool(drains["ok"] and ok)
|
|
3587
|
+
rep["drains"] = drains
|
|
3588
|
+
if "material" in doc:
|
|
3589
|
+
from .materials import material_from_spec
|
|
3590
|
+
|
|
3591
|
+
mat = material_from_spec(doc["material"])
|
|
3592
|
+
rep["mass"] = rep["volume"] * mat.density / 1000.0
|
|
3593
|
+
rep["material"] = mat.name
|
|
3594
|
+
for key, mod, fn in (("impact", "impact", "impact_report"), ("vibration", "modal", "vibration_report"),
|
|
3595
|
+
("largeDeflection", "nonlinear", "large_deflection_report")):
|
|
3596
|
+
if doc.get(key) is not None:
|
|
3597
|
+
if "material" not in doc:
|
|
3598
|
+
raise ValueError(f"field graph: '{key}' needs a material")
|
|
3599
|
+
import importlib
|
|
3600
|
+
|
|
3601
|
+
run = getattr(importlib.import_module(f".{mod}", __package__), fn)
|
|
3602
|
+
rep[key] = run(g, doc[key], mat)
|
|
3603
|
+
if g.analyses:
|
|
3604
|
+
# what each `result` node's study said of the part it analyzed
|
|
3605
|
+
rep["analyses"] = {k: {"part": g.nodes[k].spec.get("part"), "safetyFactor": a.get("safetyFactor"),
|
|
3606
|
+
"deflection": max((c["deflection"] for c in a.get("cases") or []), default=None),
|
|
3607
|
+
"cells": a.get("analysisCells")}
|
|
3608
|
+
for k, a in g.analyses.items() if k in g.nodes}
|
|
3609
|
+
if g.plan is not None:
|
|
3610
|
+
from .sharp import edge_accuracy, plan_refs
|
|
3611
|
+
|
|
3612
|
+
Fg = g.field()
|
|
3613
|
+
try:
|
|
3614
|
+
rep["sharpEdges"] = edge_accuracy(mesh, plan_refs(g.plan), boundary=lambda P: np.abs(Fg(P)) < 1e-6)
|
|
3615
|
+
except Exception as exc: # noqa: BLE001 - a measurement never fails the job
|
|
3616
|
+
rep["sharpEdges"] = {"error": str(exc)}
|
|
3617
|
+
if doc.get("check", True):
|
|
3618
|
+
from .checks.dfm import check_dfm
|
|
3619
|
+
|
|
3620
|
+
spec = {"minWall": float(doc.get("minWall", process.min_feature if process is not None else 0.8)),
|
|
3621
|
+
"build": doc.get("build", (0, 0, 1))}
|
|
3622
|
+
try:
|
|
3623
|
+
d = check_dfm(mesh, spec)
|
|
3624
|
+
rep["printability"] = d
|
|
3625
|
+
except Exception as exc: # noqa: BLE001 - a check never fails the job
|
|
3626
|
+
rep["printability"] = {"error": str(exc)}
|
|
3627
|
+
# strut lattices: how much strut length bridges at the build
|
|
3628
|
+
# direction (FDM prints a strut flatter than 45 degrees as a bridge;
|
|
3629
|
+
# powder and resin processes support them, so it is information there)
|
|
3630
|
+
from .homogenized import lattice_nodes
|
|
3631
|
+
from .struts import is_strut, lattice_printability
|
|
3632
|
+
|
|
3633
|
+
for lat in lattice_nodes(g, g.output):
|
|
3634
|
+
if not is_strut(g.nodes[lat].spec.get("type", "gyroid")):
|
|
3635
|
+
continue
|
|
3636
|
+
try:
|
|
3637
|
+
lp = lattice_printability(g, lat, build=doc.get("build", (0, 0, 1)))
|
|
3638
|
+
lp["matters"] = process is None or process.kind == "fdm"
|
|
3639
|
+
except Exception as exc: # noqa: BLE001
|
|
3640
|
+
lp = {"error": str(exc)}
|
|
3641
|
+
rep.setdefault("lattices", {})[lat] = lp
|
|
3642
|
+
if g.bodies and node is None:
|
|
3643
|
+
if out_dir:
|
|
3644
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
3645
|
+
rep["materials"] = _material_report(g, doc, mesh, out_dir, rep["name"], progress)
|
|
3646
|
+
rep["mass"] = rep["materials"]["mass"] # the bodies' own materials, not `material`
|
|
3647
|
+
rep["seconds"] = round(time.time() - t0, 1)
|
|
3648
|
+
if out_dir:
|
|
3649
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
3650
|
+
path = os.path.join(out_dir, f"{rep['name']}.stl")
|
|
3651
|
+
mesh.export(path)
|
|
3652
|
+
rep["stl"] = path
|
|
3653
|
+
_write_graded(doc, rep, out_dir)
|
|
3654
|
+
if doc.get("step"):
|
|
3655
|
+
from .step import DEFAULT_FACES, DEFAULT_PATCHES, export_step
|
|
3656
|
+
|
|
3657
|
+
opts = doc["step"] if isinstance(doc["step"], dict) else {}
|
|
3658
|
+
lat = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
|
|
3659
|
+
try:
|
|
3660
|
+
if lat and opts.get("lattice") in ("exact", "cavities"):
|
|
3661
|
+
from .latticestep import export_cavity_step, export_lattice_step
|
|
3662
|
+
|
|
3663
|
+
fn = export_cavity_step if opts["lattice"] == "cavities" else export_lattice_step
|
|
3664
|
+
st, _ = fn(doc, g, out_dir, rep["name"], base_dir, sources, meshes,
|
|
3665
|
+
h=opts.get("latticeGrid"),
|
|
3666
|
+
surface=opts.get("surface", "smooth"), part_mesh=mesh,
|
|
3667
|
+
progress=progress, faces=opts.get("faces"),
|
|
3668
|
+
patches=opts.get("patches"))
|
|
3669
|
+
elif lat:
|
|
3670
|
+
st = _envelope_step(doc, g, mesh, lat, out_dir, rep["name"], opts, base_dir, sources, meshes, progress)
|
|
3671
|
+
else:
|
|
3672
|
+
st = export_step(mesh, os.path.join(out_dir, f"{rep['name']}.step"),
|
|
3673
|
+
faces=int(opts.get("faces", DEFAULT_FACES)), name=rep["name"],
|
|
3674
|
+
surface=opts.get("surface", "facets"),
|
|
3675
|
+
patches=int(opts.get("patches", DEFAULT_PATCHES)))
|
|
3676
|
+
st.pop("shape", None)
|
|
3677
|
+
rep["step"] = st
|
|
3678
|
+
except Exception as exc: # noqa: BLE001 - the STL is the deliverable; say why STEP is missing
|
|
3679
|
+
rep["step"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
3680
|
+
lat = [k for k, nd in g.nodes.items() if nd.op == "lattice"]
|
|
3681
|
+
if doc.get("lattice3mf") and lat:
|
|
3682
|
+
opts = doc["lattice3mf"] if isinstance(doc["lattice3mf"], dict) else {}
|
|
3683
|
+
try:
|
|
3684
|
+
rep["lattice3mf"] = _lattice_3mf(doc, g, lat, out_dir, rep["name"], opts, base_dir, sources,
|
|
3685
|
+
meshes, progress)
|
|
3686
|
+
except Exception as exc: # noqa: BLE001 - the STL is the deliverable; say why this is missing
|
|
3687
|
+
rep["lattice3mf"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
3688
|
+
if render:
|
|
3689
|
+
try:
|
|
3690
|
+
from .render import render_views
|
|
3691
|
+
|
|
3692
|
+
img = os.path.join(out_dir, f"{rep['name']}.png")
|
|
3693
|
+
render_views(mesh, img, title=rep["name"])
|
|
3694
|
+
rep["render"] = img
|
|
3695
|
+
except Exception: # noqa: BLE001 - renders are optional
|
|
3696
|
+
pass
|
|
3697
|
+
return mesh, rep
|