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/sizing.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Size a two-fluid TPMS heat exchanger for a duty: the core's dimensions
|
|
3
|
+
inside an envelope, the cell, the wall, the split of the void between the
|
|
4
|
+
fluids and each fluid's ports, under a pressure-drop budget per fluid and
|
|
5
|
+
the print process's limits. Seconds: it sweeps the epsilon-NTU rating
|
|
6
|
+
(heatx_thermal.rate), corrected for what the whole-exchanger simulation
|
|
7
|
+
and the LBM showed the rating misses, and the best few can then be
|
|
8
|
+
designed and simulated whole (`verify`).
|
|
9
|
+
|
|
10
|
+
What the rating misses, and how it is put back:
|
|
11
|
+
|
|
12
|
+
- Edge bands. Outside its own zones each fluid is sealed within `cap` of
|
|
13
|
+
the core's surface, and partial channels next to that seal carry
|
|
14
|
+
almost nothing (porous.py, fitted to LBM of the 30 mm crossflow cube).
|
|
15
|
+
So each sealed face loses a band about `cap + cell / 4` deep from the
|
|
16
|
+
core that actually works. On a core a few cells across that is a third
|
|
17
|
+
of it; it is why small cells (more cells across) win more than the
|
|
18
|
+
rating alone says.
|
|
19
|
+
- Ports. A port that jets into its plenum loses more than the rating's
|
|
20
|
+
1.5 velocity heads, and on the cube a 6 mm port at 1 L/min lost more
|
|
21
|
+
at the core's face than the porous medium can show. Each port is sized
|
|
22
|
+
so its velocity heads stay under a share of the fluid's budget
|
|
23
|
+
(`port_share`), as large as its face allows.
|
|
24
|
+
- Plenums. The port turns into its plenum through a ring pi d around and
|
|
25
|
+
`header` deep; a velocity head of that speed in and out is added. On
|
|
26
|
+
Connor's intercooler duty (0.143 kg/s charge air) a 6 mm plenum behind
|
|
27
|
+
a 47 mm port cost more than the core, so the header depth is swept too
|
|
28
|
+
(6-20 mm) and pays for itself out of the envelope.
|
|
29
|
+
|
|
30
|
+
Checked whole (porous.simulate_box, 3 mm voxels) on intercooler-sized
|
|
31
|
+
cores 200 x 70-100 x 62 mm: effectiveness within 5% (the sim reads
|
|
32
|
+
higher), core pressure drop within 10%.
|
|
33
|
+
|
|
34
|
+
The envelope is the part's bounding box. Each face with a zone gives up
|
|
35
|
+
header + shell + port length to its plenum and port; the other faces a
|
|
36
|
+
shell.
|
|
37
|
+
|
|
38
|
+
Process limits (walls that hold pressure and stay leak-tight, channels
|
|
39
|
+
that drain powder or resin): `PROCESS_LIMITS`.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import itertools
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from .heatx_thermal import WALL_DENSITY, _flow, _fluid, rate, tpms_geometry
|
|
48
|
+
|
|
49
|
+
__all__ = ["PROCESS_LIMITS", "size_exchanger", "verify"]
|
|
50
|
+
|
|
51
|
+
# per process: thinnest leak-tight wall (mm), narrowest channel (hydraulic diameter, mm,
|
|
52
|
+
# to clear powder or drain resin), and the heatx process that builds it
|
|
53
|
+
PROCESS_LIMITS = {
|
|
54
|
+
"fdm": {"minWall": 0.8, "minChannel": 1.5, "heatx": "fdm"},
|
|
55
|
+
"lpbf": {"minWall": 0.5, "minChannel": 2.0, "heatx": "lpbf"},
|
|
56
|
+
"sls": {"minWall": 0.8, "minChannel": 2.5, "heatx": "lpbf"},
|
|
57
|
+
"mjf": {"minWall": 0.8, "minChannel": 2.5, "heatx": "lpbf"},
|
|
58
|
+
"sla": {"minWall": 0.6, "minChannel": 1.5, "heatx": "lpbf"},
|
|
59
|
+
"dlp": {"minWall": 0.6, "minChannel": 1.5, "heatx": "lpbf"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
from .porous import _BAND_CELLS # noqa: E402 (the dead band under a sealed face, in cells, beyond the seal)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# port speed to aim for (m/s) when the face has room: whole-exchanger sims put 3-5
|
|
66
|
+
# velocity heads in the headers, so a port sized only to the budget leaves a
|
|
67
|
+
# liquid side over it (intercooler coolant: 11.5 mm port, 18 kPa of headers; 22 mm, 1.8)
|
|
68
|
+
_PORT_SPEED = {"liquid": 1.0, "gas": 25.0}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _port(m, fl, dp_budget, share, d_max, d_min=4.0):
|
|
72
|
+
"""The port (mm, 0.5 mm steps): wide enough that its 1.5 velocity heads
|
|
73
|
+
stay under `share` of the budget and its speed under _PORT_SPEED, as the
|
|
74
|
+
face allows. Returns (d, whether the face capped it below the budget's)."""
|
|
75
|
+
v_aim = _PORT_SPEED["liquid" if fl.rho > 100 else "gas"]
|
|
76
|
+
if dp_budget is None or not np.isfinite(dp_budget):
|
|
77
|
+
v = v_aim
|
|
78
|
+
else:
|
|
79
|
+
v = np.sqrt(share * dp_budget / (0.75 * fl.rho))
|
|
80
|
+
d_need = 2e3 * np.sqrt(m / (fl.rho * np.pi * v))
|
|
81
|
+
d = max(d_need, min(2e3 * np.sqrt(m / (fl.rho * np.pi * v_aim)), d_max))
|
|
82
|
+
d = max(d_min, np.ceil(d * 2.0) / 2.0)
|
|
83
|
+
return float(min(d, d_max)), d_need > d_max
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def size_exchanger(duty: dict, envelope, *, process: str = "fdm", max_pressure_drop: dict | None = None,
|
|
87
|
+
layout: str = "auto", kind: str = "gyroid", cells=None, walls=None, splits=(0.4, 0.5, 0.6, 0.66),
|
|
88
|
+
min_effectiveness: float | None = None, target_duty: float | None = None,
|
|
89
|
+
objective: str = "mass", header: float | None = None, shell: float | None = None,
|
|
90
|
+
port_length: float = 8.0, cap: float | None = None, port_share: float = 0.25,
|
|
91
|
+
fractions=(0.4, 0.55, 0.7, 0.85, 1.0), keep: int = 8, min_wall: float | None = None,
|
|
92
|
+
axes: str | None = None, max_points: int | None = None) -> dict:
|
|
93
|
+
"""Sweep the core's dimensions (fractions of what the envelope leaves),
|
|
94
|
+
cell, wall, split and layout for `duty` ({"A": {"fluid", "kgPerS" |
|
|
95
|
+
"lPerMin", "tIn"}, "B": {...}, "wall": material}). `envelope` (mm) is
|
|
96
|
+
the part's [x, y, z] extent; one fluid runs along x and the other
|
|
97
|
+
along y (cross), or back along the first with side ports on the other
|
|
98
|
+
(counter), both ways round; z carries no ports. Keeps designs within
|
|
99
|
+
`max_pressure_drop` ({fluid: Pa}) and, if given, `min_effectiveness`
|
|
100
|
+
or `target_duty` (W). `objective`: "mass" (lightest that meets them),
|
|
101
|
+
"effectiveness" (best), or "pressure" (least of the worst fluid's
|
|
102
|
+
share of its budget; "metal" is "mass"). Returns the best few, each
|
|
103
|
+
with a heatx spec ready to design ("spec"): its core centred in the
|
|
104
|
+
envelope's frame (the box from [0, 0, 0] to `envelope`), so ports and
|
|
105
|
+
plenums land inside it.
|
|
106
|
+
|
|
107
|
+
`min_wall` (mm) floors the walls swept (the default walls start at the
|
|
108
|
+
process's minimum);
|
|
109
|
+
`axes` ("xy" or "yx") pins which way fluid A runs (A along axes[0]);
|
|
110
|
+
without it both are tried. Only candidates the design step can mesh
|
|
111
|
+
within `max_points` grid points (heatx's cap, default 16M: a grid of at
|
|
112
|
+
most wall / 2) are returned; "unmeshable" counts those dropped.
|
|
113
|
+
"solidVolume" and "coreMass" are the core's sheet only: the shell,
|
|
114
|
+
plenums and ports add to it (the design's report.mass is the part)."""
|
|
115
|
+
from .heatx import HeatExchangerSpec, grid_plan, heat_exchanger_from_spec
|
|
116
|
+
|
|
117
|
+
if process not in PROCESS_LIMITS:
|
|
118
|
+
raise ValueError(f"process is one of {sorted(PROCESS_LIMITS)}")
|
|
119
|
+
objective = "mass" if objective == "metal" else objective
|
|
120
|
+
if objective not in ("mass", "effectiveness", "pressure"):
|
|
121
|
+
raise ValueError('objective is "mass", "effectiveness" or "pressure"')
|
|
122
|
+
if axes is not None and axes not in ("xy", "yx"):
|
|
123
|
+
raise ValueError(f"sizing puts the ports on the envelope's x and y faces (z, the build axis, carries "
|
|
124
|
+
f"none): axes is \"xy\" (the first fluid along x) or \"yx\", got {axes!r}; turn the "
|
|
125
|
+
"envelope so the ports face x and y")
|
|
126
|
+
max_points = int(max_points or HeatExchangerSpec.max_points)
|
|
127
|
+
lim = PROCESS_LIMITS[process]
|
|
128
|
+
names = [n for n in duty if n != "wall"]
|
|
129
|
+
if len(names) != 2:
|
|
130
|
+
raise ValueError("duty names two fluids")
|
|
131
|
+
fls = [_fluid(duty[n].get("fluid", "water")) for n in names]
|
|
132
|
+
ms = [_flow(duty[n], f) for n, f in zip(names, fls)]
|
|
133
|
+
t_in = [duty[n].get("tIn") for n in names]
|
|
134
|
+
caps = [m * f.cp for m, f in zip(ms, fls)]
|
|
135
|
+
if target_duty is not None:
|
|
136
|
+
if None in t_in:
|
|
137
|
+
raise ValueError("target_duty needs each fluid's tIn")
|
|
138
|
+
min_effectiveness = max(min_effectiveness or 0.0, target_duty / (min(caps) * abs(t_in[0] - t_in[1])))
|
|
139
|
+
budget = max_pressure_drop or {}
|
|
140
|
+
wall_mat = duty.get("wall", "petg")
|
|
141
|
+
density = WALL_DENSITY.get(wall_mat) if isinstance(wall_mat, str) else None
|
|
142
|
+
shell = shell if shell is not None else max(1.6, 2 * lim["minWall"])
|
|
143
|
+
env = np.asarray(envelope, float)
|
|
144
|
+
headers = [float(header)] if header is not None else [6.0, 9.0, 12.0, 16.0, 20.0]
|
|
145
|
+
# the process's minimum floors the default walls; min_wall floors any
|
|
146
|
+
floor = max(lim["minWall"] if walls is None else 0.0, float(min_wall or 0.0))
|
|
147
|
+
walls = [w for w in (walls if walls is not None else (0.5, 0.6, 0.8, 1.0, 1.2, 1.5)) if w >= floor - 1e-9]
|
|
148
|
+
if not walls:
|
|
149
|
+
raise ValueError(f"no wall to sweep at or over {floor:g} mm")
|
|
150
|
+
cells = cells if cells is not None else [4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0, 14.0]
|
|
151
|
+
layouts = ["cross", "counter"] if layout == "auto" else [layout]
|
|
152
|
+
rows, rejected = [], 0
|
|
153
|
+
axes_tried = (axes,) if axes else ("xy", "yx")
|
|
154
|
+
for hd, lay, axes in itertools.product(headers, layouts, axes_tried):
|
|
155
|
+
allow = hd + shell + port_length
|
|
156
|
+
# what the envelope leaves the core: A's zones on its axis, B's on the other (both
|
|
157
|
+
# layouts); `room` is in A's frame (A along its first entry)
|
|
158
|
+
e = env if axes == "xy" else env[[1, 0, 2]]
|
|
159
|
+
room = np.array([e[0] - 2 * allow, e[1] - 2 * allow, e[2] - 2 * shell])
|
|
160
|
+
if (room <= 0).any():
|
|
161
|
+
continue
|
|
162
|
+
for fx, fy, fz in itertools.product(fractions, repeat=3):
|
|
163
|
+
dims = room * np.array([fx, fy, fz])
|
|
164
|
+
for a, t, sp in itertools.product(cells, walls, splits):
|
|
165
|
+
if a < 3 * t or min(dims) < a:
|
|
166
|
+
continue
|
|
167
|
+
cp_ = cap if cap is not None else t + 1.0
|
|
168
|
+
try:
|
|
169
|
+
geo = tpms_geometry(kind, a, t, sp)
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
if min(g["dh"] for g in geo["sides"]) < lim["minChannel"]:
|
|
173
|
+
continue
|
|
174
|
+
# the band each sealed face loses, and the core that works
|
|
175
|
+
band = cp_ + _BAND_CELLS * a
|
|
176
|
+
eff_dims = dims - 2 * band
|
|
177
|
+
if (eff_dims <= 0.25 * dims).any():
|
|
178
|
+
continue
|
|
179
|
+
v_eff = float(np.prod(eff_dims))
|
|
180
|
+
if lay == "cross":
|
|
181
|
+
lengths = [dims[0], dims[1]]
|
|
182
|
+
frontal = [eff_dims[1] * eff_dims[2], eff_dims[0] * eff_dims[2]]
|
|
183
|
+
faces = [(dims[1], dims[2]), (dims[0], dims[2])]
|
|
184
|
+
else:
|
|
185
|
+
lengths = [dims[0], dims[0]]
|
|
186
|
+
frontal = [eff_dims[1] * eff_dims[2], eff_dims[1] * eff_dims[2]]
|
|
187
|
+
faces = [(dims[1], dims[2]), (0.4 * dims[0], dims[2])]
|
|
188
|
+
ports, too_big = [], False
|
|
189
|
+
for i, n in enumerate(names):
|
|
190
|
+
d_max = min(faces[i]) - 2 * cp_ - 2.0
|
|
191
|
+
d, over = _port(ms[i], fls[i], budget.get(n, np.inf), port_share, d_max)
|
|
192
|
+
ports.append(d)
|
|
193
|
+
too_big |= over
|
|
194
|
+
# the port turns into the plenum through a ring pi d deep `header`: a
|
|
195
|
+
# velocity head of that speed (whole-exchanger sims put most of the
|
|
196
|
+
# header loss there; a shallow plenum on a big air port dominates)
|
|
197
|
+
if min(ports) < 3.0:
|
|
198
|
+
continue
|
|
199
|
+
# rate the working core: its volume, each fluid's frontal area through
|
|
200
|
+
# its full length (so the dead bands still cost pressure)
|
|
201
|
+
r = rate(kind, a, t, v_eff, [v_eff / f for f in frontal],
|
|
202
|
+
{**{n: duty[n] for n in names}, "wall": wall_mat},
|
|
203
|
+
"counter" if lay == "counter" else "cross", tuple(ports), split=sp)
|
|
204
|
+
dp, plen = {}, {}
|
|
205
|
+
for i, n in enumerate(names):
|
|
206
|
+
s = r["fluids"][n]
|
|
207
|
+
v_ring = ms[i] / (fls[i].rho * np.pi * ports[i] * 1e-3 * hd * 1e-3)
|
|
208
|
+
plen[n] = 2 * 0.5 * fls[i].rho * v_ring ** 2 # in and out
|
|
209
|
+
dp[n] = s["pressureDropCore"] * lengths[i] / (v_eff / frontal[i]) + \
|
|
210
|
+
s["pressureDropPorts"] + s["pressureDropMouths"] + plen[n]
|
|
211
|
+
solid = float(np.prod(dims)) * (1.0 - geo["phiSheet"])
|
|
212
|
+
world = dims if axes == "xy" else dims[[1, 0, 2]]
|
|
213
|
+
row = {"layout": lay, "axes": axes, "fluidAxes": dict(zip(names, axes)),
|
|
214
|
+
"core": np.round(world, 1).tolist(), "cell": float(a), "wall": float(t),
|
|
215
|
+
"split": float(sp), "cap": cp_, "header": hd,
|
|
216
|
+
"pressureDropPlenums": plen, "ports": dict(zip(names, ports)),
|
|
217
|
+
"effectiveness": r["effectiveness"], "effectivenessRange": r["effectivenessRange"],
|
|
218
|
+
"duty": r.get("duty"), "tOut": r.get("tOut"), "pressureDrop": dp,
|
|
219
|
+
"workingCore": round(v_eff / float(np.prod(dims)), 3),
|
|
220
|
+
"solidVolume": solid, "coreMass": solid * density / 1000.0 if density else None,
|
|
221
|
+
"portLimited": bool(too_big)}
|
|
222
|
+
ok = all(dp[n] <= budget.get(n, np.inf) for n in names) and \
|
|
223
|
+
(min_effectiveness is None or r["effectiveness"] >= min_effectiveness)
|
|
224
|
+
if ok:
|
|
225
|
+
rows.append(row)
|
|
226
|
+
else:
|
|
227
|
+
rejected += 1
|
|
228
|
+
if objective == "mass":
|
|
229
|
+
rows.sort(key=lambda x: (x["solidVolume"], -x["effectiveness"]))
|
|
230
|
+
elif objective == "effectiveness":
|
|
231
|
+
rows.sort(key=lambda x: -x["effectiveness"])
|
|
232
|
+
else:
|
|
233
|
+
rows.sort(key=lambda x: max(x["pressureDrop"][n] / budget.get(n, 1.0) for n in names))
|
|
234
|
+
best, unmeshable, nearest = [], 0, None
|
|
235
|
+
for x in rows:
|
|
236
|
+
# skip near-duplicates (same cell, wall and layout) so the list shows real options
|
|
237
|
+
if any(b["cell"] == x["cell"] and b["wall"] == x["wall"] and b["layout"] == x["layout"]
|
|
238
|
+
and b["axes"] == x["axes"] for b in best):
|
|
239
|
+
continue
|
|
240
|
+
spec = _spec(x, names, kind, x["header"], shell, port_length, lim["heatx"], duty, env)
|
|
241
|
+
# only what the design step can mesh within its point cap
|
|
242
|
+
hx = heat_exchanger_from_spec({k: v for k, v in spec.items() if k not in ("name", "duty")})
|
|
243
|
+
hx.max_points = max_points
|
|
244
|
+
plan = grid_plan(hx)
|
|
245
|
+
if not plan["fits"]:
|
|
246
|
+
unmeshable += 1
|
|
247
|
+
nearest = plan if nearest is None or plan["maxPointsNeeded"] < nearest["maxPointsNeeded"] else nearest
|
|
248
|
+
continue
|
|
249
|
+
x["spec"] = spec
|
|
250
|
+
x["grid"] = {k: plan[k] for k in ("step", "points", "peakMemoryGB")}
|
|
251
|
+
best.append(x)
|
|
252
|
+
if len(best) >= keep:
|
|
253
|
+
break
|
|
254
|
+
out = {"best": best[0] if best else None, "candidates": best, "objective": objective,
|
|
255
|
+
"considered": len(rows) + rejected, "feasible": len(rows), "process": process,
|
|
256
|
+
"minEffectiveness": min_effectiveness, "unmeshable": unmeshable, "maxPoints": max_points,
|
|
257
|
+
"envelopeFrame": {"min": [0.0, 0.0, 0.0], "max": env.tolist()}}
|
|
258
|
+
if unmeshable:
|
|
259
|
+
out["note"] = (f"{unmeshable} candidate(s) were dropped: the design step could not mesh them "
|
|
260
|
+
f"within {max_points / 1e6:g}M grid points (it needs a grid of at most wall / 2). The "
|
|
261
|
+
f"nearest needs {nearest['maxPointsNeeded'] / 1e6:.0f}M points, about "
|
|
262
|
+
f"{nearest['memoryNeededGB']:.1f} GB: raise the cap with "
|
|
263
|
+
"--max-points if the machine has that, or take a thicker wall")
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _spec(row, names, kind, header, shell, port_length, heatx_process, duty, envelope=None):
|
|
268
|
+
c = np.asarray(row["core"], float)
|
|
269
|
+
# centred in the envelope's frame ([0, 0, 0] to envelope): ports and plenums inside it
|
|
270
|
+
lo = 0.5 * (np.asarray(envelope, float) - c) if envelope is not None else np.zeros(3)
|
|
271
|
+
return {"name": f"sized-{row['layout']}-{row['cell']:g}-{row['wall']:g}",
|
|
272
|
+
"core": {"type": "box", "min": np.round(lo, 2).tolist(), "max": np.round(lo + c, 2).tolist()},
|
|
273
|
+
"type": kind, "layout": row["layout"],
|
|
274
|
+
"axes": row["axes"], "fluids": list(names), "cell": row["cell"], "wall": row["wall"],
|
|
275
|
+
"split": row["split"], "cap": row["cap"], "header": header, "shell": shell,
|
|
276
|
+
"portLength": port_length, "portDiameter": row["ports"], "process": heatx_process,
|
|
277
|
+
"duty": duty}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def verify(candidate: dict, voxel=None, closures="design", progress=None, max_points=None, method="auto"):
|
|
281
|
+
"""Simulate a sized candidate whole (porous.py): returns the
|
|
282
|
+
simulation's effectiveness, pressure drops and flow sharing next to the
|
|
283
|
+
sizing's own. `method` "design" designs it first (heatx, wall-resolving:
|
|
284
|
+
small cores); "box" builds the porous grid straight from the spec
|
|
285
|
+
(porous.box_domain, cross layout, any size); "auto" takes "box" for a
|
|
286
|
+
cross core over 1e5 mm3. The box sim's header losses read 3-5 port
|
|
287
|
+
velocity heads at 3 mm voxels where cell-resolved LBM of the tuned cube
|
|
288
|
+
measured ~1.4: take its header dp as an upper bound."""
|
|
289
|
+
from .heatx import heat_exchanger_from_spec
|
|
290
|
+
from .porous import simulate_box, simulate_exchanger
|
|
291
|
+
|
|
292
|
+
spec = dict(candidate["spec"])
|
|
293
|
+
duty = spec.pop("duty")
|
|
294
|
+
hx = heat_exchanger_from_spec(spec)
|
|
295
|
+
if max_points:
|
|
296
|
+
hx.max_points = int(max_points)
|
|
297
|
+
if method == "auto":
|
|
298
|
+
method = "box" if spec.get("layout") == "cross" and float(np.prod(candidate["core"])) > 1e5 else "design"
|
|
299
|
+
if method == "box":
|
|
300
|
+
sim = simulate_box(hx, duty, voxel=voxel, closures=closures, progress=progress)
|
|
301
|
+
rep = {}
|
|
302
|
+
else:
|
|
303
|
+
_, rep, sim = simulate_exchanger(hx, duty, voxel=voxel, closures=closures, progress=progress)
|
|
304
|
+
return {"sized": {k: candidate[k] for k in ("effectiveness", "pressureDrop", "tOut")},
|
|
305
|
+
"simulated": {"effectiveness": sim.get("effectiveness"),
|
|
306
|
+
"pressureDrop": {n: f["pressureDrop"] for n, f in sim["fluids"].items()},
|
|
307
|
+
"tOut": {n: f.get("tOut") for n, f in sim["fluids"].items()},
|
|
308
|
+
"midplaneCv": {n: f["midplane"]["cv"] for n, f in sim["fluids"].items()}},
|
|
309
|
+
"design": {"isolated": rep.get("isolated"), "connected": rep.get("connected"),
|
|
310
|
+
"minWallBetweenFluids": rep.get("minWallBetweenFluids"), "solidVolume": rep.get("solidVolume"),
|
|
311
|
+
"warnings": rep.get("warnings")},
|
|
312
|
+
"method": method, "seconds": sim.get("seconds")}
|
loadpath/slicer.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A real slicer in the FDM loop: print time, filament and support material
|
|
3
|
+
measured by slicing the delivered part, not modelled.
|
|
4
|
+
|
|
5
|
+
For an FDM user the cost of a part is mostly hours on the printer, and grams
|
|
6
|
+
are only a proxy for that: two designs of the same mass can print in very
|
|
7
|
+
different times once one of them needs a forest of support. So every FDM
|
|
8
|
+
outcome is sliced headless (PrusaSlicer's command line, or a fork that keeps
|
|
9
|
+
it: SuperSlicer) standing the way it was designed to print, and the G-code
|
|
10
|
+
is read back for
|
|
11
|
+
|
|
12
|
+
- printTime (s), the slicer's own estimate under its machine limits;
|
|
13
|
+
- filament (mm, cm^3, g), and how much of it is support;
|
|
14
|
+
- the extruded volume of each feature type (perimeters, infill, support...).
|
|
15
|
+
|
|
16
|
+
`goal.minimize: "printTime"` then picks the fastest passing outcome instead
|
|
17
|
+
of the lightest, and `slicer.grade` (infill.py) turns the verified stress
|
|
18
|
+
into per-region infill and wall counts the slicer prints as modifiers.
|
|
19
|
+
|
|
20
|
+
The part is verified solid, so the default slice is solid too (100% infill):
|
|
21
|
+
what was analysed is what gets printed. Sparse infill is only safe where the
|
|
22
|
+
stress allows it, which is what grading works out.
|
|
23
|
+
|
|
24
|
+
The built-in profile is a generic 0.4 mm nozzle, 0.2 mm layer bed slinger at
|
|
25
|
+
moderate speeds (Prusa MK3S class); a CoreXY printer runs two to three times
|
|
26
|
+
faster. Pass your own PrusaSlicer config (`slicer.profile`, an .ini exported
|
|
27
|
+
from File > Export > Export Config) for absolute times; for comparing
|
|
28
|
+
outcomes against each other the generic one is enough.
|
|
29
|
+
|
|
30
|
+
Orca Slicer and Bambu Studio open the 3MF this writes (PrusaSlicer's
|
|
31
|
+
modifier metadata), but their command lines differ and are not driven here.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import shutil
|
|
38
|
+
import subprocess
|
|
39
|
+
import tempfile
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
|
|
45
|
+
__all__ = ["SlicerOptions", "find_slicer", "slice_mesh", "read_gcode", "DEFAULT_PROFILE"]
|
|
46
|
+
|
|
47
|
+
# PrusaSlicer config keys. Bed 256 x 256 to match threemf's plate centre.
|
|
48
|
+
DEFAULT_PROFILE = {
|
|
49
|
+
"printer_technology": "FFF",
|
|
50
|
+
"gcode_flavor": "marlin2",
|
|
51
|
+
"bed_shape": "0x0,256x0,256x256,0x256",
|
|
52
|
+
"max_print_height": "256",
|
|
53
|
+
"nozzle_diameter": "0.4",
|
|
54
|
+
"filament_diameter": "1.75",
|
|
55
|
+
"layer_height": "0.2",
|
|
56
|
+
"first_layer_height": "0.2",
|
|
57
|
+
"extrusion_width": "0.45",
|
|
58
|
+
"perimeter_extrusion_width": "0.45",
|
|
59
|
+
"external_perimeter_extrusion_width": "0.45",
|
|
60
|
+
"infill_extrusion_width": "0.45",
|
|
61
|
+
"solid_infill_extrusion_width": "0.45",
|
|
62
|
+
"top_infill_extrusion_width": "0.4",
|
|
63
|
+
"support_material_extrusion_width": "0.35",
|
|
64
|
+
"perimeters": "3",
|
|
65
|
+
"top_solid_layers": "5",
|
|
66
|
+
"bottom_solid_layers": "4",
|
|
67
|
+
"top_solid_min_thickness": "0.7",
|
|
68
|
+
"bottom_solid_min_thickness": "0.5",
|
|
69
|
+
"fill_density": "100%",
|
|
70
|
+
"fill_pattern": "gyroid",
|
|
71
|
+
"infill_anchor": "2.5",
|
|
72
|
+
"perimeter_speed": "45",
|
|
73
|
+
"small_perimeter_speed": "25",
|
|
74
|
+
"external_perimeter_speed": "25",
|
|
75
|
+
"infill_speed": "80",
|
|
76
|
+
"solid_infill_speed": "80",
|
|
77
|
+
"top_solid_infill_speed": "40",
|
|
78
|
+
"support_material_speed": "50",
|
|
79
|
+
"support_material_interface_speed": "80%",
|
|
80
|
+
"bridge_speed": "30",
|
|
81
|
+
"gap_fill_speed": "40",
|
|
82
|
+
"travel_speed": "180",
|
|
83
|
+
"first_layer_speed": "20",
|
|
84
|
+
"max_volumetric_speed": "11.5",
|
|
85
|
+
"perimeter_acceleration": "800",
|
|
86
|
+
"infill_acceleration": "1000",
|
|
87
|
+
"bridge_acceleration": "1000",
|
|
88
|
+
"first_layer_acceleration": "800",
|
|
89
|
+
"default_acceleration": "1000",
|
|
90
|
+
"machine_limits_usage": "time_estimate_only",
|
|
91
|
+
"machine_max_acceleration_extruding": "1250",
|
|
92
|
+
"machine_max_acceleration_travel": "1250",
|
|
93
|
+
"machine_max_acceleration_x": "1000",
|
|
94
|
+
"machine_max_acceleration_y": "1000",
|
|
95
|
+
"machine_max_feedrate_x": "200",
|
|
96
|
+
"machine_max_feedrate_y": "200",
|
|
97
|
+
"support_material": "1",
|
|
98
|
+
"support_material_auto": "1",
|
|
99
|
+
# overhangs flatter than 45 degrees from horizontal get support: the
|
|
100
|
+
# angle loadpath's overhang control designs to, less printcheck's
|
|
101
|
+
# 5 degree tolerance
|
|
102
|
+
"support_material_threshold": "40",
|
|
103
|
+
"support_material_contact_distance": "0.2",
|
|
104
|
+
"support_material_spacing": "2",
|
|
105
|
+
"support_material_style": "grid",
|
|
106
|
+
"skirts": "1",
|
|
107
|
+
"brim_width": "0",
|
|
108
|
+
"use_relative_e_distances": "1",
|
|
109
|
+
"layer_gcode": "G92 E0",
|
|
110
|
+
"gcode_comments": "0",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# PrusaSlicer's ";TYPE:" feature names, by what they are
|
|
114
|
+
_SUPPORT_TYPES = ("Support material", "Support material interface")
|
|
115
|
+
_PART_TYPES = ("Perimeter", "External perimeter", "Overhang perimeter", "Internal infill", "Solid infill",
|
|
116
|
+
"Top solid infill", "Bridge infill", "Gap fill")
|
|
117
|
+
|
|
118
|
+
_NAMES = ("prusa-slicer", "PrusaSlicer", "prusaslicer", "superslicer", "SuperSlicer")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_slicer(path: str | None = None) -> str | None:
|
|
122
|
+
"""The slicer executable: `path`, $LOADPATH_SLICER, or PrusaSlicer (or
|
|
123
|
+
SuperSlicer) on PATH or in its usual macOS place. None if there is none."""
|
|
124
|
+
for cand in (path, os.environ.get("LOADPATH_SLICER")):
|
|
125
|
+
if cand:
|
|
126
|
+
return cand if (os.path.isfile(cand) or shutil.which(cand)) else None
|
|
127
|
+
for n in _NAMES:
|
|
128
|
+
p = shutil.which(n)
|
|
129
|
+
if p:
|
|
130
|
+
return p
|
|
131
|
+
for p in ("/Applications/PrusaSlicer.app/Contents/MacOS/PrusaSlicer",
|
|
132
|
+
"/Applications/Original Prusa Drivers/PrusaSlicer.app/Contents/MacOS/PrusaSlicer"):
|
|
133
|
+
if os.path.isfile(p):
|
|
134
|
+
return p
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SlicerOptions:
|
|
140
|
+
enabled: object = "auto" # "auto": when FDM and a slicer is installed; True: warn if none
|
|
141
|
+
executable: str | None = None
|
|
142
|
+
profile: str | None = None # PrusaSlicer .ini, applied before `settings`
|
|
143
|
+
settings: dict = field(default_factory=dict)
|
|
144
|
+
supports: object = "auto" # "auto"/True: slicer's own auto supports; False: none
|
|
145
|
+
infill: float = 100.0 # percent infill of the ungraded slice (100 = as verified)
|
|
146
|
+
timeout: float = 600.0
|
|
147
|
+
grade: dict | None = None # infill.GradeOptions spec, or None
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_spec(cls, spec, base_dir: str | None = None) -> "SlicerOptions":
|
|
151
|
+
if spec is None:
|
|
152
|
+
return cls()
|
|
153
|
+
if isinstance(spec, bool):
|
|
154
|
+
return cls(enabled=spec)
|
|
155
|
+
if not isinstance(spec, dict):
|
|
156
|
+
raise ValueError("slicer is true/false or an object")
|
|
157
|
+
enabled = spec.get("enabled", "auto")
|
|
158
|
+
if enabled not in (True, False, "auto"):
|
|
159
|
+
raise ValueError("slicer.enabled is true, false or \"auto\"")
|
|
160
|
+
prof = spec.get("profile")
|
|
161
|
+
if prof is not None and base_dir and not os.path.isabs(prof):
|
|
162
|
+
prof = os.path.join(base_dir, prof)
|
|
163
|
+
infill = float(spec.get("infill", 100.0))
|
|
164
|
+
if not 0.0 <= infill <= 100.0:
|
|
165
|
+
raise ValueError("slicer.infill is a percentage, 0..100")
|
|
166
|
+
sup = spec.get("supports", "auto")
|
|
167
|
+
if sup not in (True, False, "auto"):
|
|
168
|
+
raise ValueError("slicer.supports is true, false or \"auto\"")
|
|
169
|
+
settings = spec.get("settings") or {}
|
|
170
|
+
if not isinstance(settings, dict):
|
|
171
|
+
raise ValueError("slicer.settings is an object of PrusaSlicer config keys")
|
|
172
|
+
grade = spec.get("grade")
|
|
173
|
+
if grade is True:
|
|
174
|
+
grade = {}
|
|
175
|
+
elif grade is False:
|
|
176
|
+
grade = None
|
|
177
|
+
elif grade is not None and not isinstance(grade, dict):
|
|
178
|
+
raise ValueError("slicer.grade is true or an object")
|
|
179
|
+
return cls(enabled=enabled, executable=spec.get("executable"), profile=prof,
|
|
180
|
+
settings={str(k): _fmt_setting(v) for k, v in settings.items()}, supports=sup,
|
|
181
|
+
infill=infill, timeout=float(spec.get("timeout", 600.0)), grade=grade)
|
|
182
|
+
|
|
183
|
+
def config(self) -> dict:
|
|
184
|
+
"""The config keys the slice is run with (the profile file aside)."""
|
|
185
|
+
cfg = {} if self.profile else dict(DEFAULT_PROFILE)
|
|
186
|
+
cfg["fill_density"] = f"{self.infill:g}%"
|
|
187
|
+
if self.infill >= 100.0:
|
|
188
|
+
cfg["fill_pattern"] = "rectilinear" # PrusaSlicer refuses gyroid at 100%
|
|
189
|
+
cfg["support_material"] = "0" if self.supports is False else "1"
|
|
190
|
+
cfg["use_relative_e_distances"] = "1" # read_gcode sums E per feature
|
|
191
|
+
cfg.setdefault("layer_gcode", "G92 E0") # which PrusaSlicer requires with it
|
|
192
|
+
cfg.update(self.settings)
|
|
193
|
+
return cfg
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fmt_setting(v) -> str:
|
|
197
|
+
if isinstance(v, bool):
|
|
198
|
+
return "1" if v else "0"
|
|
199
|
+
if isinstance(v, (list, tuple)):
|
|
200
|
+
return ",".join(str(x) for x in v)
|
|
201
|
+
return str(v)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _version(exe: str) -> str:
|
|
205
|
+
try:
|
|
206
|
+
out = subprocess.run([exe, "--help"], capture_output=True, text=True, timeout=30).stdout
|
|
207
|
+
except (OSError, subprocess.SubprocessError):
|
|
208
|
+
return "unknown"
|
|
209
|
+
first = out.strip().splitlines()[0] if out.strip() else ""
|
|
210
|
+
return first.split(" based on")[0].strip() or "unknown"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_TIME_RE = re.compile(r"(\d+)\s*([dhms])")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _seconds(text: str) -> float:
|
|
217
|
+
unit = {"d": 86400, "h": 3600, "m": 60, "s": 1}
|
|
218
|
+
return float(sum(int(n) * unit[u] for n, u in _TIME_RE.findall(text)))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_gcode(path: str, density: float | None = None) -> dict:
|
|
222
|
+
"""Print time, filament and per-feature extruded volume from a
|
|
223
|
+
PrusaSlicer G-code (relative E). `density` g/cm^3 gives grams."""
|
|
224
|
+
feat: dict[str, float] = {}
|
|
225
|
+
cur = "Other"
|
|
226
|
+
meta: dict[str, str] = {}
|
|
227
|
+
dia = 1.75
|
|
228
|
+
relative = None
|
|
229
|
+
with open(path, "r", errors="replace") as fh:
|
|
230
|
+
for line in fh:
|
|
231
|
+
if line.startswith(";TYPE:"):
|
|
232
|
+
cur = line[6:].strip()
|
|
233
|
+
continue
|
|
234
|
+
if line.startswith("; ") and " = " in line:
|
|
235
|
+
k, v = line[2:].split(" = ", 1)
|
|
236
|
+
meta[k.strip()] = v.strip()
|
|
237
|
+
continue
|
|
238
|
+
if line.startswith("M83"):
|
|
239
|
+
relative = True
|
|
240
|
+
elif line.startswith("M82"):
|
|
241
|
+
relative = False
|
|
242
|
+
if line.startswith("G1 ") or line.startswith("G2 ") or line.startswith("G3 "):
|
|
243
|
+
i = line.find(" E")
|
|
244
|
+
if i >= 0:
|
|
245
|
+
j = i + 2
|
|
246
|
+
k = j
|
|
247
|
+
while k < len(line) and (line[k].isdigit() or line[k] in ".-"):
|
|
248
|
+
k += 1
|
|
249
|
+
try:
|
|
250
|
+
e = float(line[j:k])
|
|
251
|
+
except ValueError:
|
|
252
|
+
continue
|
|
253
|
+
if e > 0:
|
|
254
|
+
feat[cur] = feat.get(cur, 0.0) + e
|
|
255
|
+
if "filament_diameter" in meta:
|
|
256
|
+
dia = float(meta["filament_diameter"].split(",")[0])
|
|
257
|
+
if relative is False:
|
|
258
|
+
raise ValueError("read_gcode needs relative extrusion (use_relative_e_distances = 1)")
|
|
259
|
+
area = np.pi * (0.5 * dia) ** 2 / 1000.0 # cm^3 per mm of filament
|
|
260
|
+
vol = {k: v * area for k, v in feat.items()}
|
|
261
|
+
support = sum(vol.get(k, 0.0) for k in _SUPPORT_TYPES)
|
|
262
|
+
total_mm = float(meta.get("filament used [mm]", sum(feat.values())))
|
|
263
|
+
total_cm3 = float(meta.get("filament used [cm3]", total_mm * area))
|
|
264
|
+
t = meta.get("estimated printing time (normal mode)")
|
|
265
|
+
out = {
|
|
266
|
+
"printTime": _seconds(t) if t else None,
|
|
267
|
+
"printTimeText": t,
|
|
268
|
+
"filamentLength": total_mm,
|
|
269
|
+
"filamentVolume": total_cm3,
|
|
270
|
+
"supportVolume": float(support),
|
|
271
|
+
"supportShare": float(support / total_cm3) if total_cm3 > 0 else 0.0,
|
|
272
|
+
"features": {k: round(v, 4) for k, v in sorted(vol.items(), key=lambda kv: -kv[1])},
|
|
273
|
+
"layerHeight": float(meta["layer_height"]) if "layer_height" in meta else None,
|
|
274
|
+
}
|
|
275
|
+
if density is not None:
|
|
276
|
+
out["filamentMass"] = total_cm3 * density
|
|
277
|
+
out["supportMass"] = float(support) * density
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def slice_mesh(mesh, build=None, material=None, opts: SlicerOptions | None = None, *, name: str = "part",
|
|
282
|
+
object_settings: dict | None = None, modifiers: list | None = None,
|
|
283
|
+
keep: str | None = None) -> dict:
|
|
284
|
+
"""Slice `mesh` (trimesh, mm) standing along `build` (as threemf takes
|
|
285
|
+
it) with `opts`; `object_settings` and `modifiers` (dicts with "mesh",
|
|
286
|
+
"settings", "name") are the per-region config graded infill prints
|
|
287
|
+
with. Returns read_gcode's report plus the slicer's name and version and
|
|
288
|
+
the slicing time. `keep` copies the G-code there."""
|
|
289
|
+
from .threemf import write_3mf
|
|
290
|
+
|
|
291
|
+
opts = opts or SlicerOptions()
|
|
292
|
+
exe = find_slicer(opts.executable)
|
|
293
|
+
if exe is None:
|
|
294
|
+
raise FileNotFoundError("no slicer found: install PrusaSlicer (or set LOADPATH_SLICER to its executable)")
|
|
295
|
+
t0 = time.time()
|
|
296
|
+
with tempfile.TemporaryDirectory(prefix="loadpath-slice-") as tmp:
|
|
297
|
+
tmf = os.path.join(tmp, "part.3mf")
|
|
298
|
+
write_3mf(tmf, mesh, build, name=name, material=material, object_settings=object_settings,
|
|
299
|
+
modifiers=modifiers)
|
|
300
|
+
ini = os.path.join(tmp, "loadpath.ini")
|
|
301
|
+
with open(ini, "w") as fh:
|
|
302
|
+
fh.write("".join(f"{k} = {v}\n" for k, v in opts.config().items()))
|
|
303
|
+
gcode = os.path.join(tmp, "part.gcode")
|
|
304
|
+
cmd = [exe]
|
|
305
|
+
if opts.profile:
|
|
306
|
+
cmd += ["--load", opts.profile]
|
|
307
|
+
cmd += ["--load", ini, "--export-gcode", "--output", gcode, tmf]
|
|
308
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=opts.timeout)
|
|
309
|
+
if proc.returncode != 0 or not os.path.isfile(gcode):
|
|
310
|
+
msg = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
|
|
311
|
+
raise RuntimeError(f"slicer failed ({proc.returncode}): {' / '.join(msg)}")
|
|
312
|
+
density = getattr(material, "density", None)
|
|
313
|
+
rep = read_gcode(gcode, density)
|
|
314
|
+
if keep:
|
|
315
|
+
shutil.copyfile(gcode, keep)
|
|
316
|
+
rep["gcode"] = keep
|
|
317
|
+
rep["slicer"] = _version(exe)
|
|
318
|
+
rep["profile"] = opts.profile or "loadpath generic (0.4 mm nozzle, 0.2 mm layers, MK3S-class speeds)"
|
|
319
|
+
rep["infill"] = opts.infill
|
|
320
|
+
rep["seconds"] = time.time() - t0
|
|
321
|
+
return rep
|