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/manufacture.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manufacturing constraints for parts that are not printed, and the size and
|
|
3
|
+
pattern controls that go with them: the rest of what Fusion 360's
|
|
4
|
+
generative design offers beside additive.
|
|
5
|
+
|
|
6
|
+
- **Milling** (`"milling"`): every void must be reachable by a cutter coming
|
|
7
|
+
straight in along one of the access directions. 3-axis mills from one
|
|
8
|
+
side (+z by default), 2.5-axis does the same with flat pocket floors at a
|
|
9
|
+
fixed step-down, and 5-axis is approximated as 3+2 positional machining
|
|
10
|
+
from the six axis directions. `toolDiameter` keeps every slot and inside
|
|
11
|
+
corner at least as wide as the cutter.
|
|
12
|
+
- **Casting / molding** (`"casting"`): no undercuts along the draw
|
|
13
|
+
direction. A two-sided mold (the default) pulls halves both ways along the
|
|
14
|
+
draw, so every line parallel to it crosses the part once; a one-sided mold
|
|
15
|
+
pulls one way off a flat parting plane.
|
|
16
|
+
- **Circular pattern** (`"pattern"`): N identical sectors round an axis (a
|
|
17
|
+
turret, a hub, a wheel), as Fusion's circular symmetry.
|
|
18
|
+
- **Maximum member** (`"maxMember"`): no member thicker than this, the
|
|
19
|
+
counterpart of minMember.
|
|
20
|
+
|
|
21
|
+
Access is one operator for milling and casting. A cell is blocked from
|
|
22
|
+
direction d when anything solid lies between it and the outside along d;
|
|
23
|
+
it must then be solid itself, because neither a cutter nor a mold half can
|
|
24
|
+
get there. With several access directions a cell has to be blocked from
|
|
25
|
+
all of them:
|
|
26
|
+
|
|
27
|
+
accessible_i = min over d of max(x_j : j on the ray from i along d)
|
|
28
|
+
|
|
29
|
+
which is a cumulative max per direction (exact, with the gradient routed to
|
|
30
|
+
the cell that set the max) and a min across directions. It runs on the
|
|
31
|
+
projected densities like the AM filter (am.py), so material that would
|
|
32
|
+
leave an undercut is credited as filled, and the optimizer stops putting it
|
|
33
|
+
there. Casting two-sided along z is access from +z and -z; 3-axis milling
|
|
34
|
+
from the top is access from +z, the same as a one-sided mold.
|
|
35
|
+
|
|
36
|
+
The cutter's radius and the 2.5-axis floors are applied exactly to the
|
|
37
|
+
delivered design (`machinable`): the void a flat end mill of that radius can
|
|
38
|
+
sweep from each side, with each pocket's depth rounded up to the step-down,
|
|
39
|
+
and everything else in the design space filled. It only adds material, so
|
|
40
|
+
it can only lower stress; the final analysis runs on the filled part.
|
|
41
|
+
|
|
42
|
+
The maximum member is the local volume constraint of Wu, Aage, Westermann &
|
|
43
|
+
Sigmund (2018, "Infill optimization for additive manufacturing", IEEE TVCG
|
|
44
|
+
24): the solid share of every ball round a design cell stays under ALPHA. A
|
|
45
|
+
slab thicker than the limit fills the ball past it. The ball is sized so a
|
|
46
|
+
flat slab exactly maxMember thick reads ALPHA (a ball of radius R cut by a
|
|
47
|
+
centred slab of half-thickness h holds 1.5 u - 0.5 u^3 of solid, u = h/R);
|
|
48
|
+
a round strut of the same diameter reads less, so for struts the limit is
|
|
49
|
+
looser. Instead of one aggregated constraint each violating ball adds a
|
|
50
|
+
penalty to the optimality-criteria update, as a cost on top of volume,
|
|
51
|
+
whose weight grows while any ball is over: thick members hollow out or split
|
|
52
|
+
in two, and the volume they free goes where the stiffness is.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
|
|
58
|
+
import numpy as np
|
|
59
|
+
from scipy import ndimage
|
|
60
|
+
|
|
61
|
+
__all__ = ["Access", "Pattern", "AccessFilter", "MaxMember", "parse_milling", "parse_casting",
|
|
62
|
+
"parse_pattern", "tie_groups", "machinable", "member_thickness", "report", "AXIS_DIRECTIONS"]
|
|
63
|
+
|
|
64
|
+
AXIS_DIRECTIONS = [(a, s) for a in range(3) for s in (1, -1)]
|
|
65
|
+
# Solid share of a ball allowed by maxMember.
|
|
66
|
+
ALPHA = 0.85
|
|
67
|
+
# Ball radius / maxMember for which a slab maxMember thick reads ALPHA.
|
|
68
|
+
_BALL = 0.754
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _label(d):
|
|
72
|
+
return ("+" if d[1] > 0 else "-") + "xyz"[d[0]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _axis_direction(v, field_name):
|
|
76
|
+
s = str(v).strip().lower()
|
|
77
|
+
if len(s) == 1:
|
|
78
|
+
s = "+" + s
|
|
79
|
+
if len(s) != 2 or s[0] not in "+-" or s[1] not in "xyz":
|
|
80
|
+
raise ValueError(f"{field_name} entries are axis directions like '+z' or '-x' "
|
|
81
|
+
f"(tilted directions are not supported yet), got {v!r}")
|
|
82
|
+
return "xyz".index(s[1]), (1 if s[0] == "+" else -1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Access:
|
|
87
|
+
"""Which way a cutter or mold half reaches the part."""
|
|
88
|
+
kind: str # "milling" | "casting"
|
|
89
|
+
directions: list # (axis, sign): the side the cutter comes from / a mold half pulls toward
|
|
90
|
+
axes: float | None = None # milling: 2.5, 3 or 5
|
|
91
|
+
tool_radius: float | None = None # mm
|
|
92
|
+
step: float | None = None # mm between 2.5-axis pocket floors
|
|
93
|
+
draw: tuple | None = None # casting
|
|
94
|
+
|
|
95
|
+
def summary(self) -> dict:
|
|
96
|
+
out = {"kind": self.kind, "directions": [_label(d) for d in self.directions]}
|
|
97
|
+
if self.kind == "milling":
|
|
98
|
+
out["axes"] = self.axes
|
|
99
|
+
if self.tool_radius:
|
|
100
|
+
out["toolDiameter"] = 2.0 * self.tool_radius
|
|
101
|
+
if self.step:
|
|
102
|
+
out["stepDown"] = self.step
|
|
103
|
+
else:
|
|
104
|
+
out["draw"] = _label(self.draw)
|
|
105
|
+
out["sides"] = len(self.directions)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Pattern:
|
|
111
|
+
"""N identical sectors round an axis through `center`."""
|
|
112
|
+
axis: int
|
|
113
|
+
count: int
|
|
114
|
+
center: np.ndarray | None = None
|
|
115
|
+
|
|
116
|
+
def summary(self) -> dict:
|
|
117
|
+
out = {"type": "circular", "axis": "xyz"[self.axis], "count": self.count}
|
|
118
|
+
if self.center is not None:
|
|
119
|
+
out["center"] = [float(c) for c in self.center]
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_milling(v) -> Access | None:
|
|
124
|
+
"""`"milling": {"axes": 3, "directions": ["+z"], "toolDiameter": 6, "stepDown": 2}`."""
|
|
125
|
+
if v is None:
|
|
126
|
+
return None
|
|
127
|
+
if not isinstance(v, dict):
|
|
128
|
+
raise ValueError("milling is an object: {axes: 2.5 | 3 | 5, directions: ['+z', ...], toolDiameter: mm}")
|
|
129
|
+
axes = float(v.get("axes", 3))
|
|
130
|
+
if axes not in (2.5, 3.0, 5.0):
|
|
131
|
+
raise ValueError("milling.axes is 2.5, 3 or 5")
|
|
132
|
+
dirs = v.get("directions")
|
|
133
|
+
if dirs is None:
|
|
134
|
+
dirs = [_label(d) for d in AXIS_DIRECTIONS] if axes == 5 else ["+z"]
|
|
135
|
+
if isinstance(dirs, str):
|
|
136
|
+
dirs = [dirs]
|
|
137
|
+
if not dirs:
|
|
138
|
+
raise ValueError("milling.directions needs at least one direction the cutter comes from")
|
|
139
|
+
parsed = []
|
|
140
|
+
for d in dirs:
|
|
141
|
+
p = _axis_direction(d, "milling.directions")
|
|
142
|
+
if p not in parsed:
|
|
143
|
+
parsed.append(p)
|
|
144
|
+
tool = v.get("toolDiameter")
|
|
145
|
+
if tool is not None and not (isinstance(tool, (int, float)) and tool > 0):
|
|
146
|
+
raise ValueError("milling.toolDiameter is the smallest cutter's diameter in mm (> 0)")
|
|
147
|
+
step = v.get("stepDown")
|
|
148
|
+
if step is not None and not (isinstance(step, (int, float)) and step > 0):
|
|
149
|
+
raise ValueError("milling.stepDown is the spacing of 2.5-axis pocket floors in mm (> 0)")
|
|
150
|
+
if step is not None and axes != 2.5:
|
|
151
|
+
raise ValueError("milling.stepDown is for 2.5-axis milling (axes: 2.5)")
|
|
152
|
+
if axes == 2.5 and step is None:
|
|
153
|
+
step = 2.0
|
|
154
|
+
return Access("milling", parsed, axes=axes, tool_radius=0.5 * float(tool) if tool else None,
|
|
155
|
+
step=float(step) if step else None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_casting(v) -> Access | None:
|
|
159
|
+
"""`"casting": {"draw": "+z", "sides": 2}`; a string is the draw direction."""
|
|
160
|
+
if v is None:
|
|
161
|
+
return None
|
|
162
|
+
if isinstance(v, str):
|
|
163
|
+
v = {"draw": v}
|
|
164
|
+
if not isinstance(v, dict) or "draw" not in v:
|
|
165
|
+
raise ValueError("casting is {draw: '+z', sides: 1 | 2}: the direction the mold opens")
|
|
166
|
+
draw = _axis_direction(v["draw"], "casting.draw")
|
|
167
|
+
sides = v.get("sides", 2)
|
|
168
|
+
if sides not in (1, 2):
|
|
169
|
+
raise ValueError("casting.sides is 2 (mold halves pull both ways along the draw) or 1 (one half pulls "
|
|
170
|
+
"toward draw off a flat parting plane)")
|
|
171
|
+
dirs = [draw] if sides == 1 else [draw, (draw[0], -draw[1])]
|
|
172
|
+
return Access("casting", dirs, draw=draw)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_pattern(v) -> Pattern | None:
|
|
176
|
+
"""`"pattern": {"type": "circular", "axis": "z", "count": 6, "center": [x, y, z]}`."""
|
|
177
|
+
if v is None:
|
|
178
|
+
return None
|
|
179
|
+
if not isinstance(v, dict):
|
|
180
|
+
raise ValueError("pattern is {type: 'circular', axis: 'z', count: N, center: [x, y, z]}")
|
|
181
|
+
if v.get("type", "circular") != "circular":
|
|
182
|
+
raise ValueError("pattern.type must be 'circular'")
|
|
183
|
+
a = str(v.get("axis", "z")).strip().lower().lstrip("+-")
|
|
184
|
+
if a not in ("x", "y", "z"):
|
|
185
|
+
raise ValueError("pattern.axis is 'x', 'y' or 'z'")
|
|
186
|
+
n = v.get("count")
|
|
187
|
+
if not isinstance(n, int) or isinstance(n, bool) or n < 2:
|
|
188
|
+
raise ValueError("pattern.count is the number of identical sectors (an integer, at least 2)")
|
|
189
|
+
c = v.get("center")
|
|
190
|
+
if c is not None:
|
|
191
|
+
c = np.asarray(c, float)
|
|
192
|
+
if c.shape != (3,) or not np.all(np.isfinite(c)):
|
|
193
|
+
raise ValueError("pattern.center is a point [x, y, z] on the axis")
|
|
194
|
+
return Pattern("xyz".index(a), n, c)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- access ---------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _cummax(f, axis, sign, track):
|
|
200
|
+
"""Max over each cell and everything beyond it toward the `sign` end of
|
|
201
|
+
`axis` (where the cutter comes from), and the axis coordinate of the
|
|
202
|
+
cell that set it."""
|
|
203
|
+
g = np.moveaxis(f, axis, -1)
|
|
204
|
+
if sign > 0:
|
|
205
|
+
g = g[..., ::-1]
|
|
206
|
+
m = np.maximum.accumulate(g, axis=-1)
|
|
207
|
+
idx = None
|
|
208
|
+
if track:
|
|
209
|
+
n = g.shape[-1]
|
|
210
|
+
pos = np.where(g >= m, np.arange(n), 0)
|
|
211
|
+
idx = np.maximum.accumulate(pos, axis=-1)
|
|
212
|
+
if sign > 0:
|
|
213
|
+
idx = (n - 1 - idx)[..., ::-1]
|
|
214
|
+
idx = np.moveaxis(idx, -1, axis)
|
|
215
|
+
if sign > 0:
|
|
216
|
+
m = m[..., ::-1]
|
|
217
|
+
return np.moveaxis(m, -1, axis), idx
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AccessFilter:
|
|
221
|
+
"""Same interface as am.AMFilter: forward/backward on full grids."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, dims, directions, track: bool = True):
|
|
224
|
+
self.dims = tuple(int(d) for d in dims)
|
|
225
|
+
self.directions = list(directions)
|
|
226
|
+
self.track = track
|
|
227
|
+
self._state = None
|
|
228
|
+
|
|
229
|
+
def forward(self, field: np.ndarray) -> np.ndarray:
|
|
230
|
+
vals, idxs = [], []
|
|
231
|
+
for a, s in self.directions:
|
|
232
|
+
m, idx = _cummax(field, a, s, self.track)
|
|
233
|
+
vals.append(m)
|
|
234
|
+
idxs.append(idx)
|
|
235
|
+
if len(vals) == 1:
|
|
236
|
+
out, choice = vals[0], None
|
|
237
|
+
else:
|
|
238
|
+
stack = np.stack(vals)
|
|
239
|
+
choice = np.argmin(stack, axis=0)
|
|
240
|
+
out = np.take_along_axis(stack, choice[None], axis=0)[0]
|
|
241
|
+
self._state = (idxs, choice) if self.track else None
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
def backward(self, grad: np.ndarray) -> np.ndarray:
|
|
245
|
+
if self._state is None:
|
|
246
|
+
raise RuntimeError("AccessFilter.backward needs a tracked forward first")
|
|
247
|
+
idxs, choice = self._state
|
|
248
|
+
flat = np.arange(int(np.prod(self.dims))).reshape(self.dims)
|
|
249
|
+
strides = [self.dims[1] * self.dims[2], self.dims[2], 1]
|
|
250
|
+
coords = np.indices(self.dims)
|
|
251
|
+
out = np.zeros(flat.size)
|
|
252
|
+
for k, (a, _) in enumerate(self.directions):
|
|
253
|
+
g = grad if choice is None else np.where(choice == k, grad, 0.0)
|
|
254
|
+
target = flat + (idxs[k] - coords[a]) * strides[a]
|
|
255
|
+
out += np.bincount(target.ravel(), weights=g.ravel(), minlength=flat.size)
|
|
256
|
+
return out.reshape(self.dims)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _disk(r_cells: float, axis: int) -> np.ndarray:
|
|
260
|
+
k = int(np.floor(r_cells + 1e-9))
|
|
261
|
+
i, j = np.mgrid[-k:k + 1, -k:k + 1]
|
|
262
|
+
d = (i * i + j * j) <= r_cells * r_cells + 1e-9
|
|
263
|
+
return np.expand_dims(d, axis)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def machinable(solid: np.ndarray, access: Access, pitch: float) -> np.ndarray:
|
|
267
|
+
"""The void a cutter (or mold half) can reach: exact, on a bool grid.
|
|
268
|
+
Returns the bool grid of cells it cannot reach; with a tool radius the
|
|
269
|
+
cutter is a flat end mill of that radius entering along each direction,
|
|
270
|
+
and with a step-down each pocket's depth is rounded up to it (floors on
|
|
271
|
+
a grid of levels from the stock face)."""
|
|
272
|
+
r = (access.tool_radius or 0.0) / pitch
|
|
273
|
+
pad = int(np.ceil(r)) + 1
|
|
274
|
+
s = np.pad(solid, pad, constant_values=False)
|
|
275
|
+
reach_any = np.zeros_like(s)
|
|
276
|
+
step = (access.step / pitch) if access.step else None
|
|
277
|
+
for a, sign in access.directions:
|
|
278
|
+
void = ~s
|
|
279
|
+
disk = _disk(r, a) if r >= 1.0 else None
|
|
280
|
+
centre_ok = ndimage.binary_erosion(void, structure=disk, border_value=1) if disk is not None else void
|
|
281
|
+
g = np.moveaxis(centre_ok, a, -1)
|
|
282
|
+
if sign > 0:
|
|
283
|
+
g = g[..., ::-1]
|
|
284
|
+
reach = np.logical_and.accumulate(g, axis=-1)
|
|
285
|
+
if step:
|
|
286
|
+
# depth reached inside the stock, rounded up to a floor level
|
|
287
|
+
# (so less is cut); a through-cut stays through
|
|
288
|
+
n = reach.shape[-1]
|
|
289
|
+
depth = reach.sum(axis=-1)
|
|
290
|
+
level = np.floor(np.floor(np.maximum(depth - pad, 0) / step + 1e-9) * step + 1e-9).astype(int) + pad
|
|
291
|
+
depth = np.where((depth >= n) | (depth < pad), depth, level)
|
|
292
|
+
reach = np.arange(n) < depth[..., None]
|
|
293
|
+
if sign > 0:
|
|
294
|
+
reach = reach[..., ::-1]
|
|
295
|
+
reach = np.moveaxis(reach, -1, a)
|
|
296
|
+
cut = ndimage.binary_dilation(reach, structure=disk) if disk is not None else reach
|
|
297
|
+
reach_any |= cut
|
|
298
|
+
blocked = ~reach_any
|
|
299
|
+
return blocked[pad:-pad, pad:-pad, pad:-pad]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- pattern and ties -----------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def tie_groups(domain, extrusion: int | None = None, pattern: Pattern | None = None):
|
|
305
|
+
"""Label per element: cells with one label share one design value (the
|
|
306
|
+
columns of an extruded part, the sectors of a circular pattern, or
|
|
307
|
+
both). None when nothing is tied."""
|
|
308
|
+
mesh = domain.mesh
|
|
309
|
+
if extrusion is None and pattern is None:
|
|
310
|
+
return None
|
|
311
|
+
if pattern is None:
|
|
312
|
+
a = extrusion
|
|
313
|
+
others = [i for i in range(3) if i != a]
|
|
314
|
+
ij = mesh.elem_ijk[:, others]
|
|
315
|
+
_, columns = np.unique(ij[:, 0] * (domain.dims[others[1]] + 1) + ij[:, 1], return_inverse=True)
|
|
316
|
+
return columns
|
|
317
|
+
from scipy.sparse import coo_matrix
|
|
318
|
+
from scipy.sparse.csgraph import connected_components
|
|
319
|
+
|
|
320
|
+
n = mesh.n_elem
|
|
321
|
+
rows, cols = [], []
|
|
322
|
+
if extrusion is not None:
|
|
323
|
+
cols_ = tie_groups(domain, extrusion, None)
|
|
324
|
+
_, first = np.unique(cols_, return_index=True)
|
|
325
|
+
rows.append(np.arange(n))
|
|
326
|
+
cols.append(first[cols_])
|
|
327
|
+
lut = np.full(domain.dims, -1, np.int64)
|
|
328
|
+
lut[tuple(mesh.elem_ijk.T)] = np.arange(n)
|
|
329
|
+
pts = domain.origin + (mesh.elem_ijk + 0.5) * domain.pitch
|
|
330
|
+
c = pattern.center if pattern.center is not None else domain.origin + 0.5 * np.asarray(domain.dims) * domain.pitch
|
|
331
|
+
a = pattern.axis
|
|
332
|
+
u_ax, v_ax = (a + 1) % 3, (a + 2) % 3
|
|
333
|
+
q = pts - c
|
|
334
|
+
theta = np.mod(np.arctan2(q[:, v_ax], q[:, u_ax]), 2 * np.pi)
|
|
335
|
+
sector = 2 * np.pi / pattern.count
|
|
336
|
+
k = np.floor(theta / sector + 1e-9)
|
|
337
|
+
back = -k * sector
|
|
338
|
+
cu, sv = np.cos(back), np.sin(back)
|
|
339
|
+
p0 = pts.copy()
|
|
340
|
+
p0[:, u_ax] = c[u_ax] + cu * q[:, u_ax] - sv * q[:, v_ax]
|
|
341
|
+
p0[:, v_ax] = c[v_ax] + sv * q[:, u_ax] + cu * q[:, v_ax]
|
|
342
|
+
ijk0 = np.floor((p0 - domain.origin) / domain.pitch).astype(int)
|
|
343
|
+
ok = np.all((ijk0 >= 0) & (ijk0 < np.asarray(domain.dims)), axis=1)
|
|
344
|
+
rep = np.full(n, -1)
|
|
345
|
+
rep[ok] = lut[tuple(ijk0[ok].T)]
|
|
346
|
+
has = rep >= 0
|
|
347
|
+
rows.append(np.arange(n)[has])
|
|
348
|
+
cols.append(rep[has])
|
|
349
|
+
r = np.concatenate(rows)
|
|
350
|
+
cc = np.concatenate(cols)
|
|
351
|
+
g = coo_matrix((np.ones(len(r)), (r, cc)), shape=(n, n))
|
|
352
|
+
_, labels = connected_components(g, directed=False)
|
|
353
|
+
return labels
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --- maximum member -------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
class MaxMember:
|
|
359
|
+
"""Local volume penalty (module docstring). Call with the projected
|
|
360
|
+
densities; returns the excess over ALPHA (as a share of 1 - ALPHA) at
|
|
361
|
+
the worst ball and the 95th percentile over the solid, and the penalty
|
|
362
|
+
gradient per element."""
|
|
363
|
+
|
|
364
|
+
def __init__(self, domain, max_member: float, design: np.ndarray):
|
|
365
|
+
from scipy.signal import fftconvolve
|
|
366
|
+
|
|
367
|
+
self._conv = lambda f: fftconvolve(f, self.kernel, mode="same")
|
|
368
|
+
self.domain = domain
|
|
369
|
+
r = _BALL * float(max_member) / domain.pitch
|
|
370
|
+
k = int(np.ceil(r))
|
|
371
|
+
i, j, l = np.mgrid[-k:k + 1, -k:k + 1, -k:k + 1]
|
|
372
|
+
self.kernel = ((i * i + j * j + l * l) <= r * r).astype(float)
|
|
373
|
+
self.ijk = tuple(domain.mesh.elem_ijk.T)
|
|
374
|
+
self.design = design
|
|
375
|
+
# normalized by the whole ball: the outside and keep-outs count as
|
|
376
|
+
# void, so a flange along a face reads its own thickness (normalized
|
|
377
|
+
# over the cells that exist, it read as if mirrored: twice as thick)
|
|
378
|
+
self.norm = float(self.kernel.sum())
|
|
379
|
+
|
|
380
|
+
def local(self, xp: np.ndarray) -> np.ndarray:
|
|
381
|
+
f = np.zeros(self.domain.dims)
|
|
382
|
+
f[self.ijk] = np.where(self.design, xp, 0.0) # preserved bosses are not members
|
|
383
|
+
return np.clip(self._conv(f)[self.ijk], 0.0, None) / self.norm
|
|
384
|
+
|
|
385
|
+
def __call__(self, xp: np.ndarray):
|
|
386
|
+
"""(worst excess, 95th percentile excess over the solid, gradient)."""
|
|
387
|
+
rho = self.local(xp)
|
|
388
|
+
v = np.where(self.design, np.maximum(rho - ALPHA, 0.0) / (1.0 - ALPHA), 0.0)
|
|
389
|
+
solid = self.design & (xp > 0.5)
|
|
390
|
+
over = float(np.percentile(v[solid], 95)) if solid.any() else 0.0
|
|
391
|
+
g = np.zeros(self.domain.dims)
|
|
392
|
+
g[self.ijk] = 2.0 * v / (1.0 - ALPHA) / self.norm
|
|
393
|
+
grad = np.clip(self._conv(g)[self.ijk], 0.0, None) * self.design
|
|
394
|
+
return float(v.max()), over, grad
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def member_thickness(solid: np.ndarray, preserved: np.ndarray, pitch: float) -> float:
|
|
398
|
+
"""Thickest member (mm) of a bool grid, from the distance transform:
|
|
399
|
+
twice the largest inscribed ball in the solid that keeps clear of
|
|
400
|
+
preserved geometry (a boss is the user's, not a member)."""
|
|
401
|
+
if not solid.any():
|
|
402
|
+
return 0.0
|
|
403
|
+
d = ndimage.distance_transform_edt(np.pad(solid, 1)) [1:-1, 1:-1, 1:-1]
|
|
404
|
+
dp = ndimage.distance_transform_edt(~preserved) if preserved.any() else np.full(solid.shape, np.inf)
|
|
405
|
+
ok = solid & ~preserved & (d <= dp)
|
|
406
|
+
if not ok.any():
|
|
407
|
+
return 0.0
|
|
408
|
+
return float(max(2.0 * d[ok].max() - 1.0, 1.0) * pitch)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# --- report ---------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
def report(study, domain, mesh) -> dict:
|
|
414
|
+
"""What the delivered mesh does against the study's manufacturing
|
|
415
|
+
constraints, measured on its voxelization at the design grid."""
|
|
416
|
+
from .verify import voxelize
|
|
417
|
+
|
|
418
|
+
out = {}
|
|
419
|
+
access, pattern, mx = study.access, study.pattern, study.max_member
|
|
420
|
+
if access is None and pattern is None and mx is None:
|
|
421
|
+
return out
|
|
422
|
+
lo = domain.origin
|
|
423
|
+
hi = lo + np.asarray(domain.dims) * domain.pitch
|
|
424
|
+
solid, _ = voxelize(mesh, domain.pitch, box=(lo, hi))
|
|
425
|
+
v = domain.pitch ** 3
|
|
426
|
+
if access is not None:
|
|
427
|
+
rough = Access(access.kind, access.directions) # exact access, no cutter radius or floors
|
|
428
|
+
blocked = machinable(solid, rough, domain.pitch)
|
|
429
|
+
hidden = blocked & ~solid
|
|
430
|
+
keep = np.zeros(domain.dims, bool)
|
|
431
|
+
if study.keep_out:
|
|
432
|
+
ctr = (domain.origin + (np.indices(domain.dims).reshape(3, -1).T + 0.5) * domain.pitch)
|
|
433
|
+
for r in study.keep_out:
|
|
434
|
+
keep |= r.shape.contains(ctr).reshape(domain.dims)
|
|
435
|
+
# the cells its surface cuts through are its shadow too
|
|
436
|
+
keep = ndimage.binary_dilation(keep, iterations=1)
|
|
437
|
+
out["access"] = access.summary() | {
|
|
438
|
+
"undercutVolume": float(hidden.sum() * v),
|
|
439
|
+
"undercutInKeepOuts": float((hidden & keep).sum() * v),
|
|
440
|
+
}
|
|
441
|
+
if pattern is not None:
|
|
442
|
+
out["pattern"] = pattern.summary()
|
|
443
|
+
if mx is not None:
|
|
444
|
+
pres = domain.full_grid(domain.preserve.astype(float)) > 0.5
|
|
445
|
+
out["maxMember"] = {"limit": mx, "measured": member_thickness(solid, pres, domain.pitch)}
|
|
446
|
+
return out
|