Spaces:
Running
Running
Add multi-nozzle shape splitting workflow
Browse files- README.md +15 -6
- app.py +601 -50
- gcode_viewer.py +37 -17
- tests/test_app_scaling.py +85 -0
- tests/test_nozzle_spacing.py +85 -9
- tests/test_tiff_to_gcode.py +481 -0
- tiff_to_gcode.py +593 -1
README.md
CHANGED
|
@@ -48,8 +48,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 48 |
- Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
|
| 49 |
- Lets you step through the slice stack in the browser
|
| 50 |
- Exports a ZIP containing the generated TIFF images
|
| 51 |
-
-
|
| 52 |
-
-
|
|
|
|
| 53 |
- Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 54 |
- Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
|
| 55 |
- Previews selected generated G-code inline
|
|
@@ -61,7 +62,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 61 |
|
| 62 |
### Reference TIFF Stack Alignment
|
| 63 |
|
| 64 |
-
When you click **Generate
|
| 65 |
|
| 66 |
- If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
|
| 67 |
- Layers are centered in X and Y before merging.
|
|
@@ -69,6 +70,14 @@ When you click **Generate Reference TIFF Stack**, the app combines available TIF
|
|
| 69 |
- Alignment is centered image placement, not bottom-left anchoring.
|
| 70 |
- If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
### G-code XY Step Size
|
| 73 |
|
| 74 |
- G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
|
|
@@ -78,10 +87,10 @@ When you click **Generate Reference TIFF Stack**, the app combines available TIF
|
|
| 78 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 79 |
- `G0` is travel and `G1` is print/feed.
|
| 80 |
- The app generates print/feed moves from material pixels and travel moves between material regions.
|
| 81 |
-
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port.
|
| 82 |
- Pressure increases by `0.1` psi per layer by default.
|
| 83 |
- **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
|
| 84 |
-
- **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry.
|
| 85 |
- **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
|
| 86 |
|
| 87 |
### Print vs Travel Classification
|
|
@@ -102,7 +111,7 @@ The G-code visualization tab renders generated shape G-code or an uploaded `.txt
|
|
| 102 |
|
| 103 |
### Parallel Printing Visualization
|
| 104 |
|
| 105 |
-
The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
|
| 106 |
|
| 107 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 108 |
|
|
|
|
| 48 |
- Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
|
| 49 |
- Lets you step through the slice stack in the browser
|
| 50 |
- Exports a ZIP containing the generated TIFF images
|
| 51 |
+
- Automatically combines generated stacks into a reference TIFF stack when TIFF stacks are generated
|
| 52 |
+
- Splits one generated TIFF stack into an editable row/column grid for multi-nozzle printing of one large shape
|
| 53 |
+
- Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
|
| 54 |
- Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 55 |
- Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
|
| 56 |
- Previews selected generated G-code inline
|
|
|
|
| 62 |
|
| 63 |
### Reference TIFF Stack Alignment
|
| 64 |
|
| 65 |
+
When you click **Generate TIFF Stacks**, the app automatically combines available TIFF stacks layer-by-layer into the Reference TIFF Stack. The **Generate Reference TIFF Stack** button can still rebuild it manually from the current shape stacks.
|
| 66 |
|
| 67 |
- If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
|
| 68 |
- Layers are centered in X and Y before merging.
|
|
|
|
| 70 |
- Alignment is centered image placement, not bottom-left anchoring.
|
| 71 |
- If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
|
| 72 |
|
| 73 |
+
### Single Shape Multi-Nozzle
|
| 74 |
+
|
| 75 |
+
The **Single Shape Multi-Nozzle** tab can split one generated shape stack into a grid of print-ready stacks. Choose a source shape that already has TIFF slices, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
|
| 76 |
+
|
| 77 |
+
- Each slice is split into columns along X and rows along Y; leftover pixels are assigned to earlier columns/rows so no pixels are dropped.
|
| 78 |
+
- The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
|
| 79 |
+
- Nozzle and valve numbers are assigned sequentially from the starting values, and the existing **TIFF Slices to GCode** tab can generate separate G-code for each piece.
|
| 80 |
+
|
| 81 |
### G-code XY Step Size
|
| 82 |
|
| 83 |
- G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
|
|
|
|
| 87 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 88 |
- `G0` is travel and `G1` is print/feed.
|
| 89 |
- The app generates print/feed moves from material pixels and travel moves between material regions.
|
| 90 |
+
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
|
| 91 |
- Pressure increases by `0.1` psi per layer by default.
|
| 92 |
- **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
|
| 93 |
+
- **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
|
| 94 |
- **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps.
|
| 95 |
|
| 96 |
### Print vs Travel Classification
|
|
|
|
| 111 |
|
| 112 |
### Parallel Printing Visualization
|
| 113 |
|
| 114 |
+
The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
|
| 115 |
|
| 116 |
It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 117 |
|
app.py
CHANGED
|
@@ -4,6 +4,7 @@ import tempfile
|
|
| 4 |
import math
|
| 5 |
import time
|
| 6 |
import warnings
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
|
@@ -134,7 +135,7 @@ APP_CSS = """
|
|
| 134 |
}
|
| 135 |
|
| 136 |
#shape-settings-table table tbody tr td:last-child,
|
| 137 |
-
#shape-settings-table [role="gridcell"]:nth-child(
|
| 138 |
color: #ffffff !important;
|
| 139 |
cursor: pointer;
|
| 140 |
font-size: 0 !important;
|
|
@@ -143,7 +144,7 @@ APP_CSS = """
|
|
| 143 |
}
|
| 144 |
|
| 145 |
#shape-settings-table table tbody tr td:last-child::after,
|
| 146 |
-
#shape-settings-table [role="gridcell"]:nth-child(
|
| 147 |
content: "X";
|
| 148 |
display: inline-flex;
|
| 149 |
align-items: center;
|
|
@@ -159,9 +160,9 @@ APP_CSS = """
|
|
| 159 |
}
|
| 160 |
|
| 161 |
#shape-settings-table table tbody tr td:last-child input,
|
| 162 |
-
#shape-settings-table [role="gridcell"]:nth-child(
|
| 163 |
#shape-settings-table table tbody tr td:last-child textarea,
|
| 164 |
-
#shape-settings-table [role="gridcell"]:nth-child(
|
| 165 |
display: none !important;
|
| 166 |
}
|
| 167 |
|
|
@@ -295,7 +296,7 @@ APP_HEAD = """
|
|
| 295 |
});
|
| 296 |
}
|
| 297 |
function suppressDeleteCellEditor(event) {
|
| 298 |
-
var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(
|
| 299 |
if (!cell) return;
|
| 300 |
setTimeout(function () {
|
| 301 |
if (document.activeElement && cell.contains(document.activeElement)) {
|
|
@@ -1377,8 +1378,23 @@ def _resolve_nozzle_layout(
|
|
| 1377 |
if not parts:
|
| 1378 |
return offsets, spacings
|
| 1379 |
|
| 1380 |
-
|
| 1381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1382 |
|
| 1383 |
first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
|
| 1384 |
raw_pairs: list[tuple[float, float]] = [
|
|
@@ -1390,37 +1406,41 @@ def _resolve_nozzle_layout(
|
|
| 1390 |
float(extra_pair_gaps[index] or 0.0),
|
| 1391 |
float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
|
| 1392 |
))
|
| 1393 |
-
max_pair_count = max(
|
| 1394 |
while len(raw_pairs) < max_pair_count:
|
| 1395 |
raw_pairs.append(first_spacing)
|
| 1396 |
if same_spacing:
|
| 1397 |
raw_pairs = [first_spacing for _ in raw_pairs]
|
| 1398 |
|
| 1399 |
pair_spacing = {
|
| 1400 |
-
(index
|
| 1401 |
-
for index in range(len(raw_pairs))
|
| 1402 |
}
|
| 1403 |
|
| 1404 |
def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
|
| 1405 |
if (prev_idx, cur_idx) in pair_spacing:
|
| 1406 |
return pair_spacing[(prev_idx, cur_idx)]
|
| 1407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1408 |
return 0.0, 0.0
|
| 1409 |
total_x = 0.0
|
| 1410 |
total_y = 0.0
|
| 1411 |
-
for
|
| 1412 |
-
|
|
|
|
| 1413 |
total_x += step_x
|
| 1414 |
total_y += step_y
|
| 1415 |
return total_x, total_y
|
| 1416 |
|
| 1417 |
-
for
|
| 1418 |
-
prev_idx = previous["idx"]
|
| 1419 |
-
cur_idx = current["idx"]
|
| 1420 |
gap_x, y_step = spacing_between(prev_idx, cur_idx)
|
| 1421 |
prev_offset_x, prev_offset_y = offsets[prev_idx]
|
| 1422 |
-
(_, _, _), (prev_xmax, _prev_ymax, _) =
|
| 1423 |
-
(cur_xmin, _cur_ymin, _), (_, _, _) =
|
| 1424 |
dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
|
| 1425 |
dy = prev_offset_y + y_step
|
| 1426 |
offsets[cur_idx] = (dx, dy)
|
|
@@ -1442,8 +1462,9 @@ def _format_shape_dimensions(parts: list[dict]) -> list[str]:
|
|
| 1442 |
lines = ["**Shape dimensions from generated G-code:**"]
|
| 1443 |
for part in sorted(parts, key=lambda item: item["idx"]):
|
| 1444 |
(xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
|
|
|
|
| 1445 |
lines.append(
|
| 1446 |
-
f"Shape {part['idx']}: X {xmax - xmin:.2f} mm, "
|
| 1447 |
f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
|
| 1448 |
)
|
| 1449 |
return lines
|
|
@@ -1463,7 +1484,7 @@ def _format_nozzle_spacing_status(
|
|
| 1463 |
lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
|
| 1464 |
|
| 1465 |
if not spacings:
|
| 1466 |
-
lines.append("Generate G-code for at least two
|
| 1467 |
return " \n".join(lines)
|
| 1468 |
|
| 1469 |
lines.append("**Nozzle-to-nozzle distances:**")
|
|
@@ -1614,7 +1635,142 @@ def generate_reference_stack(
|
|
| 1614 |
return ref_state, slider, label, preview
|
| 1615 |
|
| 1616 |
|
| 1617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1618 |
SIMPLE_NOZZLE_SPACING_HEADERS = [
|
| 1619 |
"Spacing Mode",
|
| 1620 |
"Applies To",
|
|
@@ -1622,8 +1778,8 @@ SIMPLE_NOZZLE_SPACING_HEADERS = [
|
|
| 1622 |
"Y nozzle spacing (mm)",
|
| 1623 |
]
|
| 1624 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
| 1625 |
-
"From
|
| 1626 |
-
"To
|
| 1627 |
"X edge spacing (mm)",
|
| 1628 |
"Y nozzle spacing (mm)",
|
| 1629 |
]
|
|
@@ -1640,6 +1796,39 @@ def _normalise_rows(table: Any) -> list[list[Any]]:
|
|
| 1640 |
return list(table or [])
|
| 1641 |
|
| 1642 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1643 |
def _file_path_value(file_value: Any) -> str | None:
|
| 1644 |
if not file_value:
|
| 1645 |
return None
|
|
@@ -1710,8 +1899,10 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 1710 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1711 |
"pressure": previous.get("pressure", 25.0),
|
| 1712 |
"valve": previous.get("valve", 4),
|
|
|
|
| 1713 |
"port": previous.get("port", 1),
|
| 1714 |
"color": previous.get("color", _default_color(index)),
|
|
|
|
| 1715 |
"tiff_state": previous.get("tiff_state", _empty_state()),
|
| 1716 |
"zip_path": previous.get("zip_path"),
|
| 1717 |
"gcode_path": previous.get("gcode_path"),
|
|
@@ -1738,8 +1929,10 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
|
| 1738 |
record.get("target_z", DEFAULT_TARGET_EXTENTS[2]),
|
| 1739 |
record.get("pressure", 25.0),
|
| 1740 |
record.get("valve", 4),
|
|
|
|
| 1741 |
record.get("port", 1),
|
| 1742 |
record.get("color", _default_color(record["idx"])),
|
|
|
|
| 1743 |
"Delete",
|
| 1744 |
]
|
| 1745 |
for record in records
|
|
@@ -1773,20 +1966,33 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
|
|
| 1773 |
("target_z", 4, DEFAULT_TARGET_EXTENTS[2]),
|
| 1774 |
("pressure", 5, 25.0),
|
| 1775 |
("valve", 6, 4),
|
| 1776 |
-
("port", 7, 1),
|
| 1777 |
):
|
| 1778 |
try:
|
| 1779 |
copy[key] = float(row[pos])
|
| 1780 |
except (IndexError, TypeError, ValueError):
|
| 1781 |
copy[key] = copy.get(key, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1782 |
try:
|
| 1783 |
-
copy["
|
| 1784 |
-
|
| 1785 |
-
|
| 1786 |
-
copy["valve"] = 4
|
| 1787 |
-
copy["port"] = 1
|
| 1788 |
-
if len(row) > 8 and row[8]:
|
| 1789 |
-
copy["color"] = str(row[8])
|
| 1790 |
updated.append(copy)
|
| 1791 |
return updated
|
| 1792 |
|
|
@@ -1823,8 +2029,23 @@ def _last_edited_target_axes(records: list[dict] | None, settings_table: Any) ->
|
|
| 1823 |
return edited_axes
|
| 1824 |
|
| 1825 |
|
| 1826 |
-
def
|
| 1827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1828 |
|
| 1829 |
|
| 1830 |
def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
|
|
@@ -1849,18 +2070,19 @@ def _spacing_table_update(records: list[dict], existing_table: Any | None = None
|
|
| 1849 |
if not use_individual_spacing:
|
| 1850 |
return gr.update(
|
| 1851 |
headers=SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 1852 |
-
value=[["Same spacing", "All neighboring
|
| 1853 |
row_count=(1, "fixed"),
|
| 1854 |
column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 1855 |
label="Nozzle Spacing",
|
| 1856 |
)
|
| 1857 |
|
| 1858 |
rows: list[list[Any]] = []
|
| 1859 |
-
|
|
|
|
| 1860 |
gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
|
| 1861 |
rows.append([
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
gap_x,
|
| 1865 |
gap_y,
|
| 1866 |
])
|
|
@@ -2085,6 +2307,21 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2085 |
return normalized, _shape_settings_rows(normalized)
|
| 2086 |
|
| 2087 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2088 |
def show_selected_model(
|
| 2089 |
records: list[dict] | None,
|
| 2090 |
selected: str | None,
|
|
@@ -2148,7 +2385,19 @@ def generate_dynamic_stacks(
|
|
| 2148 |
) -> tuple:
|
| 2149 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2150 |
if not records:
|
| 2151 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2152 |
total = len(records)
|
| 2153 |
messages: list[str] = []
|
| 2154 |
for pos, record in enumerate(records):
|
|
@@ -2182,6 +2431,11 @@ def generate_dynamic_stacks(
|
|
| 2182 |
messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
|
| 2183 |
except Exception as exc:
|
| 2184 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2185 |
first_state = records[0].get("tiff_state") or _empty_state()
|
| 2186 |
label, preview = _render_selected_slice(first_state, 0)
|
| 2187 |
slider = gr.update(
|
|
@@ -2199,6 +2453,10 @@ def generate_dynamic_stacks(
|
|
| 2199 |
slider,
|
| 2200 |
label,
|
| 2201 |
preview,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2202 |
)
|
| 2203 |
|
| 2204 |
|
|
@@ -2207,6 +2465,187 @@ def generate_dynamic_reference_stack(records: list[dict] | None, progress: gr.Pr
|
|
| 2207 |
return generate_reference_stack(*states, progress=progress)
|
| 2208 |
|
| 2209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2210 |
def generate_dynamic_gcode(
|
| 2211 |
records: list[dict] | None,
|
| 2212 |
settings_table: Any,
|
|
@@ -2219,7 +2658,11 @@ def generate_dynamic_gcode(
|
|
| 2219 |
) -> tuple:
|
| 2220 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2221 |
motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
|
|
|
|
| 2222 |
messages: list[str] = []
|
|
|
|
|
|
|
|
|
|
| 2223 |
for record in records:
|
| 2224 |
zip_path = record.get("zip_path")
|
| 2225 |
if not zip_path:
|
|
@@ -2241,6 +2684,8 @@ def generate_dynamic_gcode(
|
|
| 2241 |
all_g1=bool(all_g1),
|
| 2242 |
motion_tiffs=motion_tiffs,
|
| 2243 |
raster_pattern=raster_pattern,
|
|
|
|
|
|
|
| 2244 |
)
|
| 2245 |
record["gcode_path"] = str(gcode_path)
|
| 2246 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
@@ -2285,8 +2730,9 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
|
|
| 2285 |
if not parsed.get("point_count"):
|
| 2286 |
messages.append(f"Shape {idx}: no G0/G1 moves found.")
|
| 2287 |
continue
|
| 2288 |
-
|
| 2289 |
-
|
|
|
|
| 2290 |
return parts, messages
|
| 2291 |
|
| 2292 |
|
|
@@ -2480,6 +2926,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2480 |
shape_records = gr.State([])
|
| 2481 |
last_shape_delete_at = gr.State(0.0)
|
| 2482 |
ref_state = gr.State(_empty_state())
|
|
|
|
| 2483 |
|
| 2484 |
with gr.Tab("STL to TIFF Slicer"):
|
| 2485 |
gr.Markdown(
|
|
@@ -2512,6 +2959,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2512 |
value=[],
|
| 2513 |
row_count=(0, "dynamic"),
|
| 2514 |
column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
|
|
|
|
| 2515 |
interactive=True,
|
| 2516 |
label="Shape Settings",
|
| 2517 |
elem_id="shape-settings-table",
|
|
@@ -2562,15 +3010,44 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2562 |
ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 2563 |
ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 2564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2565 |
with gr.Tab("TIFF Slices to GCode"):
|
| 2566 |
gr.Markdown(
|
| 2567 |
"""
|
| 2568 |
# TIFF Slices to GCode
|
| 2569 |
-
Generate G-code for every shape with a TIFF stack. Pressure, valve, port, and color come from the Shape Settings table.
|
| 2570 |
"""
|
| 2571 |
)
|
| 2572 |
gcode_use_ref_motion = gr.Checkbox(
|
| 2573 |
-
label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry).
|
| 2574 |
value=True,
|
| 2575 |
)
|
| 2576 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
|
@@ -2593,7 +3070,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2593 |
nozzle_use_individual_spacing = gr.Checkbox(label="Advanced Settings", value=False)
|
| 2594 |
nozzle_spacing_table = gr.Dataframe(
|
| 2595 |
headers=NOZZLE_SPACING_HEADERS,
|
| 2596 |
-
value=[["Same spacing", "All neighboring
|
| 2597 |
row_count=(1, "fixed"),
|
| 2598 |
column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
|
| 2599 |
interactive=True,
|
|
@@ -2684,20 +3161,40 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2684 |
parallel_mode = gr.State("tube")
|
| 2685 |
|
| 2686 |
shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
|
| 2687 |
-
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs)
|
| 2688 |
-
|
| 2689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2690 |
shape_settings.select(
|
| 2691 |
fn=delete_shape_from_settings,
|
| 2692 |
inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at],
|
| 2693 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2694 |
)
|
| 2695 |
|
| 2696 |
preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
|
| 2697 |
shape_settings.change(
|
| 2698 |
-
fn=
|
| 2699 |
-
inputs=[shape_records, shape_settings, scale_mode],
|
| 2700 |
-
outputs=[shape_records, shape_settings],
|
| 2701 |
queue=False,
|
| 2702 |
)
|
| 2703 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
|
|
@@ -2730,13 +3227,67 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2730 |
generate_button.click(
|
| 2731 |
fn=generate_dynamic_stacks,
|
| 2732 |
inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode],
|
| 2733 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2734 |
)
|
| 2735 |
ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
|
| 2736 |
ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
|
| 2737 |
ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 2738 |
ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 2739 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2740 |
gcode_button.click(
|
| 2741 |
fn=generate_dynamic_gcode,
|
| 2742 |
inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
|
|
|
|
| 4 |
import math
|
| 5 |
import time
|
| 6 |
import warnings
|
| 7 |
+
import zipfile
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any
|
| 10 |
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
#shape-settings-table table tbody tr td:last-child,
|
| 138 |
+
#shape-settings-table [role="gridcell"]:nth-child(12n) {
|
| 139 |
color: #ffffff !important;
|
| 140 |
cursor: pointer;
|
| 141 |
font-size: 0 !important;
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
#shape-settings-table table tbody tr td:last-child::after,
|
| 147 |
+
#shape-settings-table [role="gridcell"]:nth-child(12n)::after {
|
| 148 |
content: "X";
|
| 149 |
display: inline-flex;
|
| 150 |
align-items: center;
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
#shape-settings-table table tbody tr td:last-child input,
|
| 163 |
+
#shape-settings-table [role="gridcell"]:nth-child(12n) input,
|
| 164 |
#shape-settings-table table tbody tr td:last-child textarea,
|
| 165 |
+
#shape-settings-table [role="gridcell"]:nth-child(12n) textarea {
|
| 166 |
display: none !important;
|
| 167 |
}
|
| 168 |
|
|
|
|
| 296 |
});
|
| 297 |
}
|
| 298 |
function suppressDeleteCellEditor(event) {
|
| 299 |
+
var cell = event.target && event.target.closest ? event.target.closest('#shape-settings-table td:last-child, #shape-settings-table [role="gridcell"]:nth-child(12n)') : null;
|
| 300 |
if (!cell) return;
|
| 301 |
setTimeout(function () {
|
| 302 |
if (document.activeElement && cell.contains(document.activeElement)) {
|
|
|
|
| 1378 |
if not parts:
|
| 1379 |
return offsets, spacings
|
| 1380 |
|
| 1381 |
+
grouped: dict[int, list[dict]] = {}
|
| 1382 |
+
for part in parts:
|
| 1383 |
+
grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
|
| 1384 |
+
ordered_nozzles = sorted(grouped)
|
| 1385 |
+
offsets[ordered_nozzles[0]] = (0.0, 0.0)
|
| 1386 |
+
|
| 1387 |
+
def nozzle_bounds(nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
| 1388 |
+
mins: list[tuple[float, float, float]] = []
|
| 1389 |
+
maxs: list[tuple[float, float, float]] = []
|
| 1390 |
+
for part in grouped[nozzle]:
|
| 1391 |
+
part_min, part_max = part["parsed"]["bounds"]
|
| 1392 |
+
mins.append(part_min)
|
| 1393 |
+
maxs.append(part_max)
|
| 1394 |
+
return (
|
| 1395 |
+
tuple(min(values) for values in zip(*mins)),
|
| 1396 |
+
tuple(max(values) for values in zip(*maxs)),
|
| 1397 |
+
)
|
| 1398 |
|
| 1399 |
first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
|
| 1400 |
raw_pairs: list[tuple[float, float]] = [
|
|
|
|
| 1406 |
float(extra_pair_gaps[index] or 0.0),
|
| 1407 |
float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
|
| 1408 |
))
|
| 1409 |
+
max_pair_count = max(0, len(ordered_nozzles) - 1)
|
| 1410 |
while len(raw_pairs) < max_pair_count:
|
| 1411 |
raw_pairs.append(first_spacing)
|
| 1412 |
if same_spacing:
|
| 1413 |
raw_pairs = [first_spacing for _ in raw_pairs]
|
| 1414 |
|
| 1415 |
pair_spacing = {
|
| 1416 |
+
(ordered_nozzles[index], ordered_nozzles[index + 1]): raw_pairs[index]
|
| 1417 |
+
for index in range(min(len(raw_pairs), max_pair_count))
|
| 1418 |
}
|
| 1419 |
|
| 1420 |
def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
|
| 1421 |
if (prev_idx, cur_idx) in pair_spacing:
|
| 1422 |
return pair_spacing[(prev_idx, cur_idx)]
|
| 1423 |
+
try:
|
| 1424 |
+
start_pos = ordered_nozzles.index(prev_idx)
|
| 1425 |
+
end_pos = ordered_nozzles.index(cur_idx)
|
| 1426 |
+
except ValueError:
|
| 1427 |
+
return 0.0, 0.0
|
| 1428 |
+
if end_pos <= start_pos:
|
| 1429 |
return 0.0, 0.0
|
| 1430 |
total_x = 0.0
|
| 1431 |
total_y = 0.0
|
| 1432 |
+
for pair_pos in range(start_pos, end_pos):
|
| 1433 |
+
pair = (ordered_nozzles[pair_pos], ordered_nozzles[pair_pos + 1])
|
| 1434 |
+
step_x, step_y = pair_spacing.get(pair, first_spacing)
|
| 1435 |
total_x += step_x
|
| 1436 |
total_y += step_y
|
| 1437 |
return total_x, total_y
|
| 1438 |
|
| 1439 |
+
for prev_idx, cur_idx in zip(ordered_nozzles, ordered_nozzles[1:]):
|
|
|
|
|
|
|
| 1440 |
gap_x, y_step = spacing_between(prev_idx, cur_idx)
|
| 1441 |
prev_offset_x, prev_offset_y = offsets[prev_idx]
|
| 1442 |
+
(_, _, _), (prev_xmax, _prev_ymax, _) = nozzle_bounds(prev_idx)
|
| 1443 |
+
(cur_xmin, _cur_ymin, _), (_, _, _) = nozzle_bounds(cur_idx)
|
| 1444 |
dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
|
| 1445 |
dy = prev_offset_y + y_step
|
| 1446 |
offsets[cur_idx] = (dx, dy)
|
|
|
|
| 1462 |
lines = ["**Shape dimensions from generated G-code:**"]
|
| 1463 |
for part in sorted(parts, key=lambda item: item["idx"]):
|
| 1464 |
(xmin, ymin, zmin), (xmax, ymax, zmax) = part["parsed"]["bounds"]
|
| 1465 |
+
nozzle = _record_nozzle_number(part, int(part.get("idx", 1) or 1))
|
| 1466 |
lines.append(
|
| 1467 |
+
f"Shape {part['idx']} (Nozzle {nozzle}): X {xmax - xmin:.2f} mm, "
|
| 1468 |
f"Y {ymax - ymin:.2f} mm, Z {zmax - zmin:.2f} mm."
|
| 1469 |
)
|
| 1470 |
return lines
|
|
|
|
| 1484 |
lines.append(f"Nozzle {idx}: X {x:.2f} mm, Y {y:.2f} mm.")
|
| 1485 |
|
| 1486 |
if not spacings:
|
| 1487 |
+
lines.append("Generate G-code for at least two nozzles to calculate nozzle spacing.")
|
| 1488 |
return " \n".join(lines)
|
| 1489 |
|
| 1490 |
lines.append("**Nozzle-to-nozzle distances:**")
|
|
|
|
| 1635 |
return ref_state, slider, label, preview
|
| 1636 |
|
| 1637 |
|
| 1638 |
+
def _zip_tiff_paths(tiff_paths: list[Path], zip_path: Path) -> None:
|
| 1639 |
+
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
| 1640 |
+
for tiff_path in tiff_paths:
|
| 1641 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 1642 |
+
|
| 1643 |
+
|
| 1644 |
+
def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
|
| 1645 |
+
base = length // count
|
| 1646 |
+
remainder = length % count
|
| 1647 |
+
spans: list[tuple[int, int]] = []
|
| 1648 |
+
start = 0
|
| 1649 |
+
for index in range(count):
|
| 1650 |
+
size = base + (1 if index < remainder else 0)
|
| 1651 |
+
end = start + size
|
| 1652 |
+
spans.append((start, end))
|
| 1653 |
+
start = end
|
| 1654 |
+
return spans
|
| 1655 |
+
|
| 1656 |
+
|
| 1657 |
+
def split_tiff_stack_grid(
|
| 1658 |
+
state: ViewerState,
|
| 1659 |
+
base_name: str = "split_shape",
|
| 1660 |
+
columns: float = 2,
|
| 1661 |
+
rows: float = 1,
|
| 1662 |
+
progress: gr.Progress = gr.Progress(),
|
| 1663 |
+
) -> list[dict[str, Any]]:
|
| 1664 |
+
tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
|
| 1665 |
+
if not tiff_paths:
|
| 1666 |
+
raise ValueError("Generate a TIFF stack for the selected shape before splitting it.")
|
| 1667 |
+
|
| 1668 |
+
with Image.open(tiff_paths[0]) as first_image:
|
| 1669 |
+
width, height = first_image.size
|
| 1670 |
+
column_count = max(1, _coerce_int(columns, 2))
|
| 1671 |
+
row_count = max(1, _coerce_int(rows, 1))
|
| 1672 |
+
if column_count > width:
|
| 1673 |
+
raise ValueError(f"Cannot split {width}-pixel-wide TIFF slices into {column_count} columns.")
|
| 1674 |
+
if row_count > height:
|
| 1675 |
+
raise ValueError(f"Cannot split {height}-pixel-tall TIFF slices into {row_count} rows.")
|
| 1676 |
+
|
| 1677 |
+
safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
|
| 1678 |
+
output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
|
| 1679 |
+
x_spans = _partition_length(width, column_count)
|
| 1680 |
+
y_spans = _partition_length(height, row_count)
|
| 1681 |
+
pieces: list[dict[str, Any]] = []
|
| 1682 |
+
for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
|
| 1683 |
+
for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
|
| 1684 |
+
piece_dir = output_dir / f"r{row_index:02d}_c{col_index:02d}_tiff_slices"
|
| 1685 |
+
piece_dir.mkdir(parents=True, exist_ok=True)
|
| 1686 |
+
pieces.append({
|
| 1687 |
+
"row": row_index,
|
| 1688 |
+
"col": col_index,
|
| 1689 |
+
"x_start": x_start,
|
| 1690 |
+
"x_end": x_end,
|
| 1691 |
+
"y_start": y_start,
|
| 1692 |
+
"y_end": y_end,
|
| 1693 |
+
"tiff_dir": piece_dir,
|
| 1694 |
+
"tiff_paths": [],
|
| 1695 |
+
})
|
| 1696 |
+
|
| 1697 |
+
for index, source_path in enumerate(tiff_paths):
|
| 1698 |
+
progress(index / max(len(tiff_paths), 1), desc=f"Splitting layer {index + 1}/{len(tiff_paths)}")
|
| 1699 |
+
with Image.open(source_path) as image:
|
| 1700 |
+
layer = image.convert("L")
|
| 1701 |
+
if layer.size != (width, height):
|
| 1702 |
+
raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
|
| 1703 |
+
|
| 1704 |
+
for piece in pieces:
|
| 1705 |
+
piece_image = layer.crop((piece["x_start"], piece["y_start"], piece["x_end"], piece["y_end"]))
|
| 1706 |
+
piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
|
| 1707 |
+
piece_image.save(piece_path, compression="tiff_deflate")
|
| 1708 |
+
piece["tiff_paths"].append(piece_path)
|
| 1709 |
+
|
| 1710 |
+
pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
|
| 1711 |
+
z_values = list(state.get("z_values", []))
|
| 1712 |
+
if len(z_values) < len(tiff_paths):
|
| 1713 |
+
z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
|
| 1714 |
+
|
| 1715 |
+
base_x_min = float(state.get("x_min", 0.0) or 0.0)
|
| 1716 |
+
base_y_min = float(state.get("y_min", 0.0) or 0.0)
|
| 1717 |
+
for piece in pieces:
|
| 1718 |
+
piece_width = piece["x_end"] - piece["x_start"]
|
| 1719 |
+
piece_height = piece["y_end"] - piece["y_start"]
|
| 1720 |
+
zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
|
| 1721 |
+
_zip_tiff_paths(piece["tiff_paths"], zip_path)
|
| 1722 |
+
piece_state: ViewerState = {
|
| 1723 |
+
"tiff_paths": [str(path) for path in piece["tiff_paths"]],
|
| 1724 |
+
"z_values": z_values[: len(piece["tiff_paths"])],
|
| 1725 |
+
"pixel_size": pixel_size,
|
| 1726 |
+
"x_min": base_x_min + (piece["x_start"] * pixel_size),
|
| 1727 |
+
"y_min": base_y_min + ((height - piece["y_end"]) * pixel_size),
|
| 1728 |
+
"image_width": piece_width,
|
| 1729 |
+
"image_height": piece_height,
|
| 1730 |
+
"zip_path": str(zip_path),
|
| 1731 |
+
}
|
| 1732 |
+
piece["state"] = piece_state
|
| 1733 |
+
piece["zip_path"] = zip_path
|
| 1734 |
+
return pieces
|
| 1735 |
+
|
| 1736 |
+
|
| 1737 |
+
def split_tiff_stack_left_right(
|
| 1738 |
+
state: ViewerState,
|
| 1739 |
+
base_name: str = "split_shape",
|
| 1740 |
+
progress: gr.Progress = gr.Progress(),
|
| 1741 |
+
) -> tuple[ViewerState, ViewerState, Path, Path]:
|
| 1742 |
+
pieces = split_tiff_stack_grid(state, base_name=base_name, columns=2, rows=1, progress=progress)
|
| 1743 |
+
return pieces[0]["state"], pieces[1]["state"], pieces[0]["zip_path"], pieces[1]["zip_path"]
|
| 1744 |
+
|
| 1745 |
+
|
| 1746 |
+
SHAPE_SETTINGS_HEADERS = [
|
| 1747 |
+
"Shape",
|
| 1748 |
+
"STL",
|
| 1749 |
+
"Target X (mm)",
|
| 1750 |
+
"Target Y (mm)",
|
| 1751 |
+
"Target Z (mm)",
|
| 1752 |
+
"Pressure (psi)",
|
| 1753 |
+
"Valve",
|
| 1754 |
+
"Nozzle",
|
| 1755 |
+
"Port",
|
| 1756 |
+
"Color",
|
| 1757 |
+
"Contour Tracing",
|
| 1758 |
+
"Delete",
|
| 1759 |
+
]
|
| 1760 |
+
SHAPE_SETTINGS_DATATYPES = [
|
| 1761 |
+
"number",
|
| 1762 |
+
"str",
|
| 1763 |
+
"number",
|
| 1764 |
+
"number",
|
| 1765 |
+
"number",
|
| 1766 |
+
"number",
|
| 1767 |
+
"number",
|
| 1768 |
+
"number",
|
| 1769 |
+
"number",
|
| 1770 |
+
"str",
|
| 1771 |
+
"bool",
|
| 1772 |
+
"str",
|
| 1773 |
+
]
|
| 1774 |
SIMPLE_NOZZLE_SPACING_HEADERS = [
|
| 1775 |
"Spacing Mode",
|
| 1776 |
"Applies To",
|
|
|
|
| 1778 |
"Y nozzle spacing (mm)",
|
| 1779 |
]
|
| 1780 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
| 1781 |
+
"From Nozzle",
|
| 1782 |
+
"To Nozzle",
|
| 1783 |
"X edge spacing (mm)",
|
| 1784 |
"Y nozzle spacing (mm)",
|
| 1785 |
]
|
|
|
|
| 1796 |
return list(table or [])
|
| 1797 |
|
| 1798 |
|
| 1799 |
+
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
| 1800 |
+
if isinstance(value, bool):
|
| 1801 |
+
return value
|
| 1802 |
+
if value is None:
|
| 1803 |
+
return default
|
| 1804 |
+
if isinstance(value, (int, float)):
|
| 1805 |
+
return bool(value)
|
| 1806 |
+
text = str(value).strip().lower()
|
| 1807 |
+
if not text:
|
| 1808 |
+
return default
|
| 1809 |
+
if text in {"1", "true", "yes", "y", "on", "checked"}:
|
| 1810 |
+
return True
|
| 1811 |
+
if text in {"0", "false", "no", "n", "off", "unchecked"}:
|
| 1812 |
+
return False
|
| 1813 |
+
return default
|
| 1814 |
+
|
| 1815 |
+
|
| 1816 |
+
def _coerce_int(value: Any, default: int) -> int:
|
| 1817 |
+
try:
|
| 1818 |
+
return int(float(value))
|
| 1819 |
+
except (TypeError, ValueError):
|
| 1820 |
+
try:
|
| 1821 |
+
return int(float(default))
|
| 1822 |
+
except (TypeError, ValueError):
|
| 1823 |
+
return 0
|
| 1824 |
+
|
| 1825 |
+
|
| 1826 |
+
def _record_nozzle_number(record: dict, fallback: int | None = None) -> int:
|
| 1827 |
+
default = fallback if fallback is not None else int(record.get("idx", 1) or 1)
|
| 1828 |
+
nozzle = _coerce_int(record.get("nozzle", default), default)
|
| 1829 |
+
return nozzle if nozzle > 0 else default
|
| 1830 |
+
|
| 1831 |
+
|
| 1832 |
def _file_path_value(file_value: Any) -> str | None:
|
| 1833 |
if not file_value:
|
| 1834 |
return None
|
|
|
|
| 1899 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1900 |
"pressure": previous.get("pressure", 25.0),
|
| 1901 |
"valve": previous.get("valve", 4),
|
| 1902 |
+
"nozzle": previous.get("nozzle", index),
|
| 1903 |
"port": previous.get("port", 1),
|
| 1904 |
"color": previous.get("color", _default_color(index)),
|
| 1905 |
+
"contour_tracing": previous.get("contour_tracing", False),
|
| 1906 |
"tiff_state": previous.get("tiff_state", _empty_state()),
|
| 1907 |
"zip_path": previous.get("zip_path"),
|
| 1908 |
"gcode_path": previous.get("gcode_path"),
|
|
|
|
| 1929 |
record.get("target_z", DEFAULT_TARGET_EXTENTS[2]),
|
| 1930 |
record.get("pressure", 25.0),
|
| 1931 |
record.get("valve", 4),
|
| 1932 |
+
_record_nozzle_number(record, int(record["idx"])),
|
| 1933 |
record.get("port", 1),
|
| 1934 |
record.get("color", _default_color(record["idx"])),
|
| 1935 |
+
bool(record.get("contour_tracing", False)),
|
| 1936 |
"Delete",
|
| 1937 |
]
|
| 1938 |
for record in records
|
|
|
|
| 1966 |
("target_z", 4, DEFAULT_TARGET_EXTENTS[2]),
|
| 1967 |
("pressure", 5, 25.0),
|
| 1968 |
("valve", 6, 4),
|
|
|
|
| 1969 |
):
|
| 1970 |
try:
|
| 1971 |
copy[key] = float(row[pos])
|
| 1972 |
except (IndexError, TypeError, ValueError):
|
| 1973 |
copy[key] = copy.get(key, default)
|
| 1974 |
+
has_nozzle_column = len(row) >= len(SHAPE_SETTINGS_HEADERS)
|
| 1975 |
+
nozzle_pos = 7 if has_nozzle_column else None
|
| 1976 |
+
port_pos = 8 if has_nozzle_column else 7
|
| 1977 |
+
color_pos = 9 if has_nozzle_column else 8
|
| 1978 |
+
contour_pos = 10 if has_nozzle_column else 9
|
| 1979 |
+
copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
|
| 1980 |
+
copy["nozzle"] = _coerce_int(
|
| 1981 |
+
row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
|
| 1982 |
+
_record_nozzle_number(copy),
|
| 1983 |
+
)
|
| 1984 |
+
copy["port"] = _coerce_int(
|
| 1985 |
+
row[port_pos] if len(row) > port_pos else copy.get("port", 1),
|
| 1986 |
+
_coerce_int(copy.get("port", 1), 1),
|
| 1987 |
+
)
|
| 1988 |
+
if copy["nozzle"] <= 0:
|
| 1989 |
+
copy["nozzle"] = _record_nozzle_number(copy)
|
| 1990 |
+
if len(row) > color_pos and row[color_pos]:
|
| 1991 |
+
copy["color"] = str(row[color_pos])
|
| 1992 |
try:
|
| 1993 |
+
copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
|
| 1994 |
+
except IndexError:
|
| 1995 |
+
copy["contour_tracing"] = bool(copy.get("contour_tracing", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1996 |
updated.append(copy)
|
| 1997 |
return updated
|
| 1998 |
|
|
|
|
| 2029 |
return edited_axes
|
| 2030 |
|
| 2031 |
|
| 2032 |
+
def _nozzle_spacing_label(nozzle: int, records: list[dict]) -> str:
|
| 2033 |
+
shapes = [
|
| 2034 |
+
f"Shape {record.get('idx', '?')}"
|
| 2035 |
+
for record in records
|
| 2036 |
+
if _record_nozzle_number(record, int(record.get("idx", 1) or 1)) == nozzle
|
| 2037 |
+
]
|
| 2038 |
+
if shapes:
|
| 2039 |
+
return f"Nozzle {nozzle}: {', '.join(shapes)}"
|
| 2040 |
+
return f"Nozzle {nozzle}"
|
| 2041 |
+
|
| 2042 |
+
|
| 2043 |
+
def _ordered_nozzle_numbers(records: list[dict]) -> list[int]:
|
| 2044 |
+
nozzles = {
|
| 2045 |
+
_record_nozzle_number(record, int(record.get("idx", position) or position))
|
| 2046 |
+
for position, record in enumerate(records, start=1)
|
| 2047 |
+
}
|
| 2048 |
+
return sorted(nozzles)
|
| 2049 |
|
| 2050 |
|
| 2051 |
def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
|
|
|
|
| 2070 |
if not use_individual_spacing:
|
| 2071 |
return gr.update(
|
| 2072 |
headers=SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 2073 |
+
value=[["Same spacing", "All neighboring nozzles", first_pair[0], first_pair[1]]],
|
| 2074 |
row_count=(1, "fixed"),
|
| 2075 |
column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 2076 |
label="Nozzle Spacing",
|
| 2077 |
)
|
| 2078 |
|
| 2079 |
rows: list[list[Any]] = []
|
| 2080 |
+
ordered_nozzles = _ordered_nozzle_numbers(records)
|
| 2081 |
+
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
|
| 2082 |
gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
|
| 2083 |
rows.append([
|
| 2084 |
+
_nozzle_spacing_label(first, records),
|
| 2085 |
+
_nozzle_spacing_label(second, records),
|
| 2086 |
gap_x,
|
| 2087 |
gap_y,
|
| 2088 |
])
|
|
|
|
| 2307 |
return normalized, _shape_settings_rows(normalized)
|
| 2308 |
|
| 2309 |
|
| 2310 |
+
def normalize_shape_settings_and_spacing(
|
| 2311 |
+
records: list[dict] | None,
|
| 2312 |
+
settings_table: Any | None,
|
| 2313 |
+
scale_mode: str | None,
|
| 2314 |
+
existing_spacing: Any | None,
|
| 2315 |
+
use_individual_spacing: bool | None,
|
| 2316 |
+
) -> tuple:
|
| 2317 |
+
updated_records, updated_settings = normalize_shape_dimensions_for_mode(records, settings_table, scale_mode)
|
| 2318 |
+
return (
|
| 2319 |
+
updated_records,
|
| 2320 |
+
updated_settings,
|
| 2321 |
+
_spacing_table_update(updated_records, existing_spacing, use_individual_spacing),
|
| 2322 |
+
)
|
| 2323 |
+
|
| 2324 |
+
|
| 2325 |
def show_selected_model(
|
| 2326 |
records: list[dict] | None,
|
| 2327 |
selected: str | None,
|
|
|
|
| 2385 |
) -> tuple:
|
| 2386 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2387 |
if not records:
|
| 2388 |
+
return (
|
| 2389 |
+
records,
|
| 2390 |
+
[],
|
| 2391 |
+
"Upload at least one STL first.",
|
| 2392 |
+
_dropdown_update(records),
|
| 2393 |
+
_reset_slider(),
|
| 2394 |
+
"No slice stack loaded yet.",
|
| 2395 |
+
None,
|
| 2396 |
+
_empty_state(),
|
| 2397 |
+
_reset_slider(),
|
| 2398 |
+
"No reference stack generated yet.",
|
| 2399 |
+
None,
|
| 2400 |
+
)
|
| 2401 |
total = len(records)
|
| 2402 |
messages: list[str] = []
|
| 2403 |
for pos, record in enumerate(records):
|
|
|
|
| 2431 |
messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
|
| 2432 |
except Exception as exc:
|
| 2433 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 2434 |
+
ref_state, ref_slider, ref_label, ref_preview = generate_dynamic_reference_stack(records, progress=progress)
|
| 2435 |
+
if (ref_state or {}).get("tiff_paths"):
|
| 2436 |
+
messages.append("Reference TIFF Stack: updated automatically.")
|
| 2437 |
+
else:
|
| 2438 |
+
messages.append("Reference TIFF Stack: skipped (no generated shape slices available).")
|
| 2439 |
first_state = records[0].get("tiff_state") or _empty_state()
|
| 2440 |
label, preview = _render_selected_slice(first_state, 0)
|
| 2441 |
slider = gr.update(
|
|
|
|
| 2453 |
slider,
|
| 2454 |
label,
|
| 2455 |
preview,
|
| 2456 |
+
ref_state,
|
| 2457 |
+
ref_slider,
|
| 2458 |
+
ref_label,
|
| 2459 |
+
ref_preview,
|
| 2460 |
)
|
| 2461 |
|
| 2462 |
|
|
|
|
| 2465 |
return generate_reference_stack(*states, progress=progress)
|
| 2466 |
|
| 2467 |
|
| 2468 |
+
def _split_piece_choice(piece: dict[str, Any]) -> str:
|
| 2469 |
+
return f"R{piece['row']} C{piece['col']}"
|
| 2470 |
+
|
| 2471 |
+
|
| 2472 |
+
def _split_piece_dropdown_update(pieces: list[dict[str, Any]], selected: str | None = None) -> dict[str, Any]:
|
| 2473 |
+
choices = [_split_piece_choice(piece) for piece in pieces]
|
| 2474 |
+
value = selected if selected in choices else (choices[0] if choices else None)
|
| 2475 |
+
return gr.update(choices=choices, value=value)
|
| 2476 |
+
|
| 2477 |
+
|
| 2478 |
+
def _selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> dict[str, Any] | None:
|
| 2479 |
+
if not pieces:
|
| 2480 |
+
return None
|
| 2481 |
+
for piece in pieces:
|
| 2482 |
+
if _split_piece_choice(piece) == selected:
|
| 2483 |
+
return piece
|
| 2484 |
+
return pieces[0]
|
| 2485 |
+
|
| 2486 |
+
|
| 2487 |
+
def preview_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> tuple:
|
| 2488 |
+
piece = _selected_split_piece(pieces, selected)
|
| 2489 |
+
if not piece:
|
| 2490 |
+
return _reset_slider(), "No split stack generated yet.", None
|
| 2491 |
+
state = piece.get("state") or _empty_state()
|
| 2492 |
+
label, preview = _render_selected_slice(state, 0)
|
| 2493 |
+
slider = gr.update(
|
| 2494 |
+
minimum=0,
|
| 2495 |
+
maximum=max(0, len(state.get("tiff_paths", [])) - 1),
|
| 2496 |
+
value=0,
|
| 2497 |
+
step=1,
|
| 2498 |
+
interactive=len(state.get("tiff_paths", [])) > 1,
|
| 2499 |
+
)
|
| 2500 |
+
return slider, label, preview
|
| 2501 |
+
|
| 2502 |
+
|
| 2503 |
+
def jump_to_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float) -> tuple:
|
| 2504 |
+
piece = _selected_split_piece(pieces, selected)
|
| 2505 |
+
if not piece:
|
| 2506 |
+
return "No split stack generated yet.", None
|
| 2507 |
+
return _render_selected_slice(piece.get("state") or _empty_state(), int(index))
|
| 2508 |
+
|
| 2509 |
+
|
| 2510 |
+
def shift_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float, delta: int) -> tuple:
|
| 2511 |
+
piece = _selected_split_piece(pieces, selected)
|
| 2512 |
+
if not piece:
|
| 2513 |
+
return gr.update(value=0), "No split stack generated yet.", None
|
| 2514 |
+
state = piece.get("state") or _empty_state()
|
| 2515 |
+
new_index, label, preview = shift_slice(state, index, delta)
|
| 2516 |
+
return gr.update(value=new_index), label, preview
|
| 2517 |
+
|
| 2518 |
+
|
| 2519 |
+
def split_selected_shape_for_grid(
|
| 2520 |
+
records: list[dict] | None,
|
| 2521 |
+
selected: str | None,
|
| 2522 |
+
settings_table: Any | None,
|
| 2523 |
+
existing_spacing: Any | None,
|
| 2524 |
+
use_individual_spacing: bool | None,
|
| 2525 |
+
columns: float,
|
| 2526 |
+
rows: float,
|
| 2527 |
+
starting_nozzle: float,
|
| 2528 |
+
starting_valve: float,
|
| 2529 |
+
progress: gr.Progress = gr.Progress(),
|
| 2530 |
+
) -> tuple:
|
| 2531 |
+
records = _apply_shape_settings(records or [], settings_table)
|
| 2532 |
+
if not records:
|
| 2533 |
+
empty = _empty_state()
|
| 2534 |
+
return (
|
| 2535 |
+
records,
|
| 2536 |
+
_shape_settings_rows(records),
|
| 2537 |
+
_spacing_table_update(records, existing_spacing, use_individual_spacing),
|
| 2538 |
+
[],
|
| 2539 |
+
[],
|
| 2540 |
+
_gcode_dropdown_update(records),
|
| 2541 |
+
_gcode_dropdown_update(records, include_upload=True),
|
| 2542 |
+
_dropdown_update(records),
|
| 2543 |
+
[],
|
| 2544 |
+
[],
|
| 2545 |
+
_split_piece_dropdown_update([]),
|
| 2546 |
+
_reset_slider(),
|
| 2547 |
+
"No split stack generated yet.",
|
| 2548 |
+
None,
|
| 2549 |
+
"Generate TIFF stacks for a shape before splitting it.",
|
| 2550 |
+
)
|
| 2551 |
+
|
| 2552 |
+
pos = _selected_record_index(records, selected)
|
| 2553 |
+
if pos < 0:
|
| 2554 |
+
pos = 0
|
| 2555 |
+
source = records[pos]
|
| 2556 |
+
state = source.get("tiff_state") or _empty_state()
|
| 2557 |
+
try:
|
| 2558 |
+
pieces = split_tiff_stack_grid(
|
| 2559 |
+
state,
|
| 2560 |
+
base_name=str(source.get("name") or f"shape_{source.get('idx', pos + 1)}"),
|
| 2561 |
+
columns=columns,
|
| 2562 |
+
rows=rows,
|
| 2563 |
+
progress=progress,
|
| 2564 |
+
)
|
| 2565 |
+
except Exception as exc:
|
| 2566 |
+
empty = _empty_state()
|
| 2567 |
+
return (
|
| 2568 |
+
records,
|
| 2569 |
+
_shape_settings_rows(records),
|
| 2570 |
+
_spacing_table_update(records, existing_spacing, use_individual_spacing),
|
| 2571 |
+
[record.get("zip_path") for record in records if record.get("zip_path")],
|
| 2572 |
+
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 2573 |
+
_gcode_dropdown_update(records),
|
| 2574 |
+
_gcode_dropdown_update(records, include_upload=True),
|
| 2575 |
+
_dropdown_update(records, selected),
|
| 2576 |
+
[],
|
| 2577 |
+
[],
|
| 2578 |
+
_split_piece_dropdown_update([]),
|
| 2579 |
+
_reset_slider(),
|
| 2580 |
+
"No split stack generated yet.",
|
| 2581 |
+
None,
|
| 2582 |
+
f"Split failed: {exc}",
|
| 2583 |
+
)
|
| 2584 |
+
|
| 2585 |
+
base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
|
| 2586 |
+
first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
|
| 2587 |
+
first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
|
| 2588 |
+
split_records: list[dict] = []
|
| 2589 |
+
for index, piece in enumerate(pieces):
|
| 2590 |
+
piece_state = piece["state"]
|
| 2591 |
+
piece_width_mm = float(piece_state.get("image_width", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
|
| 2592 |
+
piece_height_mm = float(piece_state.get("image_height", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
|
| 2593 |
+
piece_record = dict(source)
|
| 2594 |
+
piece_record.update({
|
| 2595 |
+
"name": f"{base_name} - R{piece['row']}C{piece['col']}",
|
| 2596 |
+
"stl_path": None,
|
| 2597 |
+
"target_x": piece_width_mm or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
|
| 2598 |
+
"target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
|
| 2599 |
+
"nozzle": first_nozzle + index,
|
| 2600 |
+
"valve": first_valve + index,
|
| 2601 |
+
"tiff_state": piece_state,
|
| 2602 |
+
"zip_path": str(piece["zip_path"]),
|
| 2603 |
+
"gcode_path": None,
|
| 2604 |
+
})
|
| 2605 |
+
split_records.append(piece_record)
|
| 2606 |
+
next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
|
| 2607 |
+
slider, label, preview = preview_selected_split_piece(pieces, None)
|
| 2608 |
+
status = (
|
| 2609 |
+
f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready stacks "
|
| 2610 |
+
f"({max(1, _coerce_int(columns, 2))} columns x {max(1, _coerce_int(rows, 1))} rows). \n"
|
| 2611 |
+
f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
|
| 2612 |
+
)
|
| 2613 |
+
return (
|
| 2614 |
+
next_records,
|
| 2615 |
+
_shape_settings_rows(next_records),
|
| 2616 |
+
_spacing_table_update(next_records, existing_spacing, use_individual_spacing),
|
| 2617 |
+
[record.get("zip_path") for record in next_records if record.get("zip_path")],
|
| 2618 |
+
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2619 |
+
_gcode_dropdown_update(next_records),
|
| 2620 |
+
_gcode_dropdown_update(next_records, include_upload=True),
|
| 2621 |
+
_dropdown_update(next_records),
|
| 2622 |
+
[str(piece["zip_path"]) for piece in pieces],
|
| 2623 |
+
pieces,
|
| 2624 |
+
_split_piece_dropdown_update(pieces),
|
| 2625 |
+
slider,
|
| 2626 |
+
label,
|
| 2627 |
+
preview,
|
| 2628 |
+
status,
|
| 2629 |
+
)
|
| 2630 |
+
|
| 2631 |
+
|
| 2632 |
+
def _contour_tracing_sources(records: list[dict]) -> list[dict]:
|
| 2633 |
+
sources: list[dict] = []
|
| 2634 |
+
for record in records:
|
| 2635 |
+
if not record.get("contour_tracing"):
|
| 2636 |
+
continue
|
| 2637 |
+
state = record.get("tiff_state") or {}
|
| 2638 |
+
tiff_paths = state.get("tiff_paths") or []
|
| 2639 |
+
source = {
|
| 2640 |
+
"owner_idx": int(record.get("idx", len(sources) + 1)),
|
| 2641 |
+
"tiff_paths": list(tiff_paths),
|
| 2642 |
+
"zip_path": record.get("zip_path"),
|
| 2643 |
+
}
|
| 2644 |
+
if source["tiff_paths"] or source["zip_path"]:
|
| 2645 |
+
sources.append(source)
|
| 2646 |
+
return sources
|
| 2647 |
+
|
| 2648 |
+
|
| 2649 |
def generate_dynamic_gcode(
|
| 2650 |
records: list[dict] | None,
|
| 2651 |
settings_table: Any,
|
|
|
|
| 2658 |
) -> tuple:
|
| 2659 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2660 |
motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
|
| 2661 |
+
contour_sources = _contour_tracing_sources(records)
|
| 2662 |
messages: list[str] = []
|
| 2663 |
+
if contour_sources:
|
| 2664 |
+
enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
|
| 2665 |
+
messages.append(f"Contour tracing enabled for {enabled}.")
|
| 2666 |
for record in records:
|
| 2667 |
zip_path = record.get("zip_path")
|
| 2668 |
if not zip_path:
|
|
|
|
| 2684 |
all_g1=bool(all_g1),
|
| 2685 |
motion_tiffs=motion_tiffs,
|
| 2686 |
raster_pattern=raster_pattern,
|
| 2687 |
+
contour_tiff_sets=contour_sources,
|
| 2688 |
+
active_contour_owner=int(record.get("idx", 0)),
|
| 2689 |
)
|
| 2690 |
record["gcode_path"] = str(gcode_path)
|
| 2691 |
messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
|
|
|
|
| 2730 |
if not parsed.get("point_count"):
|
| 2731 |
messages.append(f"Shape {idx}: no G0/G1 moves found.")
|
| 2732 |
continue
|
| 2733 |
+
nozzle = _record_nozzle_number(record, idx)
|
| 2734 |
+
parts.append({"idx": idx, "nozzle": nozzle, "color": record.get("color", _default_color(idx)), "parsed": parsed})
|
| 2735 |
+
messages.append(f"Shape {idx} (Nozzle {nozzle}): {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
|
| 2736 |
return parts, messages
|
| 2737 |
|
| 2738 |
|
|
|
|
| 2926 |
shape_records = gr.State([])
|
| 2927 |
last_shape_delete_at = gr.State(0.0)
|
| 2928 |
ref_state = gr.State(_empty_state())
|
| 2929 |
+
split_piece_states = gr.State([])
|
| 2930 |
|
| 2931 |
with gr.Tab("STL to TIFF Slicer"):
|
| 2932 |
gr.Markdown(
|
|
|
|
| 2959 |
value=[],
|
| 2960 |
row_count=(0, "dynamic"),
|
| 2961 |
column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
|
| 2962 |
+
datatype=SHAPE_SETTINGS_DATATYPES,
|
| 2963 |
interactive=True,
|
| 2964 |
label="Shape Settings",
|
| 2965 |
elem_id="shape-settings-table",
|
|
|
|
| 3010 |
ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 3011 |
ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 3012 |
|
| 3013 |
+
with gr.Tab("Single Shape Multi-Nozzle"):
|
| 3014 |
+
gr.Markdown(
|
| 3015 |
+
"""
|
| 3016 |
+
# Single Shape Multi-Nozzle
|
| 3017 |
+
Split one generated TIFF stack into a row/column grid, then print each piece with a different nozzle.
|
| 3018 |
+
"""
|
| 3019 |
+
)
|
| 3020 |
+
with gr.Row():
|
| 3021 |
+
split_source = gr.Dropdown(label="Source Shape", choices=[], value=None, allow_custom_value=False)
|
| 3022 |
+
split_refresh_sources = gr.Button("Refresh Source Shapes", variant="secondary", size="sm")
|
| 3023 |
+
with gr.Row():
|
| 3024 |
+
split_columns = gr.Number(label="Columns (X)", value=2, minimum=1, step=1)
|
| 3025 |
+
split_rows = gr.Number(label="Rows (Y)", value=1, minimum=1, step=1)
|
| 3026 |
+
split_start_nozzle = gr.Number(label="Starting Nozzle", value=1, minimum=1, step=1)
|
| 3027 |
+
split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
|
| 3028 |
+
split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
|
| 3029 |
+
split_status = gr.Markdown("Generate a TIFF stack, then split it for multi-nozzle printing.")
|
| 3030 |
+
split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
|
| 3031 |
+
with gr.Row():
|
| 3032 |
+
with gr.Column(scale=1, min_width=260):
|
| 3033 |
+
split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
|
| 3034 |
+
with gr.Row():
|
| 3035 |
+
split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
|
| 3036 |
+
split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
|
| 3037 |
+
split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
|
| 3038 |
+
split_piece_label = gr.Markdown("No split stack generated yet.")
|
| 3039 |
+
with gr.Column(scale=2, min_width=420):
|
| 3040 |
+
split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
|
| 3041 |
+
|
| 3042 |
with gr.Tab("TIFF Slices to GCode"):
|
| 3043 |
gr.Markdown(
|
| 3044 |
"""
|
| 3045 |
# TIFF Slices to GCode
|
| 3046 |
+
Generate G-code for every shape with a TIFF stack. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
|
| 3047 |
"""
|
| 3048 |
)
|
| 3049 |
gcode_use_ref_motion = gr.Checkbox(
|
| 3050 |
+
label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
|
| 3051 |
value=True,
|
| 3052 |
)
|
| 3053 |
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
|
|
|
| 3070 |
nozzle_use_individual_spacing = gr.Checkbox(label="Advanced Settings", value=False)
|
| 3071 |
nozzle_spacing_table = gr.Dataframe(
|
| 3072 |
headers=NOZZLE_SPACING_HEADERS,
|
| 3073 |
+
value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
|
| 3074 |
row_count=(1, "fixed"),
|
| 3075 |
column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
|
| 3076 |
interactive=True,
|
|
|
|
| 3161 |
parallel_mode = gr.State("tube")
|
| 3162 |
|
| 3163 |
shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
|
| 3164 |
+
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
|
| 3165 |
+
fn=lambda records: _dropdown_update(records),
|
| 3166 |
+
inputs=[shape_records],
|
| 3167 |
+
outputs=[split_source],
|
| 3168 |
+
queue=False,
|
| 3169 |
+
)
|
| 3170 |
+
sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
|
| 3171 |
+
fn=lambda records: _dropdown_update(records),
|
| 3172 |
+
inputs=[shape_records],
|
| 3173 |
+
outputs=[split_source],
|
| 3174 |
+
queue=False,
|
| 3175 |
+
)
|
| 3176 |
+
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 3177 |
+
fn=lambda records: _dropdown_update(records),
|
| 3178 |
+
inputs=[shape_records],
|
| 3179 |
+
outputs=[split_source],
|
| 3180 |
+
queue=False,
|
| 3181 |
+
)
|
| 3182 |
shape_settings.select(
|
| 3183 |
fn=delete_shape_from_settings,
|
| 3184 |
inputs=[shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing, last_shape_delete_at],
|
| 3185 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 3186 |
+
).then(
|
| 3187 |
+
fn=lambda records: _dropdown_update(records),
|
| 3188 |
+
inputs=[shape_records],
|
| 3189 |
+
outputs=[split_source],
|
| 3190 |
+
queue=False,
|
| 3191 |
)
|
| 3192 |
|
| 3193 |
preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
|
| 3194 |
shape_settings.change(
|
| 3195 |
+
fn=normalize_shape_settings_and_spacing,
|
| 3196 |
+
inputs=[shape_records, shape_settings, scale_mode, nozzle_spacing_table, nozzle_use_individual_spacing],
|
| 3197 |
+
outputs=[shape_records, shape_settings, nozzle_spacing_table],
|
| 3198 |
queue=False,
|
| 3199 |
)
|
| 3200 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
|
|
|
|
| 3227 |
generate_button.click(
|
| 3228 |
fn=generate_dynamic_stacks,
|
| 3229 |
inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode],
|
| 3230 |
+
outputs=[
|
| 3231 |
+
shape_records,
|
| 3232 |
+
tiff_downloads,
|
| 3233 |
+
slicer_status,
|
| 3234 |
+
selected_shape,
|
| 3235 |
+
slice_slider,
|
| 3236 |
+
slice_label,
|
| 3237 |
+
slice_preview,
|
| 3238 |
+
ref_state,
|
| 3239 |
+
ref_slice_slider,
|
| 3240 |
+
ref_slice_label,
|
| 3241 |
+
ref_slice_preview,
|
| 3242 |
+
],
|
| 3243 |
+
).then(
|
| 3244 |
+
fn=lambda records: _dropdown_update(records),
|
| 3245 |
+
inputs=[shape_records],
|
| 3246 |
+
outputs=[split_source],
|
| 3247 |
+
queue=False,
|
| 3248 |
)
|
| 3249 |
ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
|
| 3250 |
ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
|
| 3251 |
ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 3252 |
ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
|
| 3253 |
|
| 3254 |
+
split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
|
| 3255 |
+
split_button.click(
|
| 3256 |
+
fn=split_selected_shape_for_grid,
|
| 3257 |
+
inputs=[
|
| 3258 |
+
shape_records,
|
| 3259 |
+
split_source,
|
| 3260 |
+
shape_settings,
|
| 3261 |
+
nozzle_spacing_table,
|
| 3262 |
+
nozzle_use_individual_spacing,
|
| 3263 |
+
split_columns,
|
| 3264 |
+
split_rows,
|
| 3265 |
+
split_start_nozzle,
|
| 3266 |
+
split_start_valve,
|
| 3267 |
+
],
|
| 3268 |
+
outputs=[
|
| 3269 |
+
shape_records,
|
| 3270 |
+
shape_settings,
|
| 3271 |
+
nozzle_spacing_table,
|
| 3272 |
+
tiff_downloads,
|
| 3273 |
+
gcode_downloads,
|
| 3274 |
+
gcode_text_source,
|
| 3275 |
+
gcode_source,
|
| 3276 |
+
split_source,
|
| 3277 |
+
split_downloads,
|
| 3278 |
+
split_piece_states,
|
| 3279 |
+
split_piece_source,
|
| 3280 |
+
split_piece_slider,
|
| 3281 |
+
split_piece_label,
|
| 3282 |
+
split_piece_preview,
|
| 3283 |
+
split_status,
|
| 3284 |
+
],
|
| 3285 |
+
)
|
| 3286 |
+
split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 3287 |
+
split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
|
| 3288 |
+
split_piece_prev.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, -1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 3289 |
+
split_piece_next.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, 1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
|
| 3290 |
+
|
| 3291 |
gcode_button.click(
|
| 3292 |
fn=generate_dynamic_gcode,
|
| 3293 |
inputs=[shape_records, shape_settings, gcode_all_g1, gcode_use_ref_motion, gcode_raster_pattern, ref_state, layer_height, pixel_size],
|
gcode_viewer.py
CHANGED
|
@@ -381,6 +381,14 @@ def _segments_to_xyz(
|
|
| 381 |
return xs, ys, zs
|
| 382 |
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
def build_toolpath_figure(
|
| 385 |
parsed: dict,
|
| 386 |
travel_opacity: float = 0.2,
|
|
@@ -526,9 +534,9 @@ def build_parallel_figure(
|
|
| 526 |
`tube` True draws filament tubes with a shared-time animation
|
| 527 |
timeline; False draws fast thin scatter lines (no animation).
|
| 528 |
|
| 529 |
-
`parts` is a list of {"idx": int, "color": str, "parsed": dict}.
|
| 530 |
-
print and travel traces (and, in tube mode, a nozzle marker) are
|
| 531 |
-
so the client-side animation/recolor can address them.
|
| 532 |
"""
|
| 533 |
fig = go.Figure()
|
| 534 |
anim_parts: list[dict] = []
|
|
@@ -541,6 +549,7 @@ def build_parallel_figure(
|
|
| 541 |
running_x = 0.0
|
| 542 |
for part in parts:
|
| 543 |
idx = part["idx"]
|
|
|
|
| 544 |
color = part["color"]
|
| 545 |
parsed = part["parsed"]
|
| 546 |
moves = parsed.get("moves") or []
|
|
@@ -554,7 +563,8 @@ def build_parallel_figure(
|
|
| 554 |
y_off = 0.0
|
| 555 |
running_x += width + gap
|
| 556 |
else:
|
| 557 |
-
x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
|
|
|
|
| 558 |
|
| 559 |
if tube:
|
| 560 |
print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
|
|
@@ -591,7 +601,7 @@ def build_parallel_figure(
|
|
| 591 |
fig.add_trace(
|
| 592 |
go.Scatter3d(
|
| 593 |
x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]],
|
| 594 |
-
mode="markers", name=
|
| 595 |
marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
|
| 596 |
)
|
| 597 |
)
|
|
@@ -601,7 +611,7 @@ def build_parallel_figure(
|
|
| 601 |
anim_parts.append({
|
| 602 |
"printName": f"Shape {idx}",
|
| 603 |
"travelName": f"Travel {idx}",
|
| 604 |
-
"nozzleName":
|
| 605 |
"print_face_t": print_tube["face_t"],
|
| 606 |
"travel_face_t": travel_tube["face_t"],
|
| 607 |
"path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"],
|
|
@@ -668,13 +678,16 @@ def build_nozzle_spacing_figure(
|
|
| 668 |
fig = go.Figure()
|
| 669 |
bx0 = by0 = float("inf")
|
| 670 |
bx1 = by1 = float("-inf")
|
|
|
|
| 671 |
|
| 672 |
for part in parts:
|
| 673 |
idx = part["idx"]
|
|
|
|
| 674 |
color = part["color"]
|
|
|
|
| 675 |
parsed = part["parsed"]
|
| 676 |
(pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"]
|
| 677 |
-
x_off, y_off = part_offsets.get(idx, (0.0, 0.0))
|
| 678 |
xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off]
|
| 679 |
ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off]
|
| 680 |
fig.add_trace(
|
|
@@ -682,25 +695,31 @@ def build_nozzle_spacing_figure(
|
|
| 682 |
x=xs,
|
| 683 |
y=ys,
|
| 684 |
mode="lines",
|
| 685 |
-
name=f"Shape {idx} bounds",
|
| 686 |
line=dict(color=color, width=2),
|
| 687 |
)
|
| 688 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
fig.add_trace(
|
| 690 |
go.Scatter(
|
| 691 |
x=[x_off],
|
| 692 |
y=[y_off],
|
| 693 |
mode="markers+text",
|
| 694 |
-
name=f"Nozzle {
|
| 695 |
-
marker=dict(color=
|
| 696 |
-
text=[f"N{
|
| 697 |
textposition="top center",
|
| 698 |
)
|
| 699 |
)
|
| 700 |
-
bx0 = min(bx0,
|
| 701 |
-
bx1 = max(bx1,
|
| 702 |
-
by0 = min(by0,
|
| 703 |
-
by1 = max(by1,
|
| 704 |
|
| 705 |
for spacing in spacings:
|
| 706 |
start = spacing["from"]
|
|
@@ -759,7 +778,7 @@ def build_parallel_gif(
|
|
| 759 |
|
| 760 |
Each part's toolpath is drawn as growing colored lines (print solid, travel
|
| 761 |
faint), three parts in parallel on a shared cumulative-length time axis.
|
| 762 |
-
`parts` is a list of {"idx": int, "color": str, "parsed": dict}.
|
| 763 |
"""
|
| 764 |
import matplotlib
|
| 765 |
matplotlib.use("Agg")
|
|
@@ -784,7 +803,8 @@ def build_parallel_gif(
|
|
| 784 |
y_off = 0.0
|
| 785 |
running_x += (pxmax - pxmin) + gap
|
| 786 |
else:
|
| 787 |
-
|
|
|
|
| 788 |
|
| 789 |
cum = 0.0
|
| 790 |
mlist: list[tuple] = []
|
|
|
|
| 381 |
return xs, ys, zs
|
| 382 |
|
| 383 |
|
| 384 |
+
def _part_nozzle(part: dict) -> int:
|
| 385 |
+
try:
|
| 386 |
+
nozzle = int(float(part.get("nozzle", part.get("idx", 1))))
|
| 387 |
+
except (TypeError, ValueError):
|
| 388 |
+
nozzle = int(part.get("idx", 1) or 1)
|
| 389 |
+
return nozzle if nozzle > 0 else int(part.get("idx", 1) or 1)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
def build_toolpath_figure(
|
| 393 |
parsed: dict,
|
| 394 |
travel_opacity: float = 0.2,
|
|
|
|
| 534 |
`tube` True draws filament tubes with a shared-time animation
|
| 535 |
timeline; False draws fast thin scatter lines (no animation).
|
| 536 |
|
| 537 |
+
`parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}.
|
| 538 |
+
Each part's print and travel traces (and, in tube mode, a nozzle marker) are
|
| 539 |
+
named by idx so the client-side animation/recolor can address them.
|
| 540 |
"""
|
| 541 |
fig = go.Figure()
|
| 542 |
anim_parts: list[dict] = []
|
|
|
|
| 549 |
running_x = 0.0
|
| 550 |
for part in parts:
|
| 551 |
idx = part["idx"]
|
| 552 |
+
nozzle = _part_nozzle(part)
|
| 553 |
color = part["color"]
|
| 554 |
parsed = part["parsed"]
|
| 555 |
moves = parsed.get("moves") or []
|
|
|
|
| 563 |
y_off = 0.0
|
| 564 |
running_x += width + gap
|
| 565 |
else:
|
| 566 |
+
x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0)))
|
| 567 |
+
nozzle_trace_name = f"Nozzle {nozzle} (Shape {idx})"
|
| 568 |
|
| 569 |
if tube:
|
| 570 |
print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
|
|
|
|
| 601 |
fig.add_trace(
|
| 602 |
go.Scatter3d(
|
| 603 |
x=[path_x[-1]], y=[path_y[-1]], z=[path["z"][-1]],
|
| 604 |
+
mode="markers", name=nozzle_trace_name,
|
| 605 |
marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
|
| 606 |
)
|
| 607 |
)
|
|
|
|
| 611 |
anim_parts.append({
|
| 612 |
"printName": f"Shape {idx}",
|
| 613 |
"travelName": f"Travel {idx}",
|
| 614 |
+
"nozzleName": nozzle_trace_name,
|
| 615 |
"print_face_t": print_tube["face_t"],
|
| 616 |
"travel_face_t": travel_tube["face_t"],
|
| 617 |
"path_x": path_x, "path_y": path_y, "path_z": path["z"], "path_t": path["t"],
|
|
|
|
| 678 |
fig = go.Figure()
|
| 679 |
bx0 = by0 = float("inf")
|
| 680 |
bx1 = by1 = float("-inf")
|
| 681 |
+
nozzle_colors: dict[int, str] = {}
|
| 682 |
|
| 683 |
for part in parts:
|
| 684 |
idx = part["idx"]
|
| 685 |
+
nozzle = _part_nozzle(part)
|
| 686 |
color = part["color"]
|
| 687 |
+
nozzle_colors.setdefault(nozzle, color)
|
| 688 |
parsed = part["parsed"]
|
| 689 |
(pxmin, pymin, _), (pxmax, pymax, _) = parsed["bounds"]
|
| 690 |
+
x_off, y_off = part_offsets.get(nozzle, part_offsets.get(idx, (0.0, 0.0)))
|
| 691 |
xs = [pxmin + x_off, pxmax + x_off, pxmax + x_off, pxmin + x_off, pxmin + x_off]
|
| 692 |
ys = [pymin + y_off, pymin + y_off, pymax + y_off, pymax + y_off, pymin + y_off]
|
| 693 |
fig.add_trace(
|
|
|
|
| 695 |
x=xs,
|
| 696 |
y=ys,
|
| 697 |
mode="lines",
|
| 698 |
+
name=f"Shape {idx} bounds (N{nozzle})",
|
| 699 |
line=dict(color=color, width=2),
|
| 700 |
)
|
| 701 |
)
|
| 702 |
+
bx0 = min(bx0, min(xs), x_off)
|
| 703 |
+
bx1 = max(bx1, max(xs), x_off)
|
| 704 |
+
by0 = min(by0, min(ys), y_off)
|
| 705 |
+
by1 = max(by1, max(ys), y_off)
|
| 706 |
+
|
| 707 |
+
for nozzle, (x_off, y_off) in sorted(part_offsets.items()):
|
| 708 |
fig.add_trace(
|
| 709 |
go.Scatter(
|
| 710 |
x=[x_off],
|
| 711 |
y=[y_off],
|
| 712 |
mode="markers+text",
|
| 713 |
+
name=f"Nozzle {nozzle}",
|
| 714 |
+
marker=dict(color=nozzle_colors.get(nozzle, "#444444"), size=12),
|
| 715 |
+
text=[f"N{nozzle}"],
|
| 716 |
textposition="top center",
|
| 717 |
)
|
| 718 |
)
|
| 719 |
+
bx0 = min(bx0, x_off)
|
| 720 |
+
bx1 = max(bx1, x_off)
|
| 721 |
+
by0 = min(by0, y_off)
|
| 722 |
+
by1 = max(by1, y_off)
|
| 723 |
|
| 724 |
for spacing in spacings:
|
| 725 |
start = spacing["from"]
|
|
|
|
| 778 |
|
| 779 |
Each part's toolpath is drawn as growing colored lines (print solid, travel
|
| 780 |
faint), three parts in parallel on a shared cumulative-length time axis.
|
| 781 |
+
`parts` is a list of {"idx": int, "nozzle": int, "color": str, "parsed": dict}.
|
| 782 |
"""
|
| 783 |
import matplotlib
|
| 784 |
matplotlib.use("Agg")
|
|
|
|
| 803 |
y_off = 0.0
|
| 804 |
running_x += (pxmax - pxmin) + gap
|
| 805 |
else:
|
| 806 |
+
nozzle = _part_nozzle(part)
|
| 807 |
+
x_off, y_off = part_offsets.get(nozzle, part_offsets.get(part["idx"], (0.0, 0.0)))
|
| 808 |
|
| 809 |
cum = 0.0
|
| 810 |
mlist: list[tuple] = []
|
tests/test_app_scaling.py
CHANGED
|
@@ -2,10 +2,14 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import numpy as np
|
| 4 |
import trimesh
|
|
|
|
| 5 |
|
| 6 |
from app import (
|
| 7 |
SCALE_MODE_TARGET_DIMENSIONS,
|
| 8 |
SCALE_MODE_UNIFORM_FACTOR,
|
|
|
|
|
|
|
|
|
|
| 9 |
_resolve_mesh_scale_factors,
|
| 10 |
_uniform_target_extents_from_anchor,
|
| 11 |
)
|
|
@@ -53,3 +57,84 @@ def test_uniform_target_extents_update_from_changed_side() -> None:
|
|
| 53 |
)
|
| 54 |
|
| 55 |
np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import numpy as np
|
| 4 |
import trimesh
|
| 5 |
+
from PIL import Image
|
| 6 |
|
| 7 |
from app import (
|
| 8 |
SCALE_MODE_TARGET_DIMENSIONS,
|
| 9 |
SCALE_MODE_UNIFORM_FACTOR,
|
| 10 |
+
generate_dynamic_stacks,
|
| 11 |
+
split_tiff_stack_grid,
|
| 12 |
+
split_tiff_stack_left_right,
|
| 13 |
_resolve_mesh_scale_factors,
|
| 14 |
_uniform_target_extents_from_anchor,
|
| 15 |
)
|
|
|
|
| 57 |
)
|
| 58 |
|
| 59 |
np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_generate_dynamic_stacks_empty_input_resets_reference_stack() -> None:
|
| 63 |
+
outputs = generate_dynamic_stacks([], [], 0.8, 0.8, SCALE_MODE_TARGET_DIMENSIONS)
|
| 64 |
+
|
| 65 |
+
assert len(outputs) == 11
|
| 66 |
+
assert outputs[0] == []
|
| 67 |
+
assert outputs[2] == "Upload at least one STL first."
|
| 68 |
+
assert outputs[7]["tiff_paths"] == []
|
| 69 |
+
assert outputs[9] == "No reference stack generated yet."
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_split_tiff_stack_left_right_preserves_pixels_and_metadata(tmp_path) -> None:
|
| 73 |
+
pixels = np.array(
|
| 74 |
+
[
|
| 75 |
+
[0, 255, 0, 255, 0],
|
| 76 |
+
[255, 0, 255, 0, 255],
|
| 77 |
+
],
|
| 78 |
+
dtype=np.uint8,
|
| 79 |
+
)
|
| 80 |
+
tiff_path = tmp_path / "slice_0000.tif"
|
| 81 |
+
Image.fromarray(pixels, mode="L").save(tiff_path)
|
| 82 |
+
state = {
|
| 83 |
+
"tiff_paths": [str(tiff_path)],
|
| 84 |
+
"z_values": [1.25],
|
| 85 |
+
"pixel_size": 0.5,
|
| 86 |
+
"x_min": 10.0,
|
| 87 |
+
"y_min": -2.0,
|
| 88 |
+
"image_width": 5,
|
| 89 |
+
"image_height": 2,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
left_state, right_state, left_zip, right_zip = split_tiff_stack_left_right(state, "wide-part")
|
| 93 |
+
|
| 94 |
+
assert left_zip.exists()
|
| 95 |
+
assert right_zip.exists()
|
| 96 |
+
assert left_state["image_width"] == 3
|
| 97 |
+
assert right_state["image_width"] == 2
|
| 98 |
+
assert left_state["x_min"] == 10.0
|
| 99 |
+
assert right_state["x_min"] == 11.5
|
| 100 |
+
assert left_state["z_values"] == [1.25]
|
| 101 |
+
assert right_state["z_values"] == [1.25]
|
| 102 |
+
|
| 103 |
+
with Image.open(left_state["tiff_paths"][0]) as left_image:
|
| 104 |
+
np.testing.assert_array_equal(np.asarray(left_image), pixels[:, :3])
|
| 105 |
+
with Image.open(right_state["tiff_paths"][0]) as right_image:
|
| 106 |
+
np.testing.assert_array_equal(np.asarray(right_image), pixels[:, 3:])
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
|
| 110 |
+
pixels = np.arange(20, dtype=np.uint8).reshape((4, 5))
|
| 111 |
+
tiff_path = tmp_path / "slice_0000.tif"
|
| 112 |
+
Image.fromarray(pixels, mode="L").save(tiff_path)
|
| 113 |
+
state = {
|
| 114 |
+
"tiff_paths": [str(tiff_path)],
|
| 115 |
+
"z_values": [2.0],
|
| 116 |
+
"pixel_size": 0.25,
|
| 117 |
+
"x_min": 4.0,
|
| 118 |
+
"y_min": 10.0,
|
| 119 |
+
"image_width": 5,
|
| 120 |
+
"image_height": 4,
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
pieces = split_tiff_stack_grid(state, "grid-part", columns=2, rows=2)
|
| 124 |
+
|
| 125 |
+
assert [(piece["row"], piece["col"]) for piece in pieces] == [(1, 1), (1, 2), (2, 1), (2, 2)]
|
| 126 |
+
assert [piece["state"]["image_width"] for piece in pieces] == [3, 2, 3, 2]
|
| 127 |
+
assert [piece["state"]["image_height"] for piece in pieces] == [2, 2, 2, 2]
|
| 128 |
+
assert [piece["state"]["x_min"] for piece in pieces] == [4.0, 4.75, 4.0, 4.75]
|
| 129 |
+
assert [piece["state"]["y_min"] for piece in pieces] == [10.5, 10.5, 10.0, 10.0]
|
| 130 |
+
assert all(piece["zip_path"].exists() for piece in pieces)
|
| 131 |
+
|
| 132 |
+
expected = [
|
| 133 |
+
pixels[:2, :3],
|
| 134 |
+
pixels[:2, 3:],
|
| 135 |
+
pixels[2:, :3],
|
| 136 |
+
pixels[2:, 3:],
|
| 137 |
+
]
|
| 138 |
+
for piece, expected_pixels in zip(pieces, expected):
|
| 139 |
+
with Image.open(piece["state"]["tiff_paths"][0]) as image:
|
| 140 |
+
np.testing.assert_array_equal(np.asarray(image), expected_pixels)
|
tests/test_nozzle_spacing.py
CHANGED
|
@@ -5,7 +5,9 @@ import numpy as np
|
|
| 5 |
from app import (
|
| 6 |
ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 7 |
SCALE_MODE_UNIFORM_FACTOR,
|
|
|
|
| 8 |
SIMPLE_NOZZLE_SPACING_HEADERS,
|
|
|
|
| 9 |
delete_shape_from_settings,
|
| 10 |
_format_nozzle_spacing_status,
|
| 11 |
_shape_settings_rows,
|
|
@@ -16,9 +18,14 @@ from app import (
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
| 19 |
-
def _part(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return {
|
| 21 |
"idx": idx,
|
|
|
|
| 22 |
"color": "#000000",
|
| 23 |
"parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
|
| 24 |
}
|
|
@@ -42,9 +49,9 @@ def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
|
|
| 42 |
|
| 43 |
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 44 |
np.testing.assert_allclose(offsets[2], (12.5, -1.0))
|
| 45 |
-
np.testing.assert_allclose(offsets[3], (
|
| 46 |
assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
|
| 47 |
-
assert spacings[1] == {"from": 2, "to": 3, "dx":
|
| 48 |
|
| 49 |
|
| 50 |
def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
|
|
@@ -67,6 +74,28 @@ def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
|
|
| 67 |
assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
|
| 71 |
parts = [
|
| 72 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
|
@@ -182,6 +211,40 @@ def test_keep_proportions_uses_most_recently_edited_dimension() -> None:
|
|
| 182 |
assert updated_settings[0][2:5] == [25.0, 50.0, 12.5]
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
|
| 186 |
records = [
|
| 187 |
{"idx": 1, "name": "first"},
|
|
@@ -193,11 +256,11 @@ def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
|
|
| 193 |
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
|
| 194 |
|
| 195 |
assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
|
| 196 |
-
assert update["value"] == [["Same spacing", "All neighboring
|
| 197 |
assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
|
| 198 |
|
| 199 |
|
| 200 |
-
def
|
| 201 |
records = [
|
| 202 |
{"idx": 1, "name": "first"},
|
| 203 |
{"idx": 2, "name": "second"},
|
|
@@ -212,14 +275,27 @@ def test_advanced_spacing_table_uses_named_shape_pairs() -> None:
|
|
| 212 |
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
|
| 213 |
|
| 214 |
assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
|
| 215 |
-
assert update["value"][0][:2] == ["
|
| 216 |
-
assert update["value"][1][:2] == ["
|
| 217 |
assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
| 221 |
class Event:
|
| 222 |
-
index = (1,
|
| 223 |
|
| 224 |
records = [
|
| 225 |
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
|
@@ -241,7 +317,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
|
| 241 |
|
| 242 |
def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
| 243 |
class Event:
|
| 244 |
-
index = (1,
|
| 245 |
|
| 246 |
records = [
|
| 247 |
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
|
|
|
| 5 |
from app import (
|
| 6 |
ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 7 |
SCALE_MODE_UNIFORM_FACTOR,
|
| 8 |
+
SHAPE_SETTINGS_HEADERS,
|
| 9 |
SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 10 |
+
_apply_shape_settings,
|
| 11 |
delete_shape_from_settings,
|
| 12 |
_format_nozzle_spacing_status,
|
| 13 |
_shape_settings_rows,
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
|
| 21 |
+
def _part(
|
| 22 |
+
idx: int,
|
| 23 |
+
bounds: tuple[tuple[float, float, float], tuple[float, float, float]],
|
| 24 |
+
nozzle: int | None = None,
|
| 25 |
+
) -> dict:
|
| 26 |
return {
|
| 27 |
"idx": idx,
|
| 28 |
+
"nozzle": nozzle if nozzle is not None else idx,
|
| 29 |
"color": "#000000",
|
| 30 |
"parsed": {"bounds": bounds, "moves": [{"kind": "print"}], "point_count": 1},
|
| 31 |
}
|
|
|
|
| 49 |
|
| 50 |
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 51 |
np.testing.assert_allclose(offsets[2], (12.5, -1.0))
|
| 52 |
+
np.testing.assert_allclose(offsets[3], (27.5, 3.0))
|
| 53 |
assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
|
| 54 |
+
assert spacings[1] == {"from": 2, "to": 3, "dx": 15.0, "dy": 4.0}
|
| 55 |
|
| 56 |
|
| 57 |
def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
|
|
|
|
| 74 |
assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
|
| 75 |
|
| 76 |
|
| 77 |
+
def test_nozzle_layout_groups_shapes_that_share_a_nozzle() -> None:
|
| 78 |
+
parts = [
|
| 79 |
+
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0)), nozzle=1),
|
| 80 |
+
_part(2, ((-1.0, -2.0, 0.0), (12.0, 4.0, 1.0)), nozzle=1),
|
| 81 |
+
_part(3, ((0.0, 0.0, 0.0), (6.0, 5.0, 1.0)), nozzle=2),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
offsets, spacings = _resolve_nozzle_layout(
|
| 85 |
+
parts,
|
| 86 |
+
same_spacing=True,
|
| 87 |
+
part_gap_12_x=5.0,
|
| 88 |
+
part_gap_12_y=1.5,
|
| 89 |
+
part_gap_23_x=0.0,
|
| 90 |
+
part_gap_23_y=0.0,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
assert sorted(offsets) == [1, 2]
|
| 94 |
+
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 95 |
+
np.testing.assert_allclose(offsets[2], (17.0, 1.5))
|
| 96 |
+
assert spacings == [{"from": 1, "to": 2, "dx": 17.0, "dy": 1.5}]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
|
| 100 |
parts = [
|
| 101 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
|
|
|
| 211 |
assert updated_settings[0][2:5] == [25.0, 50.0, 12.5]
|
| 212 |
|
| 213 |
|
| 214 |
+
def test_shape_settings_round_trip_contour_tracing_column() -> None:
|
| 215 |
+
records = [
|
| 216 |
+
{
|
| 217 |
+
"idx": 1,
|
| 218 |
+
"name": "circle",
|
| 219 |
+
"stl_path": "circle.stl",
|
| 220 |
+
"target_x": 10.0,
|
| 221 |
+
"target_y": 11.0,
|
| 222 |
+
"target_z": 12.0,
|
| 223 |
+
"pressure": 25.0,
|
| 224 |
+
"valve": 4,
|
| 225 |
+
"nozzle": 1,
|
| 226 |
+
"port": 1,
|
| 227 |
+
"color": "#111111",
|
| 228 |
+
"contour_tracing": False,
|
| 229 |
+
}
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
rows = _shape_settings_rows(records)
|
| 233 |
+
assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
|
| 234 |
+
assert SHAPE_SETTINGS_HEADERS[-2:] == ["Contour Tracing", "Delete"]
|
| 235 |
+
assert rows[0][6:9] == [4, 1, 1]
|
| 236 |
+
assert rows[0][-2:] == [False, "Delete"]
|
| 237 |
+
|
| 238 |
+
rows[0][7] = 3
|
| 239 |
+
rows[0][8] = 2
|
| 240 |
+
rows[0][-2] = True
|
| 241 |
+
updated = _apply_shape_settings(records, rows)
|
| 242 |
+
|
| 243 |
+
assert updated[0]["nozzle"] == 3
|
| 244 |
+
assert updated[0]["port"] == 2
|
| 245 |
+
assert updated[0]["contour_tracing"] is True
|
| 246 |
+
|
| 247 |
+
|
| 248 |
def test_simple_spacing_table_uses_one_shared_spacing_row() -> None:
|
| 249 |
records = [
|
| 250 |
{"idx": 1, "name": "first"},
|
|
|
|
| 256 |
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
|
| 257 |
|
| 258 |
assert update["headers"] == SIMPLE_NOZZLE_SPACING_HEADERS
|
| 259 |
+
assert update["value"] == [["Same spacing", "All neighboring nozzles", 7.0, -1.5]]
|
| 260 |
assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
|
| 261 |
|
| 262 |
|
| 263 |
+
def test_advanced_spacing_table_uses_named_nozzle_pairs() -> None:
|
| 264 |
records = [
|
| 265 |
{"idx": 1, "name": "first"},
|
| 266 |
{"idx": 2, "name": "second"},
|
|
|
|
| 275 |
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
|
| 276 |
|
| 277 |
assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
|
| 278 |
+
assert update["value"][0][:2] == ["Nozzle 1: Shape 1", "Nozzle 2: Shape 2"]
|
| 279 |
+
assert update["value"][1][:2] == ["Nozzle 2: Shape 2", "Nozzle 3: Shape 3"]
|
| 280 |
assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
|
| 281 |
|
| 282 |
|
| 283 |
+
def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
|
| 284 |
+
records = [
|
| 285 |
+
{"idx": 1, "name": "first", "nozzle": 1},
|
| 286 |
+
{"idx": 2, "name": "second", "nozzle": 1},
|
| 287 |
+
{"idx": 3, "name": "third", "nozzle": 2},
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
update = _spacing_table_update(records, [["ignored", "ignored", 9.0, 2.5]], True)
|
| 291 |
+
|
| 292 |
+
assert update["headers"] == ADVANCED_NOZZLE_SPACING_HEADERS
|
| 293 |
+
assert update["value"] == [["Nozzle 1: Shape 1, Shape 2", "Nozzle 2: Shape 3", 9.0, 2.5]]
|
| 294 |
+
|
| 295 |
+
|
| 296 |
def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
| 297 |
class Event:
|
| 298 |
+
index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
|
| 299 |
|
| 300 |
records = [
|
| 301 |
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
|
|
|
| 317 |
|
| 318 |
def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
| 319 |
class Event:
|
| 320 |
+
index = (1, len(SHAPE_SETTINGS_HEADERS) - 1)
|
| 321 |
|
| 322 |
records = [
|
| 323 |
{"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
|
tests/test_tiff_to_gcode.py
CHANGED
|
@@ -2,15 +2,413 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import zipfile
|
| 4 |
|
|
|
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
from tiff_to_gcode import (
|
|
|
|
| 8 |
RASTER_PATTERN_WOODPILE,
|
| 9 |
RASTER_PATTERN_Y_DIRECTION,
|
|
|
|
|
|
|
| 10 |
generate_snake_path_gcode,
|
| 11 |
)
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
| 15 |
tiff_path = tmp_path / "slice_0000.tif"
|
| 16 |
Image.new("L", (1, 1), 0).save(tiff_path)
|
|
@@ -166,3 +564,86 @@ def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
|
|
| 166 |
assert max(x_positions) <= 3.0
|
| 167 |
assert min(y_positions) >= 0.0
|
| 168 |
assert max(y_positions) <= 2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import zipfile
|
| 4 |
|
| 5 |
+
import numpy as np
|
| 6 |
from PIL import Image
|
| 7 |
|
| 8 |
from tiff_to_gcode import (
|
| 9 |
+
RASTER_PATTERN_SAME_DIRECTION,
|
| 10 |
RASTER_PATTERN_WOODPILE,
|
| 11 |
RASTER_PATTERN_Y_DIRECTION,
|
| 12 |
+
_build_contour_layers,
|
| 13 |
+
_trace_mask_contours,
|
| 14 |
generate_snake_path_gcode,
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
+
def _move_signature(gcode_text: str) -> list[tuple[float | None, float | None, float | None]]:
|
| 19 |
+
signature: list[tuple[float | None, float | None, float | None]] = []
|
| 20 |
+
for line in gcode_text.splitlines():
|
| 21 |
+
if not line.startswith(("G0", "G1")):
|
| 22 |
+
continue
|
| 23 |
+
axes: dict[str, float] = {}
|
| 24 |
+
for token in line.split():
|
| 25 |
+
if token[:1] in {"X", "Y", "Z"}:
|
| 26 |
+
axes[token[0]] = float(token[1:])
|
| 27 |
+
signature.append((axes.get("X"), axes.get("Y"), axes.get("Z")))
|
| 28 |
+
return signature
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _move_endpoints_for_color(gcode_text: str, color: int) -> list[tuple[float, float]]:
|
| 32 |
+
x = y = 0.0
|
| 33 |
+
endpoints: list[tuple[float, float]] = []
|
| 34 |
+
for line in gcode_text.splitlines():
|
| 35 |
+
if not line.startswith(("G0", "G1")):
|
| 36 |
+
continue
|
| 37 |
+
start = (x, y)
|
| 38 |
+
for token in line.split():
|
| 39 |
+
if token.startswith("X"):
|
| 40 |
+
x += float(token[1:])
|
| 41 |
+
if token.startswith("Y"):
|
| 42 |
+
y += float(token[1:])
|
| 43 |
+
if f"; Color {color}" in line:
|
| 44 |
+
endpoints.extend([start, (x, y)])
|
| 45 |
+
return endpoints
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _moves_with_colors(gcode_text: str) -> list[dict]:
|
| 49 |
+
x = y = z = 0.0
|
| 50 |
+
moves: list[dict] = []
|
| 51 |
+
for line in gcode_text.splitlines():
|
| 52 |
+
if not line.startswith(("G0", "G1")):
|
| 53 |
+
continue
|
| 54 |
+
start = (x, y, z)
|
| 55 |
+
for token in line.split():
|
| 56 |
+
if token.startswith("X"):
|
| 57 |
+
x += float(token[1:])
|
| 58 |
+
if token.startswith("Y"):
|
| 59 |
+
y += float(token[1:])
|
| 60 |
+
if token.startswith("Z"):
|
| 61 |
+
z += float(token[1:])
|
| 62 |
+
color = None
|
| 63 |
+
if "; Color " in line:
|
| 64 |
+
color = int(line.rsplit("; Color ", 1)[1])
|
| 65 |
+
moves.append({"start": start, "end": (x, y, z), "color": color})
|
| 66 |
+
return moves
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_trace_mask_contours_uses_tiff_pixel_border_edges() -> None:
|
| 70 |
+
contours = _trace_mask_contours(
|
| 71 |
+
np.array(
|
| 72 |
+
[
|
| 73 |
+
[True, True],
|
| 74 |
+
[True, True],
|
| 75 |
+
],
|
| 76 |
+
dtype=bool,
|
| 77 |
+
),
|
| 78 |
+
pixel_size=1.0,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
assert contours == [[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0)]]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_contour_tracing_aligns_default_raster_border_pixel_frame(tmp_path) -> None:
|
| 85 |
+
raster_tiff = tmp_path / "raster_slice_0000.tif"
|
| 86 |
+
raster_image = Image.new("L", (7, 6), 255)
|
| 87 |
+
raster_image.putpixel((4, 3), 0)
|
| 88 |
+
raster_image.save(raster_tiff)
|
| 89 |
+
raster_zip = tmp_path / "raster_slices.zip"
|
| 90 |
+
with zipfile.ZipFile(raster_zip, mode="w") as archive:
|
| 91 |
+
archive.write(raster_tiff, arcname=raster_tiff.name)
|
| 92 |
+
|
| 93 |
+
contour_tiff = tmp_path / "contour_slice_0000.tif"
|
| 94 |
+
contour_image = Image.new("L", (7, 6), 255)
|
| 95 |
+
contour_image.putpixel((4, 3), 0)
|
| 96 |
+
contour_image.save(contour_tiff)
|
| 97 |
+
|
| 98 |
+
gcode_path = generate_snake_path_gcode(
|
| 99 |
+
raster_zip,
|
| 100 |
+
shape_name="aligned_contour",
|
| 101 |
+
pressure=25,
|
| 102 |
+
valve=7,
|
| 103 |
+
port=3,
|
| 104 |
+
fil_width=1.0,
|
| 105 |
+
all_g1=True,
|
| 106 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}],
|
| 107 |
+
active_contour_owner=1,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
points = _move_endpoints_for_color(gcode_path.read_text(), 255)
|
| 111 |
+
xs = [point[0] for point in points]
|
| 112 |
+
ys = [point[1] for point in points]
|
| 113 |
+
|
| 114 |
+
assert (min(xs), max(xs)) == (1.0, 2.0)
|
| 115 |
+
assert (min(ys), max(ys)) == (-0.5, 0.5)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_contour_tracing_travels_to_nearest_border_after_infill(tmp_path) -> None:
|
| 119 |
+
tiff_path = tmp_path / "slice_0000.tif"
|
| 120 |
+
image = Image.new("L", (7, 6), 255)
|
| 121 |
+
image.putpixel((4, 3), 0)
|
| 122 |
+
image.save(tiff_path)
|
| 123 |
+
zip_path = tmp_path / "slices.zip"
|
| 124 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 125 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 126 |
+
|
| 127 |
+
gcode_path = generate_snake_path_gcode(
|
| 128 |
+
zip_path,
|
| 129 |
+
shape_name="nearest_border_contour",
|
| 130 |
+
pressure=25,
|
| 131 |
+
valve=7,
|
| 132 |
+
port=3,
|
| 133 |
+
fil_width=1.0,
|
| 134 |
+
all_g1=True,
|
| 135 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
|
| 136 |
+
active_contour_owner=1,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 140 |
+
assert moves[1] == {
|
| 141 |
+
"start": (1.0, 0.0, 0.0),
|
| 142 |
+
"end": (2.0, 0.0, 0.0),
|
| 143 |
+
"color": 255,
|
| 144 |
+
}
|
| 145 |
+
assert moves[2]["color"] == 255
|
| 146 |
+
assert moves[2]["start"] == (2.0, 0.0, 0.0)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_contour_tracing_anchors_to_expanding_raster_frame(tmp_path) -> None:
|
| 150 |
+
tiff_path = tmp_path / "slice_0000.tif"
|
| 151 |
+
image = Image.new("L", (7, 5), 255)
|
| 152 |
+
for col in [3]:
|
| 153 |
+
image.putpixel((col, 0), 0)
|
| 154 |
+
for col in range(2, 5):
|
| 155 |
+
image.putpixel((col, 1), 0)
|
| 156 |
+
for col in range(1, 6):
|
| 157 |
+
image.putpixel((col, 2), 0)
|
| 158 |
+
image.save(tiff_path)
|
| 159 |
+
|
| 160 |
+
zip_path = tmp_path / "slices.zip"
|
| 161 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 162 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 163 |
+
|
| 164 |
+
gcode_path = generate_snake_path_gcode(
|
| 165 |
+
zip_path,
|
| 166 |
+
shape_name="expanding_contour",
|
| 167 |
+
pressure=25,
|
| 168 |
+
valve=7,
|
| 169 |
+
port=3,
|
| 170 |
+
fil_width=1.0,
|
| 171 |
+
all_g1=True,
|
| 172 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(tiff_path)]}],
|
| 173 |
+
active_contour_owner=1,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
print_moves = [
|
| 177 |
+
move for move in _moves_with_colors(gcode_path.read_text()) if move["color"] == 255
|
| 178 |
+
]
|
| 179 |
+
contour_points = [
|
| 180 |
+
point
|
| 181 |
+
for move in print_moves[3:]
|
| 182 |
+
for point in (move["start"], move["end"])
|
| 183 |
+
]
|
| 184 |
+
xs = [point[0] for point in contour_points]
|
| 185 |
+
ys = [point[1] for point in contour_points]
|
| 186 |
+
|
| 187 |
+
assert print_moves[:3] == [
|
| 188 |
+
{"start": (1.0, 0.0, 0.0), "end": (2.0, 0.0, 0.0), "color": 255},
|
| 189 |
+
{"start": (3.0, 1.0, 0.0), "end": (0.0, 1.0, 0.0), "color": 255},
|
| 190 |
+
{"start": (-1.0, 2.0, 0.0), "end": (4.0, 2.0, 0.0), "color": 255},
|
| 191 |
+
]
|
| 192 |
+
assert print_moves[3]["start"] == (4.0, 2.0, 0.0)
|
| 193 |
+
assert (min(xs), max(xs)) == (-1.0, 4.0)
|
| 194 |
+
assert (min(ys), max(ys)) == (-0.5, 2.5)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_contour_tracing_uses_shifted_layer_raster_frame(tmp_path) -> None:
|
| 198 |
+
tiff_paths = []
|
| 199 |
+
for index, pixel in enumerate([(4, 3), (5, 4)]):
|
| 200 |
+
tiff_path = tmp_path / f"slice_{index:04d}.tif"
|
| 201 |
+
image = Image.new("L", (8, 7), 255)
|
| 202 |
+
image.putpixel(pixel, 0)
|
| 203 |
+
image.save(tiff_path)
|
| 204 |
+
tiff_paths.append(tiff_path)
|
| 205 |
+
|
| 206 |
+
zip_path = tmp_path / "slices.zip"
|
| 207 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 208 |
+
for tiff_path in tiff_paths:
|
| 209 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 210 |
+
|
| 211 |
+
gcode_path = generate_snake_path_gcode(
|
| 212 |
+
zip_path,
|
| 213 |
+
shape_name="shifted_layer_contour",
|
| 214 |
+
pressure=25,
|
| 215 |
+
valve=7,
|
| 216 |
+
port=3,
|
| 217 |
+
fil_width=1.0,
|
| 218 |
+
layer_height=1.0,
|
| 219 |
+
all_g1=True,
|
| 220 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
|
| 221 |
+
active_contour_owner=1,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
layer_one_prints = [
|
| 225 |
+
move
|
| 226 |
+
for move in _moves_with_colors(gcode_path.read_text())
|
| 227 |
+
if move["color"] == 255
|
| 228 |
+
and move["start"][2] == 1.0
|
| 229 |
+
and move["end"][2] == 1.0
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
assert layer_one_prints[0] == {
|
| 233 |
+
"start": (3.0, 1.0, 1.0),
|
| 234 |
+
"end": (2.0, 1.0, 1.0),
|
| 235 |
+
"color": 255,
|
| 236 |
+
}
|
| 237 |
+
contour_points = [
|
| 238 |
+
point
|
| 239 |
+
for move in layer_one_prints[1:]
|
| 240 |
+
for point in (move["start"], move["end"])
|
| 241 |
+
]
|
| 242 |
+
xs = [point[0] for point in contour_points]
|
| 243 |
+
ys = [point[1] for point in contour_points]
|
| 244 |
+
|
| 245 |
+
assert layer_one_prints[1]["start"] == (2.0, 1.0, 1.0)
|
| 246 |
+
assert layer_one_prints[1]["end"] == (2.0, 0.5, 1.0)
|
| 247 |
+
assert (min(xs), max(xs)) == (2.0, 3.0)
|
| 248 |
+
assert (min(ys), max(ys)) == (0.5, 1.5)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_contour_tracing_mirrors_odd_layer_y_frame(tmp_path) -> None:
|
| 252 |
+
tiff_paths = []
|
| 253 |
+
for index in range(2):
|
| 254 |
+
tiff_path = tmp_path / f"slice_{index:04d}.tif"
|
| 255 |
+
image = Image.new("L", (8, 7), 255)
|
| 256 |
+
image.putpixel((4, 3), 0)
|
| 257 |
+
image.putpixel((4, 4), 0)
|
| 258 |
+
image.putpixel((5, 4), 0)
|
| 259 |
+
image.save(tiff_path)
|
| 260 |
+
tiff_paths.append(tiff_path)
|
| 261 |
+
|
| 262 |
+
zip_path = tmp_path / "slices.zip"
|
| 263 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 264 |
+
for tiff_path in tiff_paths:
|
| 265 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 266 |
+
|
| 267 |
+
gcode_path = generate_snake_path_gcode(
|
| 268 |
+
zip_path,
|
| 269 |
+
shape_name="odd_layer_y_contour",
|
| 270 |
+
pressure=25,
|
| 271 |
+
valve=7,
|
| 272 |
+
port=3,
|
| 273 |
+
fil_width=1.0,
|
| 274 |
+
layer_height=1.0,
|
| 275 |
+
all_g1=True,
|
| 276 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
|
| 277 |
+
active_contour_owner=1,
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
layer_one_prints = [
|
| 281 |
+
move
|
| 282 |
+
for move in _moves_with_colors(gcode_path.read_text())
|
| 283 |
+
if move["color"] == 255
|
| 284 |
+
and move["start"][2] == 1.0
|
| 285 |
+
and move["end"][2] == 1.0
|
| 286 |
+
]
|
| 287 |
+
contour_points = [
|
| 288 |
+
point
|
| 289 |
+
for move in layer_one_prints[2:]
|
| 290 |
+
for point in (move["start"], move["end"])
|
| 291 |
+
]
|
| 292 |
+
xs = [point[0] for point in contour_points]
|
| 293 |
+
ys = [point[1] for point in contour_points]
|
| 294 |
+
|
| 295 |
+
assert layer_one_prints[:2] == [
|
| 296 |
+
{"start": (1.0, 1.0, 1.0), "end": (3.0, 1.0, 1.0), "color": 255},
|
| 297 |
+
{"start": (2.0, 0.0, 1.0), "end": (1.0, 0.0, 1.0), "color": 255},
|
| 298 |
+
]
|
| 299 |
+
assert layer_one_prints[2]["start"] == (1.0, 0.0, 1.0)
|
| 300 |
+
assert layer_one_prints[2]["end"] == (1.0, -0.5, 1.0)
|
| 301 |
+
assert (min(xs), max(xs)) == (1.0, 3.0)
|
| 302 |
+
assert (min(ys), max(ys)) == (-0.5, 1.5)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> None:
|
| 306 |
+
tiff_paths = []
|
| 307 |
+
for index in range(4):
|
| 308 |
+
tiff_path = tmp_path / f"slice_{index:04d}.tif"
|
| 309 |
+
image = Image.new("L", (8, 8), 255)
|
| 310 |
+
image.putpixel((4, 2), 0)
|
| 311 |
+
image.putpixel((4, 3), 0)
|
| 312 |
+
image.putpixel((5, 4), 0)
|
| 313 |
+
image.save(tiff_path)
|
| 314 |
+
tiff_paths.append(tiff_path)
|
| 315 |
+
|
| 316 |
+
zip_path = tmp_path / "slices.zip"
|
| 317 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 318 |
+
for tiff_path in tiff_paths:
|
| 319 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 320 |
+
|
| 321 |
+
gcode_path = generate_snake_path_gcode(
|
| 322 |
+
zip_path,
|
| 323 |
+
shape_name="odd_layer_last_infill_anchor",
|
| 324 |
+
pressure=25,
|
| 325 |
+
valve=7,
|
| 326 |
+
port=3,
|
| 327 |
+
fil_width=1.0,
|
| 328 |
+
layer_height=1.0,
|
| 329 |
+
all_g1=True,
|
| 330 |
+
contour_tiff_sets=[{"owner_idx": 1, "tiff_paths": [str(p) for p in tiff_paths]}],
|
| 331 |
+
active_contour_owner=1,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
all_moves = _moves_with_colors(gcode_path.read_text())
|
| 335 |
+
for layer_z in (1.0, 3.0):
|
| 336 |
+
layer_moves = [
|
| 337 |
+
move
|
| 338 |
+
for move in all_moves
|
| 339 |
+
if move["start"][2] == layer_z
|
| 340 |
+
and move["end"][2] == layer_z
|
| 341 |
+
]
|
| 342 |
+
layer_prints = [
|
| 343 |
+
move
|
| 344 |
+
for move in layer_moves
|
| 345 |
+
if move["color"] == 255
|
| 346 |
+
]
|
| 347 |
+
|
| 348 |
+
assert layer_prints[:3] == [
|
| 349 |
+
{"start": (3.0, 2.0, layer_z), "end": (2.0, 2.0, layer_z), "color": 255},
|
| 350 |
+
{"start": (1.0, 1.0, layer_z), "end": (2.0, 1.0, layer_z), "color": 255},
|
| 351 |
+
{"start": (2.0, 0.0, layer_z), "end": (1.0, 0.0, layer_z), "color": 255},
|
| 352 |
+
]
|
| 353 |
+
contour_start = layer_prints[2]["end"]
|
| 354 |
+
assert layer_prints[3]["start"] == contour_start
|
| 355 |
+
assert layer_prints[-1]["end"] == contour_start
|
| 356 |
+
|
| 357 |
+
last_print_index = max(
|
| 358 |
+
idx for idx, move in enumerate(layer_moves) if move["color"] == 255
|
| 359 |
+
)
|
| 360 |
+
assert layer_moves[last_print_index + 1] == {
|
| 361 |
+
"start": contour_start,
|
| 362 |
+
"end": (0.0, 0.0, layer_z),
|
| 363 |
+
"color": 0,
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def test_contour_tracing_follows_default_raster_layer_flip(tmp_path) -> None:
|
| 368 |
+
tiff_paths = []
|
| 369 |
+
motion_img = np.zeros((7, 8), dtype=np.uint8)
|
| 370 |
+
motion_img[3, 4] = 255
|
| 371 |
+
motion_img[4, 4] = 255
|
| 372 |
+
motion_img[4, 5] = 255
|
| 373 |
+
for index in range(2):
|
| 374 |
+
tiff_path = tmp_path / f"l_shape_{index:04d}.tif"
|
| 375 |
+
image = Image.new("L", (8, 7), 255)
|
| 376 |
+
image.putpixel((4, 3), 0)
|
| 377 |
+
image.putpixel((4, 4), 0)
|
| 378 |
+
image.putpixel((5, 4), 0)
|
| 379 |
+
image.save(tiff_path)
|
| 380 |
+
tiff_paths.append(str(tiff_path))
|
| 381 |
+
|
| 382 |
+
contour_layers = _build_contour_layers(
|
| 383 |
+
[{"owner_idx": 1, "tiff_paths": tiff_paths}],
|
| 384 |
+
[motion_img, motion_img],
|
| 385 |
+
pixel_size=1.0,
|
| 386 |
+
invert=True,
|
| 387 |
+
off_color=0,
|
| 388 |
+
work_dir=tmp_path,
|
| 389 |
+
raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
assert contour_layers[0][0]["contours"][0] == [
|
| 393 |
+
(1.0, -0.5),
|
| 394 |
+
(2.0, -0.5),
|
| 395 |
+
(2.0, 0.5),
|
| 396 |
+
(3.0, 0.5),
|
| 397 |
+
(3.0, 1.5),
|
| 398 |
+
(1.0, 1.5),
|
| 399 |
+
(1.0, -0.5),
|
| 400 |
+
]
|
| 401 |
+
assert contour_layers[1][0]["contours"][0] == [
|
| 402 |
+
(1.0, -0.5),
|
| 403 |
+
(3.0, -0.5),
|
| 404 |
+
(3.0, 0.5),
|
| 405 |
+
(2.0, 0.5),
|
| 406 |
+
(2.0, 1.5),
|
| 407 |
+
(1.0, 1.5),
|
| 408 |
+
(1.0, -0.5),
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
|
| 412 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
| 413 |
tiff_path = tmp_path / "slice_0000.tif"
|
| 414 |
Image.new("L", (1, 1), 0).save(tiff_path)
|
|
|
|
| 564 |
assert max(x_positions) <= 3.0
|
| 565 |
assert min(y_positions) >= 0.0
|
| 566 |
assert max(y_positions) <= 2.0
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def test_contour_tracing_skips_inactive_nozzle_outline(tmp_path) -> None:
|
| 570 |
+
blank_tiff = tmp_path / "blank_slice_0000.tif"
|
| 571 |
+
Image.new("L", (1, 1), 255).save(blank_tiff)
|
| 572 |
+
blank_zip = tmp_path / "blank_slices.zip"
|
| 573 |
+
with zipfile.ZipFile(blank_zip, mode="w") as archive:
|
| 574 |
+
archive.write(blank_tiff, arcname=blank_tiff.name)
|
| 575 |
+
|
| 576 |
+
contour_tiff = tmp_path / "contour_slice_0000.tif"
|
| 577 |
+
Image.new("L", (1, 1), 0).save(contour_tiff)
|
| 578 |
+
contour_sources = [{"owner_idx": 1, "tiff_paths": [str(contour_tiff)]}]
|
| 579 |
+
|
| 580 |
+
active_path = generate_snake_path_gcode(
|
| 581 |
+
blank_zip,
|
| 582 |
+
shape_name="active_contour",
|
| 583 |
+
pressure=25,
|
| 584 |
+
valve=7,
|
| 585 |
+
port=3,
|
| 586 |
+
all_g1=True,
|
| 587 |
+
contour_tiff_sets=contour_sources,
|
| 588 |
+
active_contour_owner=1,
|
| 589 |
+
)
|
| 590 |
+
inactive_path = generate_snake_path_gcode(
|
| 591 |
+
blank_zip,
|
| 592 |
+
shape_name="inactive_contour",
|
| 593 |
+
pressure=25,
|
| 594 |
+
valve=7,
|
| 595 |
+
port=3,
|
| 596 |
+
all_g1=True,
|
| 597 |
+
contour_tiff_sets=contour_sources,
|
| 598 |
+
active_contour_owner=2,
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
active_text = active_path.read_text()
|
| 602 |
+
inactive_text = inactive_path.read_text()
|
| 603 |
+
|
| 604 |
+
assert _move_signature(active_text)
|
| 605 |
+
assert _move_signature(inactive_text) == []
|
| 606 |
+
assert any(
|
| 607 |
+
line.startswith("G1") and "; Color 255" in line
|
| 608 |
+
for line in active_text.splitlines()
|
| 609 |
+
)
|
| 610 |
+
assert not any("; Color 255" in line for line in inactive_text.splitlines())
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def test_inactive_contour_tracing_preserves_original_raster_moves(tmp_path) -> None:
|
| 614 |
+
tiff_paths = []
|
| 615 |
+
for index in range(2):
|
| 616 |
+
tiff_path = tmp_path / f"slice_{index:04d}.tif"
|
| 617 |
+
image = Image.new("L", (4, 3), 255)
|
| 618 |
+
image.putpixel((1, 1), 0)
|
| 619 |
+
image.putpixel((2, 1), 0)
|
| 620 |
+
image.save(tiff_path)
|
| 621 |
+
tiff_paths.append(tiff_path)
|
| 622 |
+
|
| 623 |
+
zip_path = tmp_path / "slices.zip"
|
| 624 |
+
with zipfile.ZipFile(zip_path, mode="w") as archive:
|
| 625 |
+
for tiff_path in tiff_paths:
|
| 626 |
+
archive.write(tiff_path, arcname=tiff_path.name)
|
| 627 |
+
|
| 628 |
+
original_path = generate_snake_path_gcode(
|
| 629 |
+
zip_path,
|
| 630 |
+
shape_name="original_raster",
|
| 631 |
+
pressure=25,
|
| 632 |
+
valve=7,
|
| 633 |
+
port=3,
|
| 634 |
+
all_g1=True,
|
| 635 |
+
)
|
| 636 |
+
inactive_path = generate_snake_path_gcode(
|
| 637 |
+
zip_path,
|
| 638 |
+
shape_name="inactive_contour_raster",
|
| 639 |
+
pressure=25,
|
| 640 |
+
valve=7,
|
| 641 |
+
port=3,
|
| 642 |
+
all_g1=True,
|
| 643 |
+
contour_tiff_sets=[{"owner_idx": 2, "tiff_paths": [str(p) for p in tiff_paths]}],
|
| 644 |
+
active_contour_owner=1,
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
assert _move_signature(inactive_path.read_text()) == _move_signature(
|
| 648 |
+
original_path.read_text()
|
| 649 |
+
)
|
tiff_to_gcode.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import os
|
| 4 |
import tempfile
|
| 5 |
import zipfile
|
|
|
|
| 6 |
from codecs import encode
|
| 7 |
from pathlib import Path
|
| 8 |
from textwrap import wrap
|
|
@@ -20,7 +21,6 @@ RASTER_PATTERN_CHOICES = (
|
|
| 20 |
RASTER_PATTERN_WOODPILE,
|
| 21 |
)
|
| 22 |
|
| 23 |
-
|
| 24 |
def _normalize_raster_pattern(pattern: str | None) -> str:
|
| 25 |
if pattern == RASTER_PATTERN_WOODPILE:
|
| 26 |
return RASTER_PATTERN_WOODPILE
|
|
@@ -197,6 +197,115 @@ def _center_on_canvas(
|
|
| 197 |
return out
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def _append_relative_move(
|
| 201 |
output_list: list[dict],
|
| 202 |
current_x: float,
|
|
@@ -217,6 +326,396 @@ def _append_relative_move(
|
|
| 217 |
return target_x, target_y
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
def _woodpile_layer_segments(
|
| 221 |
path_img: np.ndarray,
|
| 222 |
color_img: np.ndarray,
|
|
@@ -320,10 +819,13 @@ def _build_footprint_raster_gcode_list(
|
|
| 320 |
pixel_size: float,
|
| 321 |
layer_height: float,
|
| 322 |
raster_pattern: str,
|
|
|
|
|
|
|
| 323 |
) -> list[dict]:
|
| 324 |
gcode_list: list[dict] = []
|
| 325 |
current_x = 0.0
|
| 326 |
current_y = 0.0
|
|
|
|
| 327 |
|
| 328 |
for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
|
| 329 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
|
@@ -331,6 +833,23 @@ def _build_footprint_raster_gcode_list(
|
|
| 331 |
if not segments:
|
| 332 |
if layer_number > 0:
|
| 333 |
gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
continue
|
| 335 |
|
| 336 |
first_x, first_y = segments[0][0], segments[0][1]
|
|
@@ -372,6 +891,24 @@ def _build_footprint_raster_gcode_list(
|
|
| 372 |
color,
|
| 373 |
)
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
return gcode_list
|
| 376 |
|
| 377 |
|
|
@@ -388,6 +925,8 @@ def generate_snake_path_gcode(
|
|
| 388 |
all_g1: bool = False,
|
| 389 |
motion_tiffs: list[str] | None = None,
|
| 390 |
raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
|
|
|
|
|
|
|
| 391 |
) -> Path:
|
| 392 |
zip_path = Path(zip_path)
|
| 393 |
if not zip_path.exists():
|
|
@@ -435,6 +974,24 @@ def generate_snake_path_gcode(
|
|
| 435 |
path_ref_list = [im.copy() for im in shape_imgs]
|
| 436 |
color_ref_list = [im.copy() for im in shape_imgs]
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
|
| 439 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 440 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
|
@@ -446,9 +1003,13 @@ def generate_snake_path_gcode(
|
|
| 446 |
fil_width,
|
| 447 |
layer_height,
|
| 448 |
raster_pattern,
|
|
|
|
|
|
|
| 449 |
)
|
| 450 |
else:
|
| 451 |
gcode_list: list[dict] = []
|
|
|
|
|
|
|
| 452 |
dist_sign_long = 1
|
| 453 |
current_offsets_x: list[int] = []
|
| 454 |
use_flip_y = False
|
|
@@ -504,6 +1065,8 @@ def generate_snake_path_gcode(
|
|
| 504 |
gcode_list.append(
|
| 505 |
{"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
|
| 506 |
)
|
|
|
|
|
|
|
| 507 |
|
| 508 |
for row in current_image_ref:
|
| 509 |
if all(p == off_color for p in row):
|
|
@@ -521,6 +1084,10 @@ def generate_snake_path_gcode(
|
|
| 521 |
|
| 522 |
if layers == 0:
|
| 523 |
direction = -1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
direction = _gcode_layer(
|
| 525 |
ref_for_path,
|
| 526 |
current_image,
|
|
@@ -529,6 +1096,31 @@ def generate_snake_path_gcode(
|
|
| 529 |
direction,
|
| 530 |
layers,
|
| 531 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
|
| 534 |
pressure_cur = float(pressure)
|
|
|
|
| 3 |
import os
|
| 4 |
import tempfile
|
| 5 |
import zipfile
|
| 6 |
+
from collections import defaultdict
|
| 7 |
from codecs import encode
|
| 8 |
from pathlib import Path
|
| 9 |
from textwrap import wrap
|
|
|
|
| 21 |
RASTER_PATTERN_WOODPILE,
|
| 22 |
)
|
| 23 |
|
|
|
|
| 24 |
def _normalize_raster_pattern(pattern: str | None) -> str:
|
| 25 |
if pattern == RASTER_PATTERN_WOODPILE:
|
| 26 |
return RASTER_PATTERN_WOODPILE
|
|
|
|
| 197 |
return out
|
| 198 |
|
| 199 |
|
| 200 |
+
def _simplify_closed_contour(
|
| 201 |
+
points: list[tuple[int, int]],
|
| 202 |
+
) -> list[tuple[int, int]]:
|
| 203 |
+
if len(points) < 4:
|
| 204 |
+
return points
|
| 205 |
+
if points[0] != points[-1]:
|
| 206 |
+
points = [*points, points[0]]
|
| 207 |
+
|
| 208 |
+
ring = points[:-1]
|
| 209 |
+
simplified: list[tuple[int, int]] = []
|
| 210 |
+
for idx, point in enumerate(ring):
|
| 211 |
+
prev_point = ring[idx - 1]
|
| 212 |
+
next_point = ring[(idx + 1) % len(ring)]
|
| 213 |
+
dx1 = point[0] - prev_point[0]
|
| 214 |
+
dy1 = point[1] - prev_point[1]
|
| 215 |
+
dx2 = next_point[0] - point[0]
|
| 216 |
+
dy2 = next_point[1] - point[1]
|
| 217 |
+
if dx1 * dy2 == dy1 * dx2:
|
| 218 |
+
continue
|
| 219 |
+
simplified.append(point)
|
| 220 |
+
|
| 221 |
+
if len(simplified) < 3:
|
| 222 |
+
simplified = ring
|
| 223 |
+
simplified.append(simplified[0])
|
| 224 |
+
return simplified
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _contour_area2(points: list[tuple[float, float]]) -> float:
|
| 228 |
+
return sum(
|
| 229 |
+
x0 * y1 - x1 * y0
|
| 230 |
+
for (x0, y0), (x1, y1) in zip(points, points[1:])
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
|
| 235 |
+
xs = [point[0] for point in points]
|
| 236 |
+
ys = [point[1] for point in points]
|
| 237 |
+
return (-abs(_contour_area2(points)), min(ys), min(xs))
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _trace_mask_contours(
|
| 241 |
+
mask: np.ndarray,
|
| 242 |
+
pixel_size: float,
|
| 243 |
+
x_offset_px: float = 0.0,
|
| 244 |
+
y_offset_px: float = 0.0,
|
| 245 |
+
) -> list[list[tuple[float, float]]]:
|
| 246 |
+
if not np.any(mask):
|
| 247 |
+
return []
|
| 248 |
+
|
| 249 |
+
mask = mask.astype(bool)
|
| 250 |
+
segments: list[tuple[tuple[int, int], tuple[int, int]]] = []
|
| 251 |
+
height, width = mask.shape
|
| 252 |
+
|
| 253 |
+
def is_on(row: int, col: int) -> bool:
|
| 254 |
+
return 0 <= row < height and 0 <= col < width and bool(mask[row, col])
|
| 255 |
+
|
| 256 |
+
for row in range(height):
|
| 257 |
+
for col in range(width):
|
| 258 |
+
if not mask[row, col]:
|
| 259 |
+
continue
|
| 260 |
+
if not is_on(row - 1, col):
|
| 261 |
+
segments.append(((col, row), (col + 1, row)))
|
| 262 |
+
if not is_on(row, col + 1):
|
| 263 |
+
segments.append(((col + 1, row), (col + 1, row + 1)))
|
| 264 |
+
if not is_on(row + 1, col):
|
| 265 |
+
segments.append(((col + 1, row + 1), (col, row + 1)))
|
| 266 |
+
if not is_on(row, col - 1):
|
| 267 |
+
segments.append(((col, row + 1), (col, row)))
|
| 268 |
+
|
| 269 |
+
outgoing: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
|
| 270 |
+
for start, end in segments:
|
| 271 |
+
outgoing[start].append(end)
|
| 272 |
+
|
| 273 |
+
contours: list[list[tuple[float, float]]] = []
|
| 274 |
+
remaining = set(segments)
|
| 275 |
+
while remaining:
|
| 276 |
+
start, end = min(remaining)
|
| 277 |
+
remaining.remove((start, end))
|
| 278 |
+
contour = [start, end]
|
| 279 |
+
current = end
|
| 280 |
+
|
| 281 |
+
while current != start:
|
| 282 |
+
next_point = next(
|
| 283 |
+
(
|
| 284 |
+
candidate
|
| 285 |
+
for candidate in outgoing.get(current, [])
|
| 286 |
+
if (current, candidate) in remaining
|
| 287 |
+
),
|
| 288 |
+
None,
|
| 289 |
+
)
|
| 290 |
+
if next_point is None:
|
| 291 |
+
break
|
| 292 |
+
remaining.remove((current, next_point))
|
| 293 |
+
current = next_point
|
| 294 |
+
contour.append(current)
|
| 295 |
+
|
| 296 |
+
if len(contour) > 3 and contour[-1] == contour[0]:
|
| 297 |
+
simplified = _simplify_closed_contour(contour)
|
| 298 |
+
contours.append(
|
| 299 |
+
[
|
| 300 |
+
((x + x_offset_px) * pixel_size, (y + y_offset_px) * pixel_size)
|
| 301 |
+
for x, y in simplified
|
| 302 |
+
]
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
contours.sort(key=_contour_sort_key)
|
| 306 |
+
return contours
|
| 307 |
+
|
| 308 |
+
|
| 309 |
def _append_relative_move(
|
| 310 |
output_list: list[dict],
|
| 311 |
current_x: float,
|
|
|
|
| 326 |
return target_x, target_y
|
| 327 |
|
| 328 |
|
| 329 |
+
def _point_distance_sq(
|
| 330 |
+
ax: float,
|
| 331 |
+
ay: float,
|
| 332 |
+
bx: float,
|
| 333 |
+
by: float,
|
| 334 |
+
) -> float:
|
| 335 |
+
return (ax - bx) ** 2 + (ay - by) ** 2
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _closest_point_on_segment(
|
| 339 |
+
px: float,
|
| 340 |
+
py: float,
|
| 341 |
+
ax: float,
|
| 342 |
+
ay: float,
|
| 343 |
+
bx: float,
|
| 344 |
+
by: float,
|
| 345 |
+
) -> tuple[float, float, float]:
|
| 346 |
+
dx = bx - ax
|
| 347 |
+
dy = by - ay
|
| 348 |
+
length_sq = dx * dx + dy * dy
|
| 349 |
+
if length_sq == 0:
|
| 350 |
+
return ax, ay, 0.0
|
| 351 |
+
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
| 352 |
+
t = max(0.0, min(1.0, t))
|
| 353 |
+
return ax + t * dx, ay + t * dy, t
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _rotate_closed_contour_to_nearest_border(
|
| 357 |
+
contour: list[tuple[float, float]],
|
| 358 |
+
current_x: float,
|
| 359 |
+
current_y: float,
|
| 360 |
+
approach_dx: float = 0.0,
|
| 361 |
+
approach_dy: float = 0.0,
|
| 362 |
+
) -> list[tuple[float, float]]:
|
| 363 |
+
if len(contour) < 3:
|
| 364 |
+
return contour
|
| 365 |
+
|
| 366 |
+
ring = contour[:-1] if contour[0] == contour[-1] else contour
|
| 367 |
+
if len(ring) < 2:
|
| 368 |
+
return contour
|
| 369 |
+
|
| 370 |
+
best_idx = 0
|
| 371 |
+
best_t = 0.0
|
| 372 |
+
best_point = ring[0]
|
| 373 |
+
best_dist = float("inf")
|
| 374 |
+
for idx, (ax, ay) in enumerate(ring):
|
| 375 |
+
bx, by = ring[(idx + 1) % len(ring)]
|
| 376 |
+
point_x, point_y, t = _closest_point_on_segment(
|
| 377 |
+
current_x,
|
| 378 |
+
current_y,
|
| 379 |
+
ax,
|
| 380 |
+
ay,
|
| 381 |
+
bx,
|
| 382 |
+
by,
|
| 383 |
+
)
|
| 384 |
+
distance = _point_distance_sq(current_x, current_y, point_x, point_y)
|
| 385 |
+
if distance < best_dist:
|
| 386 |
+
best_dist = distance
|
| 387 |
+
best_idx = idx
|
| 388 |
+
best_t = t
|
| 389 |
+
best_point = (point_x, point_y)
|
| 390 |
+
|
| 391 |
+
eps = 1e-9
|
| 392 |
+
def choose_direction(
|
| 393 |
+
forward: list[tuple[float, float]],
|
| 394 |
+
reverse: list[tuple[float, float]],
|
| 395 |
+
) -> list[tuple[float, float]]:
|
| 396 |
+
if (
|
| 397 |
+
len(forward) < 2
|
| 398 |
+
or len(reverse) < 2
|
| 399 |
+
or (approach_dx == 0 and approach_dy == 0)
|
| 400 |
+
):
|
| 401 |
+
return forward
|
| 402 |
+
|
| 403 |
+
def score(candidate: list[tuple[float, float]]) -> float:
|
| 404 |
+
tx = candidate[1][0] - candidate[0][0]
|
| 405 |
+
ty = candidate[1][1] - candidate[0][1]
|
| 406 |
+
# Positive when the shape interior, opposite the approach vector,
|
| 407 |
+
# is to the left of the contour's first move.
|
| 408 |
+
return (ty * approach_dx) - (tx * approach_dy)
|
| 409 |
+
|
| 410 |
+
return reverse if score(reverse) > score(forward) else forward
|
| 411 |
+
|
| 412 |
+
if best_t <= eps:
|
| 413 |
+
forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
|
| 414 |
+
reverse_ring = list(reversed(ring))
|
| 415 |
+
reverse_idx = reverse_ring.index(ring[best_idx])
|
| 416 |
+
reverse = (
|
| 417 |
+
reverse_ring[reverse_idx:]
|
| 418 |
+
+ reverse_ring[:reverse_idx]
|
| 419 |
+
+ [reverse_ring[reverse_idx]]
|
| 420 |
+
)
|
| 421 |
+
return choose_direction(forward, reverse)
|
| 422 |
+
|
| 423 |
+
next_idx = (best_idx + 1) % len(ring)
|
| 424 |
+
if best_t >= 1.0 - eps:
|
| 425 |
+
forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
|
| 426 |
+
reverse_ring = list(reversed(ring))
|
| 427 |
+
reverse_idx = reverse_ring.index(ring[next_idx])
|
| 428 |
+
reverse = (
|
| 429 |
+
reverse_ring[reverse_idx:]
|
| 430 |
+
+ reverse_ring[:reverse_idx]
|
| 431 |
+
+ [reverse_ring[reverse_idx]]
|
| 432 |
+
)
|
| 433 |
+
return choose_direction(forward, reverse)
|
| 434 |
+
|
| 435 |
+
forward = [best_point]
|
| 436 |
+
for step in range(1, len(ring) + 1):
|
| 437 |
+
forward.append(ring[(best_idx + step) % len(ring)])
|
| 438 |
+
forward.append(best_point)
|
| 439 |
+
|
| 440 |
+
reverse = [best_point]
|
| 441 |
+
for step in range(0, len(ring)):
|
| 442 |
+
reverse.append(ring[(best_idx - step) % len(ring)])
|
| 443 |
+
reverse.append(best_point)
|
| 444 |
+
return choose_direction(forward, reverse)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
|
| 448 |
+
x = y = 0.0
|
| 449 |
+
last_x = last_y = 0.0
|
| 450 |
+
last_dx = last_dy = 0.0
|
| 451 |
+
for move in output_list:
|
| 452 |
+
dx = float(move.get("X", 0.0))
|
| 453 |
+
dy = float(move.get("Y", 0.0))
|
| 454 |
+
x += dx
|
| 455 |
+
y += dy
|
| 456 |
+
if move.get("Color") == 255 and "Z" not in move:
|
| 457 |
+
last_x = x
|
| 458 |
+
last_y = y
|
| 459 |
+
last_dx = dx
|
| 460 |
+
last_dy = dy
|
| 461 |
+
return last_x, last_y, last_dx, last_dy
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def _rewind_trailing_travel(
|
| 465 |
+
output_list: list[dict],
|
| 466 |
+
current_x: float,
|
| 467 |
+
current_y: float,
|
| 468 |
+
) -> tuple[float, float]:
|
| 469 |
+
if not output_list:
|
| 470 |
+
return current_x, current_y
|
| 471 |
+
|
| 472 |
+
last_move = output_list[-1]
|
| 473 |
+
if last_move.get("Color") != 0 or "Z" in last_move:
|
| 474 |
+
return current_x, current_y
|
| 475 |
+
|
| 476 |
+
has_layer_print = any(
|
| 477 |
+
move.get("Color") == 255 and "Z" not in move
|
| 478 |
+
for move in reversed(output_list[:-1])
|
| 479 |
+
)
|
| 480 |
+
if not has_layer_print:
|
| 481 |
+
return current_x, current_y
|
| 482 |
+
|
| 483 |
+
output_list.pop()
|
| 484 |
+
return (
|
| 485 |
+
current_x - float(last_move.get("X", 0.0)),
|
| 486 |
+
current_y - float(last_move.get("Y", 0.0)),
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _contour_source_paths(source: dict, extract_root: Path, source_pos: int) -> list[Path]:
|
| 491 |
+
paths = source.get("tiff_paths") or source.get("paths") or []
|
| 492 |
+
if paths:
|
| 493 |
+
return sorted((Path(path) for path in paths), key=lambda path: _sort_key(path.name))
|
| 494 |
+
|
| 495 |
+
zip_path = source.get("zip_path")
|
| 496 |
+
if not zip_path:
|
| 497 |
+
return []
|
| 498 |
+
extract_dir = extract_root / f"source_{source_pos:03d}"
|
| 499 |
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
| 500 |
+
return _extract_zip_tiffs(Path(zip_path), extract_dir)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _active_pixel_bounds(mask: np.ndarray) -> tuple[int, int, int, int] | None:
|
| 504 |
+
rows, cols = np.where(mask)
|
| 505 |
+
if len(rows) == 0:
|
| 506 |
+
return None
|
| 507 |
+
return int(rows.min()), int(rows.max()), int(cols.min()), int(cols.max())
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def _first_raster_row_start(mask: np.ndarray) -> tuple[int, int] | None:
|
| 511 |
+
rows = np.where(mask.any(axis=1))[0]
|
| 512 |
+
if len(rows) == 0:
|
| 513 |
+
return None
|
| 514 |
+
row = int(rows[0])
|
| 515 |
+
cols = np.where(mask[row])[0]
|
| 516 |
+
if len(cols) == 0:
|
| 517 |
+
return None
|
| 518 |
+
return row, int(cols[0])
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _contour_pixel_offsets(
|
| 522 |
+
raster_pattern: str,
|
| 523 |
+
motion_mask: np.ndarray,
|
| 524 |
+
contour_mask: np.ndarray,
|
| 525 |
+
) -> tuple[float, float]:
|
| 526 |
+
if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
|
| 527 |
+
first_row_start = _first_raster_row_start(motion_mask)
|
| 528 |
+
if first_row_start is None:
|
| 529 |
+
first_row_start = _first_raster_row_start(contour_mask)
|
| 530 |
+
if first_row_start is None:
|
| 531 |
+
return 0.0, 0.0
|
| 532 |
+
first_row, first_col = first_row_start
|
| 533 |
+
# The legacy X-raster infill path anchors every row to the first
|
| 534 |
+
# rastered row's starting pixel. Wider lower rows can extend left of
|
| 535 |
+
# that point, so using the layer-wide min column shifts pyramid-like
|
| 536 |
+
# contours away from the infill.
|
| 537 |
+
return 1.0 - first_col, -0.5 - first_row
|
| 538 |
+
return 0.0, 0.0
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _build_contour_layers(
|
| 542 |
+
contour_tiff_sets: list[dict] | None,
|
| 543 |
+
path_ref_list: list[np.ndarray],
|
| 544 |
+
pixel_size: float,
|
| 545 |
+
invert: bool,
|
| 546 |
+
off_color: int,
|
| 547 |
+
work_dir: Path,
|
| 548 |
+
raster_pattern: str,
|
| 549 |
+
layer_start_directions: list[int] | None = None,
|
| 550 |
+
) -> list[list[dict]]:
|
| 551 |
+
contour_layers: list[list[dict]] = [[] for _ in path_ref_list]
|
| 552 |
+
if not contour_tiff_sets:
|
| 553 |
+
return contour_layers
|
| 554 |
+
|
| 555 |
+
extract_root = work_dir / "contour_sources"
|
| 556 |
+
for source_pos, source in enumerate(contour_tiff_sets):
|
| 557 |
+
try:
|
| 558 |
+
owner_idx = int(source.get("owner_idx", source.get("idx", source_pos + 1)))
|
| 559 |
+
except (TypeError, ValueError):
|
| 560 |
+
owner_idx = source_pos + 1
|
| 561 |
+
|
| 562 |
+
source_paths = _contour_source_paths(source, extract_root, source_pos)
|
| 563 |
+
for layer_number, path_img in enumerate(path_ref_list):
|
| 564 |
+
if layer_number >= len(source_paths):
|
| 565 |
+
continue
|
| 566 |
+
canvas_h, canvas_w = path_img.shape[:2]
|
| 567 |
+
contour_img = _load_grayscale(source_paths[layer_number], invert=invert)
|
| 568 |
+
if contour_img.shape[:2] != (canvas_h, canvas_w):
|
| 569 |
+
contour_img = _center_on_canvas(
|
| 570 |
+
contour_img,
|
| 571 |
+
canvas_h,
|
| 572 |
+
canvas_w,
|
| 573 |
+
fill=off_color,
|
| 574 |
+
)
|
| 575 |
+
contour_path_img = path_img
|
| 576 |
+
if raster_pattern == RASTER_PATTERN_SAME_DIRECTION and layer_number % 2 == 1:
|
| 577 |
+
contour_path_img = np.flipud(contour_path_img)
|
| 578 |
+
contour_img = np.flipud(contour_img)
|
| 579 |
+
if (
|
| 580 |
+
raster_pattern == RASTER_PATTERN_SAME_DIRECTION
|
| 581 |
+
and layer_start_directions is not None
|
| 582 |
+
and layer_number < len(layer_start_directions)
|
| 583 |
+
and layer_start_directions[layer_number] > 0
|
| 584 |
+
):
|
| 585 |
+
contour_path_img = np.fliplr(contour_path_img)
|
| 586 |
+
contour_img = np.fliplr(contour_img)
|
| 587 |
+
|
| 588 |
+
contour_mask = contour_img > off_color
|
| 589 |
+
x_offset_px, y_offset_px = _contour_pixel_offsets(
|
| 590 |
+
raster_pattern,
|
| 591 |
+
contour_path_img > off_color,
|
| 592 |
+
contour_mask,
|
| 593 |
+
)
|
| 594 |
+
contours = _trace_mask_contours(
|
| 595 |
+
contour_mask,
|
| 596 |
+
pixel_size,
|
| 597 |
+
x_offset_px=x_offset_px,
|
| 598 |
+
y_offset_px=y_offset_px,
|
| 599 |
+
)
|
| 600 |
+
if contours:
|
| 601 |
+
contour_layers[layer_number].append(
|
| 602 |
+
{"owner_idx": owner_idx, "contours": contours}
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
return contour_layers
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def _same_direction_layer_start_directions(
|
| 609 |
+
path_ref_list: list[np.ndarray],
|
| 610 |
+
off_color: int,
|
| 611 |
+
) -> list[int]:
|
| 612 |
+
directions: list[int] = []
|
| 613 |
+
direction = -1
|
| 614 |
+
for path_img in path_ref_list:
|
| 615 |
+
directions.append(direction)
|
| 616 |
+
nonblank_rows = int(np.count_nonzero((path_img > off_color).any(axis=1)))
|
| 617 |
+
if nonblank_rows % 2 == 1:
|
| 618 |
+
direction *= -1
|
| 619 |
+
return directions
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
def _append_layer_contours(
|
| 623 |
+
output_list: list[dict],
|
| 624 |
+
current_x: float,
|
| 625 |
+
current_y: float,
|
| 626 |
+
contour_layers: list[list[dict]],
|
| 627 |
+
layer_number: int,
|
| 628 |
+
active_owner_idx: int | None,
|
| 629 |
+
origin_x: float = 0.0,
|
| 630 |
+
origin_y: float = 0.0,
|
| 631 |
+
x_scale: float = 1.0,
|
| 632 |
+
y_scale: float = 1.0,
|
| 633 |
+
) -> tuple[float, float]:
|
| 634 |
+
if layer_number >= len(contour_layers):
|
| 635 |
+
return current_x, current_y
|
| 636 |
+
|
| 637 |
+
active_sources = [
|
| 638 |
+
source
|
| 639 |
+
for source in contour_layers[layer_number]
|
| 640 |
+
if source.get("owner_idx") == active_owner_idx
|
| 641 |
+
and any(len(contour) >= 2 for contour in source.get("contours", []))
|
| 642 |
+
]
|
| 643 |
+
if not active_sources:
|
| 644 |
+
return current_x, current_y
|
| 645 |
+
|
| 646 |
+
current_x, current_y = _rewind_trailing_travel(
|
| 647 |
+
output_list,
|
| 648 |
+
current_x,
|
| 649 |
+
current_y,
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
first_start_x: float | None = None
|
| 653 |
+
first_start_y: float | None = None
|
| 654 |
+
use_infill_reference = True
|
| 655 |
+
|
| 656 |
+
for source in active_sources:
|
| 657 |
+
color = 255
|
| 658 |
+
for contour in source.get("contours", []):
|
| 659 |
+
if len(contour) < 2:
|
| 660 |
+
continue
|
| 661 |
+
contour = [
|
| 662 |
+
(origin_x + (x_scale * x), origin_y + (y_scale * y))
|
| 663 |
+
for x, y in contour
|
| 664 |
+
]
|
| 665 |
+
if use_infill_reference:
|
| 666 |
+
nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
|
| 667 |
+
output_list
|
| 668 |
+
)
|
| 669 |
+
else:
|
| 670 |
+
nearest_x, nearest_y = current_x, current_y
|
| 671 |
+
approach_dx, approach_dy = 0.0, 0.0
|
| 672 |
+
contour = _rotate_closed_contour_to_nearest_border(
|
| 673 |
+
contour,
|
| 674 |
+
nearest_x,
|
| 675 |
+
nearest_y,
|
| 676 |
+
approach_dx,
|
| 677 |
+
approach_dy,
|
| 678 |
+
)
|
| 679 |
+
if contour[0] != contour[-1]:
|
| 680 |
+
contour = [*contour, contour[0]]
|
| 681 |
+
start_x, start_y = contour[0]
|
| 682 |
+
if first_start_x is None:
|
| 683 |
+
first_start_x, first_start_y = start_x, start_y
|
| 684 |
+
use_infill_reference = False
|
| 685 |
+
current_x, current_y = _append_relative_move(
|
| 686 |
+
output_list,
|
| 687 |
+
current_x,
|
| 688 |
+
current_y,
|
| 689 |
+
start_x,
|
| 690 |
+
start_y,
|
| 691 |
+
0,
|
| 692 |
+
)
|
| 693 |
+
for target_x, target_y in contour[1:]:
|
| 694 |
+
current_x, current_y = _append_relative_move(
|
| 695 |
+
output_list,
|
| 696 |
+
current_x,
|
| 697 |
+
current_y,
|
| 698 |
+
target_x,
|
| 699 |
+
target_y,
|
| 700 |
+
color,
|
| 701 |
+
)
|
| 702 |
+
|
| 703 |
+
if first_start_x is not None and (
|
| 704 |
+
abs(current_x - first_start_x) > 1e-9
|
| 705 |
+
or abs(current_y - (first_start_y or 0.0)) > 1e-9
|
| 706 |
+
):
|
| 707 |
+
current_x, current_y = _append_relative_move(
|
| 708 |
+
output_list,
|
| 709 |
+
current_x,
|
| 710 |
+
current_y,
|
| 711 |
+
first_start_x,
|
| 712 |
+
first_start_y,
|
| 713 |
+
255,
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
return current_x, current_y
|
| 717 |
+
|
| 718 |
+
|
| 719 |
def _woodpile_layer_segments(
|
| 720 |
path_img: np.ndarray,
|
| 721 |
color_img: np.ndarray,
|
|
|
|
| 819 |
pixel_size: float,
|
| 820 |
layer_height: float,
|
| 821 |
raster_pattern: str,
|
| 822 |
+
contour_layers: list[list[dict]] | None = None,
|
| 823 |
+
active_contour_owner: int | None = None,
|
| 824 |
) -> list[dict]:
|
| 825 |
gcode_list: list[dict] = []
|
| 826 |
current_x = 0.0
|
| 827 |
current_y = 0.0
|
| 828 |
+
contour_layers = contour_layers or []
|
| 829 |
|
| 830 |
for layer_number, (path_img, color_img) in enumerate(zip(path_ref_list, color_ref_list)):
|
| 831 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
|
|
|
| 833 |
if not segments:
|
| 834 |
if layer_number > 0:
|
| 835 |
gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
|
| 836 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 837 |
+
current_x, current_y = _append_layer_contours(
|
| 838 |
+
gcode_list,
|
| 839 |
+
current_x,
|
| 840 |
+
current_y,
|
| 841 |
+
contour_layers,
|
| 842 |
+
layer_number,
|
| 843 |
+
active_contour_owner,
|
| 844 |
+
)
|
| 845 |
+
current_x, current_y = _append_relative_move(
|
| 846 |
+
gcode_list,
|
| 847 |
+
current_x,
|
| 848 |
+
current_y,
|
| 849 |
+
layer_end_x,
|
| 850 |
+
layer_end_y,
|
| 851 |
+
0,
|
| 852 |
+
)
|
| 853 |
continue
|
| 854 |
|
| 855 |
first_x, first_y = segments[0][0], segments[0][1]
|
|
|
|
| 891 |
color,
|
| 892 |
)
|
| 893 |
|
| 894 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 895 |
+
current_x, current_y = _append_layer_contours(
|
| 896 |
+
gcode_list,
|
| 897 |
+
current_x,
|
| 898 |
+
current_y,
|
| 899 |
+
contour_layers,
|
| 900 |
+
layer_number,
|
| 901 |
+
active_contour_owner,
|
| 902 |
+
)
|
| 903 |
+
current_x, current_y = _append_relative_move(
|
| 904 |
+
gcode_list,
|
| 905 |
+
current_x,
|
| 906 |
+
current_y,
|
| 907 |
+
layer_end_x,
|
| 908 |
+
layer_end_y,
|
| 909 |
+
0,
|
| 910 |
+
)
|
| 911 |
+
|
| 912 |
return gcode_list
|
| 913 |
|
| 914 |
|
|
|
|
| 925 |
all_g1: bool = False,
|
| 926 |
motion_tiffs: list[str] | None = None,
|
| 927 |
raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
|
| 928 |
+
contour_tiff_sets: list[dict] | None = None,
|
| 929 |
+
active_contour_owner: int | None = None,
|
| 930 |
) -> Path:
|
| 931 |
zip_path = Path(zip_path)
|
| 932 |
if not zip_path.exists():
|
|
|
|
| 974 |
path_ref_list = [im.copy() for im in shape_imgs]
|
| 975 |
color_ref_list = [im.copy() for im in shape_imgs]
|
| 976 |
|
| 977 |
+
layer_start_directions = None
|
| 978 |
+
if raster_pattern == RASTER_PATTERN_SAME_DIRECTION:
|
| 979 |
+
layer_start_directions = _same_direction_layer_start_directions(
|
| 980 |
+
path_ref_list,
|
| 981 |
+
off_color,
|
| 982 |
+
)
|
| 983 |
+
|
| 984 |
+
contour_layers = _build_contour_layers(
|
| 985 |
+
contour_tiff_sets,
|
| 986 |
+
path_ref_list,
|
| 987 |
+
fil_width,
|
| 988 |
+
invert,
|
| 989 |
+
off_color,
|
| 990 |
+
work_dir,
|
| 991 |
+
raster_pattern,
|
| 992 |
+
layer_start_directions,
|
| 993 |
+
)
|
| 994 |
+
|
| 995 |
setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
|
| 996 |
pressure_on_lines = [_toggle_cmd(com_port, start=True)]
|
| 997 |
pressure_off_lines = [_toggle_cmd(com_port, start=False)]
|
|
|
|
| 1003 |
fil_width,
|
| 1004 |
layer_height,
|
| 1005 |
raster_pattern,
|
| 1006 |
+
contour_layers,
|
| 1007 |
+
active_contour_owner,
|
| 1008 |
)
|
| 1009 |
else:
|
| 1010 |
gcode_list: list[dict] = []
|
| 1011 |
+
current_x = 0.0
|
| 1012 |
+
current_y = 0.0
|
| 1013 |
dist_sign_long = 1
|
| 1014 |
current_offsets_x: list[int] = []
|
| 1015 |
use_flip_y = False
|
|
|
|
| 1065 |
gcode_list.append(
|
| 1066 |
{"X": shift_y, "Y": shift_x, "Z": layer_height, "Color": 0}
|
| 1067 |
)
|
| 1068 |
+
current_x += shift_y
|
| 1069 |
+
current_y += shift_x
|
| 1070 |
|
| 1071 |
for row in current_image_ref:
|
| 1072 |
if all(p == off_color for p in row):
|
|
|
|
| 1084 |
|
| 1085 |
if layers == 0:
|
| 1086 |
direction = -1
|
| 1087 |
+
layer_start = len(gcode_list)
|
| 1088 |
+
layer_origin_x, layer_origin_y = current_x, current_y
|
| 1089 |
+
layer_x_scale = 1.0 if direction < 0 else -1.0
|
| 1090 |
+
layer_y_scale = -1.0 if layers % 2 == 1 else 1.0
|
| 1091 |
direction = _gcode_layer(
|
| 1092 |
ref_for_path,
|
| 1093 |
current_image,
|
|
|
|
| 1096 |
direction,
|
| 1097 |
layers,
|
| 1098 |
)
|
| 1099 |
+
for move in gcode_list[layer_start:]:
|
| 1100 |
+
current_x += float(move.get("X", 0.0))
|
| 1101 |
+
current_y += float(move.get("Y", 0.0))
|
| 1102 |
+
|
| 1103 |
+
layer_end_x, layer_end_y = current_x, current_y
|
| 1104 |
+
current_x, current_y = _append_layer_contours(
|
| 1105 |
+
gcode_list,
|
| 1106 |
+
current_x,
|
| 1107 |
+
current_y,
|
| 1108 |
+
contour_layers,
|
| 1109 |
+
layers,
|
| 1110 |
+
active_contour_owner,
|
| 1111 |
+
layer_origin_x,
|
| 1112 |
+
layer_origin_y,
|
| 1113 |
+
layer_x_scale,
|
| 1114 |
+
layer_y_scale,
|
| 1115 |
+
)
|
| 1116 |
+
current_x, current_y = _append_relative_move(
|
| 1117 |
+
gcode_list,
|
| 1118 |
+
current_x,
|
| 1119 |
+
current_y,
|
| 1120 |
+
layer_end_x,
|
| 1121 |
+
layer_end_y,
|
| 1122 |
+
off_color,
|
| 1123 |
+
)
|
| 1124 |
|
| 1125 |
gcode_path = work_dir / f"{shape_name}_SnakePath_gcode.txt"
|
| 1126 |
pressure_cur = float(pressure)
|