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/voxelprint.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Voxel print export: a multi-material fraction field as a GrabCAD Voxel Print
|
|
3
|
+
PNG slice stack for PolyJet material jetting (Stratasys J750 / J850 / J8,
|
|
4
|
+
J5) and other voxel-addressable printers.
|
|
5
|
+
|
|
6
|
+
A multi-material design here is a continuous field: per cell, the volume
|
|
7
|
+
fraction of each of k materials (0 = rigid Vero, 1 = rubbery Agilus30, in
|
|
8
|
+
between a graded mix), the rest void. A material-jetting printer drops
|
|
9
|
+
exactly one material per printer voxel, so a mix is made by dithering: the
|
|
10
|
+
field is resampled (trilinear) to the printer's native voxel grid and every
|
|
11
|
+
printer voxel is assigned one material (or left empty) so that the local
|
|
12
|
+
share of each material matches the requested fraction. The result is one
|
|
13
|
+
PNG per layer whose pixel colours are the materials, which GrabCAD Print's
|
|
14
|
+
Voxel Print Utility turns into a .GCVF print job (Apps > Voxel Print
|
|
15
|
+
Utility: give the folder and the slice prefix, then map each colour to a
|
|
16
|
+
loaded material).
|
|
17
|
+
|
|
18
|
+
Format facts, sourced from Stratasys "Guide to Voxel Printing"
|
|
19
|
+
(https://support.stratasys.com/en/Software/GrabCAD-Print/Tips-Guides-and-FAQs/Guide-to-Voxel-Printing,
|
|
20
|
+
formerly https://help.grabcad.com/article/230-guide-to-voxel-printing):
|
|
21
|
+
|
|
22
|
+
- PNG is the supported input for new work (one PNG per layer); the per-
|
|
23
|
+
material 1-bit BMP route is legacy.
|
|
24
|
+
- J750 slice resolution is 600 DPI in X and 300 DPI in Y (a 1 inch cube is
|
|
25
|
+
600 x 300 pixel slices, so image width is X and height is Y).
|
|
26
|
+
- Layer thickness by build mode: High Quality 0.014 mm, High Mix 0.027 mm,
|
|
27
|
+
High Speed 0.027 mm. Slices should be cut at the print layer thickness;
|
|
28
|
+
otherwise the printer repeats or skips slices.
|
|
29
|
+
- At least 30 slices.
|
|
30
|
+
- Each image may use at most 6 distinct colours (3 in High Speed) plus the
|
|
31
|
+
background colour; the utility matches colours to loaded materials.
|
|
32
|
+
Anti-aliasing / dithering *between colours* (blended RGB values at
|
|
33
|
+
boundaries) causes a "Too many colors" error. The dithering done here
|
|
34
|
+
never blends colours: every pixel is exactly one palette colour, so it is
|
|
35
|
+
the safe kind.
|
|
36
|
+
- The guide's example makes background pixels transparent (alpha 0), and
|
|
37
|
+
the utility lets the background colour be checked/set. The slice file
|
|
38
|
+
prefix (e.g. "slice_") is entered in the utility.
|
|
39
|
+
- Base-material reference RGB colours (the table in the guide) are in
|
|
40
|
+
STRATASYS_COLORS below, used as default colours by material name.
|
|
41
|
+
- The J5 uses 300 x 300 DPI and 0.01875 mm slices (Stratasys J5 GrabCAD
|
|
42
|
+
Print manual, https://manuals.plus/stratasys/j5-grabcad-print-manual).
|
|
43
|
+
|
|
44
|
+
Assumed (not stated in a public spec; all configurable):
|
|
45
|
+
|
|
46
|
+
- The J850 / J8-series profile is taken equal to the J750 (600 x 300 DPI,
|
|
47
|
+
0.027 mm High Mix); the guide documents the J750 only. "j850" defaults to
|
|
48
|
+
High Mix, the mode for graded digital materials; "j850-hq" is 0.014 mm.
|
|
49
|
+
- Images are 8-bit RGBA PNGs, void = fully transparent (0, 0, 0, 0)
|
|
50
|
+
(`image_mode="RGB"` writes an opaque background colour instead, white by
|
|
51
|
+
default). Bit depth and palette-vs-truecolour aren't specified.
|
|
52
|
+
- Files are named prefix + zero-padded layer index + ".png", layer 0 at the
|
|
53
|
+
bottom (lowest z). Image row 0 is the +Y edge (the slice seen from above,
|
|
54
|
+
+X right, +Y up); pass `flip_y=False` for row 0 = -Y. Other voxel
|
|
55
|
+
pipelines (e.g. OpenVCAD's material-inkjet compiler,
|
|
56
|
+
https://matterassembly.org/OpenVCAD-Docs/v3/guides/compilers/material-inkjet.html)
|
|
57
|
+
note the handedness may need checking against the tool: print a test
|
|
58
|
+
coupon with an asymmetric feature first.
|
|
59
|
+
- The image covers exactly the field's bounding box (origin to origin +
|
|
60
|
+
shape * pitch), each axis rounded to whole printer voxels.
|
|
61
|
+
|
|
62
|
+
Dithering. "floyd-steinberg" is multi-channel error diffusion within each
|
|
63
|
+
layer: the palette is the simplex vertices (one-hot material or void), so
|
|
64
|
+
the nearest palette entry is the channel with the largest value, and the
|
|
65
|
+
quantization error goes 7/16, 3/16, 5/16, 1/16 to the unvisited neighbours.
|
|
66
|
+
The scan is serial in principle; it is vectorized here as a wavefront
|
|
67
|
+
(pixel (y, x) only depends on pixels with x + 2y smaller, so all pixels on
|
|
68
|
+
one line x + 2y = t are done at once) and across a batch of layers. Layers
|
|
69
|
+
alternate scan orientation, and a small random perturbation of each choice
|
|
70
|
+
(`jitter`; the error is still diffused against the true value) breaks up
|
|
71
|
+
FS's regular textures, so patterns don't stack into identical columns.
|
|
72
|
+
"ordered" (8x8 Bayer) and "blue-noise" (a high-pass noise tile) are
|
|
73
|
+
threshold dithers: a threshold u per pixel picks the material whose
|
|
74
|
+
cumulative-fraction interval contains u, fully vectorized; the tile shifts
|
|
75
|
+
from layer to layer. All three preserve each material's mean share.
|
|
76
|
+
|
|
77
|
+
Geometry. By default (`crisp=True`) the part's outline is not dithered: a
|
|
78
|
+
printer voxel is solid where the resampled total occupancy is >= 0.5, and
|
|
79
|
+
there the material shares are renormalized to sum to 1 and dithered. With
|
|
80
|
+
`crisp=False` void is one more dithered channel (porous grading).
|
|
81
|
+
|
|
82
|
+
Memory: the field is resampled and dithered a few layers at a time, so the
|
|
83
|
+
printer-resolution volume is never held whole.
|
|
84
|
+
"""
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
import json
|
|
88
|
+
import os
|
|
89
|
+
import struct
|
|
90
|
+
import time
|
|
91
|
+
import zlib
|
|
92
|
+
|
|
93
|
+
import numpy as np
|
|
94
|
+
|
|
95
|
+
__all__ = ["export_voxel_slices", "PRINTERS", "STRATASYS_COLORS"]
|
|
96
|
+
|
|
97
|
+
_MM_PER_IN = 25.4
|
|
98
|
+
|
|
99
|
+
# dpi_x, dpi_y, layer_mm, max materials per job (guide: 6, High Speed 3)
|
|
100
|
+
PRINTERS = {
|
|
101
|
+
"j750": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=6,
|
|
102
|
+
mode="High Mix"),
|
|
103
|
+
"j750-hq": dict(dpi_x=600, dpi_y=300, layer_mm=0.014, max_materials=6,
|
|
104
|
+
mode="High Quality"),
|
|
105
|
+
"j750-hs": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=3,
|
|
106
|
+
mode="High Speed"),
|
|
107
|
+
"j850": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=6,
|
|
108
|
+
mode="High Mix"),
|
|
109
|
+
"j850-hq": dict(dpi_x=600, dpi_y=300, layer_mm=0.014, max_materials=6,
|
|
110
|
+
mode="High Quality"),
|
|
111
|
+
"j850-hs": dict(dpi_x=600, dpi_y=300, layer_mm=0.027, max_materials=3,
|
|
112
|
+
mode="High Speed"),
|
|
113
|
+
# J5: resolution from the J5 manual; 5 materials is the J5 cabinet
|
|
114
|
+
# (assumed as the per-job colour limit)
|
|
115
|
+
"j5": dict(dpi_x=300, dpi_y=300, layer_mm=0.01875, max_materials=5,
|
|
116
|
+
mode="J5"),
|
|
117
|
+
}
|
|
118
|
+
PRINTERS["j8"] = PRINTERS["j850"]
|
|
119
|
+
|
|
120
|
+
# Stratasys base-material RGB values from the Guide to Voxel Printing.
|
|
121
|
+
STRATASYS_COLORS = {
|
|
122
|
+
"fullcure720": (255, 255, 170),
|
|
123
|
+
"tango+": (255, 255, 100),
|
|
124
|
+
"tangoblack+": (50, 50, 50),
|
|
125
|
+
"agilus30clr": (255, 255, 100),
|
|
126
|
+
"agilus30blk": (40, 77, 108),
|
|
127
|
+
"agilus30wht": (255, 255, 255),
|
|
128
|
+
"veroflexwt": (255, 255, 255),
|
|
129
|
+
"veropurewht": (240, 240, 240),
|
|
130
|
+
"veroblack": (26, 26, 29),
|
|
131
|
+
"veroflexbk": (26, 26, 29),
|
|
132
|
+
"veroclear": (227, 233, 253),
|
|
133
|
+
"veroflexclr": (227, 233, 253),
|
|
134
|
+
"verocyan": (0, 90, 158),
|
|
135
|
+
"veroflexcy": (0, 90, 158),
|
|
136
|
+
"veromgnt": (166, 33, 98),
|
|
137
|
+
"veroflexmgt": (166, 33, 98),
|
|
138
|
+
"veroyellow": (200, 189, 3),
|
|
139
|
+
"veroflexyl": (200, 189, 3),
|
|
140
|
+
"veroultraclr": (247, 247, 247),
|
|
141
|
+
"rgd821": (247, 247, 247),
|
|
142
|
+
"veromgt-v": (198, 0, 88),
|
|
143
|
+
"veroyl-v": (240, 197, 0),
|
|
144
|
+
"verocy-v": (0, 137, 166),
|
|
145
|
+
"veroblue": (156, 208, 237),
|
|
146
|
+
"verogrey": (185, 185, 185),
|
|
147
|
+
}
|
|
148
|
+
_ALIASES = {"agilus30": "agilus30blk", "vero": "veroblue",
|
|
149
|
+
"verogray": "verogrey", "veromagenta": "veromgnt",
|
|
150
|
+
"agilus30black": "agilus30blk", "agilus30white": "agilus30wht",
|
|
151
|
+
"agilus30clear": "agilus30clr", "tangoplus": "tango+",
|
|
152
|
+
"tangoblackplus": "tangoblack+", "verowhite": "veropurewht"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- inputs
|
|
156
|
+
|
|
157
|
+
def _printer_profile(printer):
|
|
158
|
+
if isinstance(printer, dict):
|
|
159
|
+
p = dict(printer)
|
|
160
|
+
if "voxel_mm" in p:
|
|
161
|
+
dx, dy, dz = p.pop("voxel_mm")
|
|
162
|
+
p.setdefault("dpi_x", _MM_PER_IN / dx)
|
|
163
|
+
p.setdefault("dpi_y", _MM_PER_IN / dy)
|
|
164
|
+
p.setdefault("layer_mm", dz)
|
|
165
|
+
for key in ("dpi_x", "dpi_y", "layer_mm"):
|
|
166
|
+
if key not in p:
|
|
167
|
+
raise ValueError(f"printer dict needs {key!r} (or voxel_mm)")
|
|
168
|
+
p.setdefault("max_materials", 6)
|
|
169
|
+
p.setdefault("mode", "custom")
|
|
170
|
+
p.setdefault("name", "custom")
|
|
171
|
+
return p
|
|
172
|
+
key = str(printer).lower().replace(" ", "")
|
|
173
|
+
if key not in PRINTERS:
|
|
174
|
+
raise ValueError(f"unknown printer {printer!r}; known: "
|
|
175
|
+
f"{sorted(PRINTERS)} or a dict with dpi_x, dpi_y, "
|
|
176
|
+
"layer_mm")
|
|
177
|
+
p = dict(PRINTERS[key])
|
|
178
|
+
p["name"] = key
|
|
179
|
+
return p
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_color(c):
|
|
183
|
+
if isinstance(c, str):
|
|
184
|
+
s = c.lstrip("#")
|
|
185
|
+
if len(s) != 6:
|
|
186
|
+
raise ValueError(f"colour {c!r}: want '#rrggbb'")
|
|
187
|
+
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
|
|
188
|
+
c = tuple(int(round(v)) for v in c)
|
|
189
|
+
if len(c) != 3 or not all(0 <= v <= 255 for v in c):
|
|
190
|
+
raise ValueError(f"colour {c!r}: want 3 ints 0..255")
|
|
191
|
+
return c
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_materials(materials, k):
|
|
195
|
+
out = []
|
|
196
|
+
for m in materials:
|
|
197
|
+
if isinstance(m, dict):
|
|
198
|
+
name, color = m["name"], m.get("color")
|
|
199
|
+
elif isinstance(m, str):
|
|
200
|
+
name, color = m, None
|
|
201
|
+
else:
|
|
202
|
+
name, color = m
|
|
203
|
+
if color is None:
|
|
204
|
+
key = name.lower().replace(" ", "")
|
|
205
|
+
key = _ALIASES.get(key, key)
|
|
206
|
+
if key not in STRATASYS_COLORS:
|
|
207
|
+
raise ValueError(f"no default colour for material {name!r}; "
|
|
208
|
+
"give (name, color)")
|
|
209
|
+
color = STRATASYS_COLORS[key]
|
|
210
|
+
out.append({"name": str(name), "color": _parse_color(color)})
|
|
211
|
+
if len(out) != k:
|
|
212
|
+
raise ValueError(f"{len(out)} materials given for {k} fraction "
|
|
213
|
+
"channels")
|
|
214
|
+
cols = [m["color"] for m in out]
|
|
215
|
+
if len(set(cols)) != len(cols):
|
|
216
|
+
raise ValueError(f"material colours must be distinct, got {cols}; "
|
|
217
|
+
"the utility maps colours to materials")
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------ resampling
|
|
222
|
+
|
|
223
|
+
def _axis_weights(n_out, step_out, pitch, n_in):
|
|
224
|
+
"""Linear interpolation of printer-voxel centres onto cell centres."""
|
|
225
|
+
u = ((np.arange(n_out) + 0.5) * step_out) / pitch - 0.5
|
|
226
|
+
u = np.clip(u, 0.0, n_in - 1)
|
|
227
|
+
i0 = np.minimum(np.floor(u).astype(np.int64), max(n_in - 2, 0))
|
|
228
|
+
i1 = np.minimum(i0 + 1, n_in - 1)
|
|
229
|
+
w = (u - i0).astype(np.float32)
|
|
230
|
+
return i0, i1, w
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# -------------------------------------------------------------- dithering
|
|
234
|
+
|
|
235
|
+
def _bayer(n=8):
|
|
236
|
+
m = np.zeros((1, 1), dtype=np.int64)
|
|
237
|
+
while m.shape[0] < n:
|
|
238
|
+
m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
|
|
239
|
+
return ((m + 0.5) / m.size).astype(np.float32)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _blue_noise(n=64, seed=12345):
|
|
243
|
+
"""A blue-ish noise threshold tile: white noise, high-passed in Fourier
|
|
244
|
+
space (periodic, so tiles seamlessly), then rank-mapped to uniform."""
|
|
245
|
+
rng = np.random.default_rng(seed)
|
|
246
|
+
f = np.fft.fft2(rng.standard_normal((n, n)))
|
|
247
|
+
kx = np.fft.fftfreq(n)[:, None]
|
|
248
|
+
ky = np.fft.fftfreq(n)[None, :]
|
|
249
|
+
r = np.sqrt(kx ** 2 + ky ** 2)
|
|
250
|
+
f *= 1.0 - np.exp(-(r / 0.18) ** 2)
|
|
251
|
+
g = np.real(np.fft.ifft2(f)).ravel()
|
|
252
|
+
t = np.empty(n * n, dtype=np.float32)
|
|
253
|
+
t[np.argsort(g, kind="stable")] = (np.arange(n * n) + 0.5) / (n * n)
|
|
254
|
+
return t.reshape(n, n)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _threshold_dither(vals, tile, layer_ids):
|
|
258
|
+
"""vals (L, H, W, C) with channel 0 = void. Picks the channel whose
|
|
259
|
+
cumulative interval holds the per-pixel threshold."""
|
|
260
|
+
L, H, W, C = vals.shape
|
|
261
|
+
ty, tx = tile.shape
|
|
262
|
+
c = np.cumsum(vals, axis=-1)
|
|
263
|
+
out = np.empty((L, H, W), dtype=np.uint8)
|
|
264
|
+
for li, iz in enumerate(layer_ids):
|
|
265
|
+
# step the tile offset through all ty*tx shifts in a scrambled order
|
|
266
|
+
# (odd multiplier, coprime to the power-of-two tile size)
|
|
267
|
+
m = (int(iz) * 2654435761) % (ty * tx)
|
|
268
|
+
oy, ox = divmod(m, tx)
|
|
269
|
+
tt = np.roll(tile, (oy, ox), axis=(0, 1))
|
|
270
|
+
thr = np.tile(tt, (H // ty + 1, W // tx + 1))[:H, :W]
|
|
271
|
+
idx = (thr[..., None] >= c[li]).sum(axis=-1)
|
|
272
|
+
out[li] = np.minimum(idx, C - 1)
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fs_dither(vals, void_mask, jitter=0.0, seed=0):
|
|
277
|
+
"""Multi-channel Floyd-Steinberg on L layers at once, wavefront order.
|
|
278
|
+
vals (L, H, W, C) float32; void_mask (L, H, W) bool or None (pixels
|
|
279
|
+
forced to channel 0, whose error is dropped). `jitter` perturbs only the
|
|
280
|
+
choice (the error is still taken against the true value, so means are
|
|
281
|
+
kept); it breaks up FS's regular textures so they don't repeat layer
|
|
282
|
+
after layer."""
|
|
283
|
+
L, H, W, C = vals.shape
|
|
284
|
+
noise = None
|
|
285
|
+
if jitter > 0:
|
|
286
|
+
rng = np.random.default_rng(seed)
|
|
287
|
+
noise = (rng.random((L, H, W, C), dtype=np.float32) - 0.5) * jitter
|
|
288
|
+
buf = np.zeros((L, H + 1, W + 2, C), dtype=np.float32)
|
|
289
|
+
buf[:, :H, 1:W + 1] = vals
|
|
290
|
+
out = np.empty((L, H, W), dtype=np.uint8)
|
|
291
|
+
ys_all = np.arange(H)
|
|
292
|
+
li = np.arange(L)[:, None]
|
|
293
|
+
for t in range(W + 2 * (H - 1)):
|
|
294
|
+
y_lo = max(0, -(-(t - (W - 1)) // 2))
|
|
295
|
+
y_hi = min(H - 1, t // 2)
|
|
296
|
+
ys = ys_all[y_lo:y_hi + 1]
|
|
297
|
+
xs = t - 2 * ys
|
|
298
|
+
v = buf[:, ys, xs + 1] # (L, n, C)
|
|
299
|
+
if noise is None:
|
|
300
|
+
q = v.argmax(axis=-1) # (L, n)
|
|
301
|
+
else:
|
|
302
|
+
q = (v + noise[:, ys, xs]).argmax(axis=-1)
|
|
303
|
+
if void_mask is not None:
|
|
304
|
+
m = void_mask[:, ys, xs]
|
|
305
|
+
q[m] = 0
|
|
306
|
+
out[:, ys, xs] = q
|
|
307
|
+
v[li, np.arange(len(ys))[None, :], q] -= 1.0 # v is now the error
|
|
308
|
+
if void_mask is not None:
|
|
309
|
+
v[m] = 0.0
|
|
310
|
+
buf[:, ys, xs + 2] += v * (7.0 / 16.0)
|
|
311
|
+
buf[:, ys + 1, xs] += v * (3.0 / 16.0)
|
|
312
|
+
buf[:, ys + 1, xs + 1] += v * (5.0 / 16.0)
|
|
313
|
+
buf[:, ys + 1, xs + 2] += v * (1.0 / 16.0)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------- PNG out
|
|
318
|
+
|
|
319
|
+
def _png_bytes(img):
|
|
320
|
+
"""Minimal 8-bit RGB/RGBA PNG writer (no Pillow)."""
|
|
321
|
+
h, w, ch = img.shape
|
|
322
|
+
ctype = {3: 2, 4: 6}[ch]
|
|
323
|
+
raw = np.empty((h, 1 + w * ch), dtype=np.uint8)
|
|
324
|
+
raw[:, 0] = 0
|
|
325
|
+
raw[:, 1:] = img.reshape(h, w * ch)
|
|
326
|
+
|
|
327
|
+
def chunk(tag, data):
|
|
328
|
+
return (struct.pack(">I", len(data)) + tag + data
|
|
329
|
+
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
|
330
|
+
|
|
331
|
+
return (b"\x89PNG\r\n\x1a\n"
|
|
332
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, ctype, 0, 0, 0))
|
|
333
|
+
+ chunk(b"IDAT", zlib.compress(raw.tobytes(), 3))
|
|
334
|
+
+ chunk(b"IEND", b""))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _png_writer(dpi):
|
|
338
|
+
try:
|
|
339
|
+
from PIL import Image
|
|
340
|
+
except ImportError:
|
|
341
|
+
Image = None
|
|
342
|
+
|
|
343
|
+
def write(path, img):
|
|
344
|
+
if Image is not None:
|
|
345
|
+
mode = "RGBA" if img.shape[2] == 4 else "RGB"
|
|
346
|
+
Image.fromarray(img, mode).save(path, compress_level=3,
|
|
347
|
+
dpi=dpi)
|
|
348
|
+
else:
|
|
349
|
+
with open(path, "wb") as f:
|
|
350
|
+
f.write(_png_bytes(img))
|
|
351
|
+
return write, ("pillow" if Image is not None else "zlib")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------ main
|
|
355
|
+
|
|
356
|
+
def export_voxel_slices(out_dir, fraction, origin, pitch, materials,
|
|
357
|
+
printer="j850", dither="floyd-steinberg", solid=None,
|
|
358
|
+
max_layers=None, *, prefix="slice_", crisp=True,
|
|
359
|
+
image_mode="RGBA", background=(255, 255, 255),
|
|
360
|
+
flip_y=True, jitter=0.25, batch_mb=96, verbose=False) -> dict:
|
|
361
|
+
"""Write a GrabCAD Voxel Print PNG slice stack for a material field.
|
|
362
|
+
|
|
363
|
+
out_dir: folder for the PNGs and manifest.json (created).
|
|
364
|
+
fraction: (nx, ny, nz, k) per-cell volume fractions of k materials
|
|
365
|
+
(sum over k <= 1; the rest is void), or (nx, ny, nz) for a two-
|
|
366
|
+
material mix (0 = materials[0], 1 = materials[1]) with `solid` the
|
|
367
|
+
occupancy. Cell (0, 0, 0)'s min corner is at `origin` (mm); cells
|
|
368
|
+
are `pitch` mm cubes (a scalar or (px, py, pz)).
|
|
369
|
+
materials: k entries, each a name (colour from STRATASYS_COLORS, e.g.
|
|
370
|
+
"VeroCyan", "Agilus30Blk"), (name, color) or {"name", "color"};
|
|
371
|
+
colours are RGB tuples or "#rrggbb" and must be distinct.
|
|
372
|
+
printer: a PRINTERS key ("j850" = 600 x 300 DPI, 0.027 mm High Mix;
|
|
373
|
+
"j850-hq" 0.014 mm; "j750*"; "j5") or a dict with dpi_x, dpi_y,
|
|
374
|
+
layer_mm (or voxel_mm=(dx, dy, dz)) and optional max_materials.
|
|
375
|
+
dither: "floyd-steinberg", "ordered" (Bayer 8x8) or "blue-noise".
|
|
376
|
+
solid: (nx, ny, nz) bool/float occupancy, multiplies the fractions;
|
|
377
|
+
required meaning for the 2-material form (default: all solid).
|
|
378
|
+
max_layers: write only the first this-many layers (previews, tests).
|
|
379
|
+
crisp: threshold the outline at occupancy 0.5 instead of dithering void.
|
|
380
|
+
image_mode: "RGBA" (void transparent) or "RGB" (void = `background`).
|
|
381
|
+
flip_y: image row 0 is the +Y edge (seen from above).
|
|
382
|
+
jitter: Floyd-Steinberg decision noise amplitude (0 = plain FS).
|
|
383
|
+
|
|
384
|
+
Returns a summary dict (also written as manifest.json) with the printer
|
|
385
|
+
grid, files, and per-material requested vs achieved share/volume.
|
|
386
|
+
"""
|
|
387
|
+
t_start = time.time()
|
|
388
|
+
prof = _printer_profile(printer)
|
|
389
|
+
f = np.asarray(fraction, dtype=np.float32)
|
|
390
|
+
if f.ndim == 3:
|
|
391
|
+
f = np.stack([1.0 - f, f], axis=-1)
|
|
392
|
+
if solid is None:
|
|
393
|
+
solid = np.ones(f.shape[:3], dtype=np.float32)
|
|
394
|
+
elif f.ndim != 4:
|
|
395
|
+
raise ValueError("fraction must be (nx, ny, nz, k) or (nx, ny, nz)")
|
|
396
|
+
f = np.clip(f, 0.0, 1.0)
|
|
397
|
+
if solid is not None:
|
|
398
|
+
s = np.asarray(solid, dtype=np.float32)
|
|
399
|
+
if s.shape != f.shape[:3]:
|
|
400
|
+
raise ValueError(f"solid shape {s.shape} != grid {f.shape[:3]}")
|
|
401
|
+
f = f * np.clip(s, 0.0, 1.0)[..., None]
|
|
402
|
+
tot = f.sum(axis=-1, keepdims=True)
|
|
403
|
+
f = np.where(tot > 1.0, f / np.maximum(tot, 1e-12), f)
|
|
404
|
+
nx, ny, nz, k = f.shape
|
|
405
|
+
if k > prof["max_materials"]:
|
|
406
|
+
raise ValueError(f"{k} materials; {prof['name']} takes at most "
|
|
407
|
+
f"{prof['max_materials']} per job")
|
|
408
|
+
mats = _parse_materials(materials, k)
|
|
409
|
+
if dither not in ("floyd-steinberg", "ordered", "blue-noise"):
|
|
410
|
+
raise ValueError(f"unknown dither {dither!r}")
|
|
411
|
+
if image_mode not in ("RGBA", "RGB"):
|
|
412
|
+
raise ValueError("image_mode must be 'RGBA' or 'RGB'")
|
|
413
|
+
bg = _parse_color(background)
|
|
414
|
+
if image_mode == "RGB" and bg in [m["color"] for m in mats]:
|
|
415
|
+
raise ValueError("background colour equals a material colour")
|
|
416
|
+
|
|
417
|
+
pitch3 = np.broadcast_to(np.asarray(pitch, dtype=float), (3,))
|
|
418
|
+
origin = np.asarray(origin, dtype=float).reshape(3)
|
|
419
|
+
size = np.array([nx, ny, nz]) * pitch3
|
|
420
|
+
dx = _MM_PER_IN / prof["dpi_x"]
|
|
421
|
+
dy = _MM_PER_IN / prof["dpi_y"]
|
|
422
|
+
dz = float(prof["layer_mm"])
|
|
423
|
+
W = max(1, int(round(size[0] / dx)))
|
|
424
|
+
H = max(1, int(round(size[1] / dy)))
|
|
425
|
+
NZ = max(1, int(round(size[2] / dz)))
|
|
426
|
+
n_write = NZ if max_layers is None else min(NZ, int(max_layers))
|
|
427
|
+
|
|
428
|
+
# channels: 0 = void, 1..k = materials
|
|
429
|
+
full = np.concatenate([1.0 - f.sum(axis=-1, keepdims=True), f], axis=-1)
|
|
430
|
+
full = np.ascontiguousarray(full.astype(np.float32))
|
|
431
|
+
C = k + 1
|
|
432
|
+
xi0, xi1, xw = _axis_weights(W, dx, pitch3[0], nx)
|
|
433
|
+
yi0, yi1, yw = _axis_weights(H, dy, pitch3[1], ny)
|
|
434
|
+
zi0, zi1, zw = _axis_weights(NZ, dz, pitch3[2], nz)
|
|
435
|
+
if flip_y: # row 0 = +Y
|
|
436
|
+
yi0, yi1, yw = yi0[::-1], yi1[::-1], yw[::-1]
|
|
437
|
+
|
|
438
|
+
lut = np.zeros((C, 4), dtype=np.uint8)
|
|
439
|
+
lut[0] = (0, 0, 0, 0) if image_mode == "RGBA" else (*bg, 255)
|
|
440
|
+
for j, m in enumerate(mats):
|
|
441
|
+
lut[j + 1] = (*m["color"], 255)
|
|
442
|
+
nch = 4 if image_mode == "RGBA" else 3
|
|
443
|
+
write, png_backend = _png_writer((prof["dpi_x"], prof["dpi_y"]))
|
|
444
|
+
|
|
445
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
446
|
+
digits = max(4, len(str(NZ - 1)))
|
|
447
|
+
names = [f"{prefix}{i:0{digits}d}.png" for i in range(n_write)]
|
|
448
|
+
|
|
449
|
+
tile = (_bayer(8) if dither == "ordered"
|
|
450
|
+
else _blue_noise() if dither == "blue-noise" else None)
|
|
451
|
+
per_layer_bytes = H * W * C * 4 * (4 if tile is None else 4)
|
|
452
|
+
batch = int(max(1, min(32, batch_mb * 2 ** 20 // max(per_layer_bytes, 1))))
|
|
453
|
+
|
|
454
|
+
counts = np.zeros(C, dtype=np.int64)
|
|
455
|
+
req_raw = np.zeros(C, dtype=np.float64) # resampled fractions
|
|
456
|
+
req_target = np.zeros(C, dtype=np.float64) # what the dither aimed at
|
|
457
|
+
t_dither = 0.0
|
|
458
|
+
for z0 in range(0, n_write, batch):
|
|
459
|
+
ids = np.arange(z0, min(n_write, z0 + batch))
|
|
460
|
+
# z, then x, then y interpolation (separable trilinear)
|
|
461
|
+
a = full[:, :, zi0[ids]] * (1 - zw[ids])[None, None, :, None] \
|
|
462
|
+
+ full[:, :, zi1[ids]] * zw[ids][None, None, :, None]
|
|
463
|
+
a = np.moveaxis(a, 2, 0) # (L,nx,ny,C)
|
|
464
|
+
a = a[:, xi0] * (1 - xw)[None, :, None, None] \
|
|
465
|
+
+ a[:, xi1] * xw[None, :, None, None] # (L,W,ny,C)
|
|
466
|
+
a = a[:, :, yi0] * (1 - yw)[None, None, :, None] \
|
|
467
|
+
+ a[:, :, yi1] * yw[None, None, :, None] # (L,W,H,C)
|
|
468
|
+
vals = np.ascontiguousarray(np.swapaxes(a, 1, 2)) # (L,H,W,C)
|
|
469
|
+
del a
|
|
470
|
+
req_raw += vals.sum(axis=2).sum(axis=(0, 1), dtype=np.float64)
|
|
471
|
+
if crisp:
|
|
472
|
+
occ = 1.0 - vals[..., 0]
|
|
473
|
+
void_mask = occ < 0.5
|
|
474
|
+
vals[..., 1:] /= np.maximum(occ, 1e-6)[..., None]
|
|
475
|
+
vals[..., 0] = 0.0
|
|
476
|
+
vals[void_mask] = 0.0
|
|
477
|
+
vals[void_mask, 0] = 1.0
|
|
478
|
+
else:
|
|
479
|
+
void_mask = None
|
|
480
|
+
req_target += vals.sum(axis=2).sum(axis=(0, 1), dtype=np.float64)
|
|
481
|
+
|
|
482
|
+
t0 = time.time()
|
|
483
|
+
if tile is None:
|
|
484
|
+
# alternate scan orientation layer to layer (flip x, flip y)
|
|
485
|
+
fl = [(iz % 2 == 1, (iz // 2) % 2 == 1) for iz in ids]
|
|
486
|
+
for li, (fx, fy) in enumerate(fl):
|
|
487
|
+
if fx:
|
|
488
|
+
vals[li] = vals[li, :, ::-1].copy()
|
|
489
|
+
if fy:
|
|
490
|
+
vals[li] = vals[li, ::-1].copy()
|
|
491
|
+
vm = None if void_mask is None else void_mask.copy()
|
|
492
|
+
if vm is not None:
|
|
493
|
+
for li, (fx, fy) in enumerate(fl):
|
|
494
|
+
if fx:
|
|
495
|
+
vm[li] = vm[li, :, ::-1].copy()
|
|
496
|
+
if fy:
|
|
497
|
+
vm[li] = vm[li, ::-1].copy()
|
|
498
|
+
idx = _fs_dither(vals, vm, jitter, seed=int(ids[0]))
|
|
499
|
+
for li, (fx, fy) in enumerate(fl):
|
|
500
|
+
if fx:
|
|
501
|
+
idx[li] = idx[li, :, ::-1].copy()
|
|
502
|
+
if fy:
|
|
503
|
+
idx[li] = idx[li, ::-1].copy()
|
|
504
|
+
else:
|
|
505
|
+
idx = _threshold_dither(vals, tile, ids)
|
|
506
|
+
if void_mask is not None:
|
|
507
|
+
idx[void_mask] = 0
|
|
508
|
+
t_dither += time.time() - t0
|
|
509
|
+
del vals
|
|
510
|
+
counts += np.bincount(idx.ravel(), minlength=C)
|
|
511
|
+
for li, iz in enumerate(ids):
|
|
512
|
+
write(os.path.join(out_dir, names[iz]), lut[idx[li]][..., :nch])
|
|
513
|
+
if verbose:
|
|
514
|
+
print(f"voxelprint: layers {ids[0]}..{ids[-1]} of {n_write}")
|
|
515
|
+
|
|
516
|
+
vox_mm3 = dx * dy * dz
|
|
517
|
+
n_solid = counts[1:].sum()
|
|
518
|
+
tgt_solid = req_target[1:].sum()
|
|
519
|
+
per_mat = []
|
|
520
|
+
for j, m in enumerate(mats):
|
|
521
|
+
req_share = float(req_target[j + 1] / tgt_solid) if tgt_solid else 0.0
|
|
522
|
+
ach_share = float(counts[j + 1] / n_solid) if n_solid else 0.0
|
|
523
|
+
per_mat.append({
|
|
524
|
+
"name": m["name"],
|
|
525
|
+
"color": "#%02x%02x%02x" % m["color"],
|
|
526
|
+
"rgb": list(m["color"]),
|
|
527
|
+
"png_index": j + 1,
|
|
528
|
+
"requested_share": req_share,
|
|
529
|
+
"achieved_share": ach_share,
|
|
530
|
+
"share_error": ach_share - req_share,
|
|
531
|
+
"requested_volume_mm3": float(req_raw[j + 1] * vox_mm3),
|
|
532
|
+
"achieved_volume_mm3": float(counts[j + 1] * vox_mm3),
|
|
533
|
+
"input_volume_mm3": float(f[..., j].sum() * np.prod(pitch3)),
|
|
534
|
+
})
|
|
535
|
+
elapsed = time.time() - t_start
|
|
536
|
+
summary = {
|
|
537
|
+
"format": "GrabCAD Voxel Print PNG slice stack",
|
|
538
|
+
"printer": prof["name"],
|
|
539
|
+
"build_mode": prof.get("mode"),
|
|
540
|
+
"dpi": [prof["dpi_x"], prof["dpi_y"]],
|
|
541
|
+
"voxel_mm": [dx, dy, dz],
|
|
542
|
+
"layer_thickness_mm": dz,
|
|
543
|
+
"width_px": W,
|
|
544
|
+
"height_px": H,
|
|
545
|
+
"layers_total": NZ,
|
|
546
|
+
"layers_written": n_write,
|
|
547
|
+
"truncated": n_write < NZ,
|
|
548
|
+
"size_mm": [W * dx, H * dy, NZ * dz],
|
|
549
|
+
"requested_size_mm": size.tolist(),
|
|
550
|
+
"origin_mm": origin.tolist(),
|
|
551
|
+
"file_prefix": prefix,
|
|
552
|
+
"files": names,
|
|
553
|
+
"image_mode": image_mode,
|
|
554
|
+
"void_color": ("transparent (0,0,0,0)" if image_mode == "RGBA"
|
|
555
|
+
else "#%02x%02x%02x" % bg),
|
|
556
|
+
"orientation": ("columns +X, row 0 = " + ("+Y" if flip_y else "-Y")
|
|
557
|
+
+ " edge, layer 0 = bottom (min z)"),
|
|
558
|
+
"dither": dither,
|
|
559
|
+
"crisp_outline": bool(crisp),
|
|
560
|
+
"materials": per_mat,
|
|
561
|
+
"void_voxels": int(counts[0]),
|
|
562
|
+
"solid_voxels": int(n_solid),
|
|
563
|
+
"max_share_error": max((abs(p["share_error"]) for p in per_mat),
|
|
564
|
+
default=0.0),
|
|
565
|
+
"warnings": ([] if NZ >= 30 else
|
|
566
|
+
[f"{NZ} slices; the Voxel Print Utility wants >= 30"]),
|
|
567
|
+
"png_backend": png_backend,
|
|
568
|
+
"seconds": elapsed,
|
|
569
|
+
"seconds_dither": t_dither,
|
|
570
|
+
"seconds_per_layer": elapsed / max(n_write, 1),
|
|
571
|
+
"utility_steps": ("GrabCAD Print > Apps > Voxel Print Utility: slice "
|
|
572
|
+
f"prefix '{prefix}', select this folder, check "
|
|
573
|
+
f"slice range 0..{n_write - 1}, layer thickness "
|
|
574
|
+
f"{dz} mm and background colour, then map each "
|
|
575
|
+
"colour to its material."),
|
|
576
|
+
}
|
|
577
|
+
manifest = {kk: v for kk, v in summary.items() if kk != "files"}
|
|
578
|
+
manifest["file_pattern"] = f"{prefix}{'#' * digits}.png"
|
|
579
|
+
with open(os.path.join(out_dir, "manifest.json"), "w") as fh:
|
|
580
|
+
json.dump(manifest, fh, indent=2)
|
|
581
|
+
summary["out_dir"] = os.path.abspath(out_dir)
|
|
582
|
+
return summary
|