Spaces:
Running
Improve parallel printing: shared contours, exact auto-align, infill %
Browse files- Contour tracing under reference motion: every head traces every traced
shape's contour on one identical path, valve open only on its own outline
- Auto Align Split Parts computes exact per-connection gaps from PathOrigin
anchors written into each G-code file (works for all raster patterns,
reference motion, and overlapping-layer splits; sets grid dims from the
split); grid controls listen on user input only so the first click sticks
- Split pieces raster on the parent's scan grid (seams keep an exact one-fil
pitch); cells are equal whole-fil multiples with blank boundary padding,
and reference alignment snaps to the fil grid so nozzle spacing is uniform
- New per-shape Infill % column: evenly-distributed lines (rings for
spirals) skip dispensing while the motion path stays identical
- Remove the redundant Custom Spacing mode; Advanced Grid Spacing table is
always visible and its checkbox controls only whether it is used
- Fix G-code corruption on curved shapes: coordinates are written in fixed
point (never scientific notation) and the viewer parses exponents safely
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +6 -4
- app.py +198 -355
- gcode_viewer.py +14 -0
- stl_slicer.py +12 -0
- tests/test_nozzle_spacing.py +246 -220
- tests/test_vector_gcode.py +265 -4
- vector_gcode.py +38 -1
- vector_toolpath.py +331 -51
|
@@ -47,11 +47,12 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 47 |
- Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
|
| 48 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 49 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 50 |
-
- Converts sliced layers into G-code files with pressure, valve, nozzle, and
|
|
|
|
| 51 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 52 |
- Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 53 |
- Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
|
| 54 |
-
- Calculates X/Y nozzle spacing from an
|
| 55 |
- Previews selected generated G-code inline
|
| 56 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
|
| 57 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
|
@@ -91,9 +92,10 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
|
|
| 91 |
- 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.
|
| 92 |
- Pressure increases by `0.1` psi per layer by default.
|
| 93 |
- **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.
|
| 94 |
-
- **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced.
|
|
|
|
| 95 |
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
|
| 96 |
-
- **Auto Align Split Parts**: in Nozzle Spacing,
|
| 97 |
- **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
|
| 98 |
|
| 99 |
### Print vs Travel Classification
|
|
|
|
| 47 |
- Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
|
| 48 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 49 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 50 |
+
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 51 |
+
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
|
| 52 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 53 |
- Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
|
| 54 |
- Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
|
| 55 |
+
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 56 |
- Previews selected generated G-code inline
|
| 57 |
- Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
|
| 58 |
- Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
|
|
|
|
| 92 |
- 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.
|
| 93 |
- Pressure increases by `0.1` psi per layer by default.
|
| 94 |
- **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.
|
| 95 |
+
- **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
|
| 96 |
+
- Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
|
| 97 |
- **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
|
| 98 |
+
- **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
|
| 99 |
- **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
|
| 100 |
|
| 101 |
### Print vs Travel Classification
|
|
@@ -55,8 +55,6 @@ SCALE_MODE_TARGET_DIMENSIONS = "Independent X/Y/Z"
|
|
| 55 |
SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
|
| 56 |
TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
|
| 57 |
FRONT_CAMERA = (90, 80, None)
|
| 58 |
-
NOZZLE_LAYOUT_GRID = "Grid Layout"
|
| 59 |
-
NOZZLE_LAYOUT_PAIR_TABLE = "Custom Spacing"
|
| 60 |
NOZZLE_LAYOUT_PRESETS = [
|
| 61 |
"Custom",
|
| 62 |
"One row",
|
|
@@ -68,8 +66,6 @@ NOZZLE_LAYOUT_PRESETS = [
|
|
| 68 |
"2 x 5",
|
| 69 |
"5 x 2",
|
| 70 |
]
|
| 71 |
-
AUTO_ALIGN_X_RASTER_OFFSETS = (-3.2, -0.8)
|
| 72 |
-
AUTO_ALIGN_Y_RASTER_OFFSETS = (-0.8, -3.2)
|
| 73 |
APP_CSS = """
|
| 74 |
.gradio-container {
|
| 75 |
font-size: 90%;
|
|
@@ -1161,96 +1157,6 @@ PARALLEL_COLOR_CHOICES = [
|
|
| 1161 |
DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
|
| 1162 |
|
| 1163 |
|
| 1164 |
-
def _resolve_nozzle_layout(
|
| 1165 |
-
parts: list[dict],
|
| 1166 |
-
same_spacing: bool | None,
|
| 1167 |
-
part_gap_12_x: float | None,
|
| 1168 |
-
part_gap_12_y: float | None,
|
| 1169 |
-
part_gap_23_x: float | None,
|
| 1170 |
-
part_gap_23_y: float | None,
|
| 1171 |
-
*extra_pair_gaps: float | None,
|
| 1172 |
-
) -> tuple[dict[int, tuple[float, float]], list[dict]]:
|
| 1173 |
-
offsets: dict[int, tuple[float, float]] = {}
|
| 1174 |
-
spacings: list[dict] = []
|
| 1175 |
-
if not parts:
|
| 1176 |
-
return offsets, spacings
|
| 1177 |
-
|
| 1178 |
-
grouped: dict[int, list[dict]] = {}
|
| 1179 |
-
for part in parts:
|
| 1180 |
-
grouped.setdefault(_record_nozzle_number(part, int(part.get("idx", 1) or 1)), []).append(part)
|
| 1181 |
-
ordered_nozzles = sorted(grouped)
|
| 1182 |
-
offsets[ordered_nozzles[0]] = (0.0, 0.0)
|
| 1183 |
-
|
| 1184 |
-
def nozzle_bounds(nozzle: int) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
| 1185 |
-
mins: list[tuple[float, float, float]] = []
|
| 1186 |
-
maxs: list[tuple[float, float, float]] = []
|
| 1187 |
-
for part in grouped[nozzle]:
|
| 1188 |
-
part_min, part_max = part["parsed"]["bounds"]
|
| 1189 |
-
mins.append(part_min)
|
| 1190 |
-
maxs.append(part_max)
|
| 1191 |
-
return (
|
| 1192 |
-
tuple(min(values) for values in zip(*mins)),
|
| 1193 |
-
tuple(max(values) for values in zip(*maxs)),
|
| 1194 |
-
)
|
| 1195 |
-
|
| 1196 |
-
first_spacing = (float(part_gap_12_x or 0.0), float(part_gap_12_y or 0.0))
|
| 1197 |
-
raw_pairs: list[tuple[float, float]] = [
|
| 1198 |
-
first_spacing,
|
| 1199 |
-
(float(part_gap_23_x or 0.0), float(part_gap_23_y or 0.0)),
|
| 1200 |
-
]
|
| 1201 |
-
for index in range(0, len(extra_pair_gaps), 2):
|
| 1202 |
-
raw_pairs.append((
|
| 1203 |
-
float(extra_pair_gaps[index] or 0.0),
|
| 1204 |
-
float(extra_pair_gaps[index + 1] or 0.0) if index + 1 < len(extra_pair_gaps) else 0.0,
|
| 1205 |
-
))
|
| 1206 |
-
max_pair_count = max(0, len(ordered_nozzles) - 1)
|
| 1207 |
-
while len(raw_pairs) < max_pair_count:
|
| 1208 |
-
raw_pairs.append(first_spacing)
|
| 1209 |
-
if same_spacing:
|
| 1210 |
-
raw_pairs = [first_spacing for _ in raw_pairs]
|
| 1211 |
-
|
| 1212 |
-
pair_spacing = {
|
| 1213 |
-
(ordered_nozzles[index], ordered_nozzles[index + 1]): raw_pairs[index]
|
| 1214 |
-
for index in range(min(len(raw_pairs), max_pair_count))
|
| 1215 |
-
}
|
| 1216 |
-
|
| 1217 |
-
def spacing_between(prev_idx: int, cur_idx: int) -> tuple[float, float]:
|
| 1218 |
-
if (prev_idx, cur_idx) in pair_spacing:
|
| 1219 |
-
return pair_spacing[(prev_idx, cur_idx)]
|
| 1220 |
-
try:
|
| 1221 |
-
start_pos = ordered_nozzles.index(prev_idx)
|
| 1222 |
-
end_pos = ordered_nozzles.index(cur_idx)
|
| 1223 |
-
except ValueError:
|
| 1224 |
-
return 0.0, 0.0
|
| 1225 |
-
if end_pos <= start_pos:
|
| 1226 |
-
return 0.0, 0.0
|
| 1227 |
-
total_x = 0.0
|
| 1228 |
-
total_y = 0.0
|
| 1229 |
-
for pair_pos in range(start_pos, end_pos):
|
| 1230 |
-
pair = (ordered_nozzles[pair_pos], ordered_nozzles[pair_pos + 1])
|
| 1231 |
-
step_x, step_y = pair_spacing.get(pair, first_spacing)
|
| 1232 |
-
total_x += step_x
|
| 1233 |
-
total_y += step_y
|
| 1234 |
-
return total_x, total_y
|
| 1235 |
-
|
| 1236 |
-
for prev_idx, cur_idx in zip(ordered_nozzles, ordered_nozzles[1:]):
|
| 1237 |
-
gap_x, y_step = spacing_between(prev_idx, cur_idx)
|
| 1238 |
-
prev_offset_x, prev_offset_y = offsets[prev_idx]
|
| 1239 |
-
(_, _, _), (prev_xmax, _prev_ymax, _) = nozzle_bounds(prev_idx)
|
| 1240 |
-
(cur_xmin, _cur_ymin, _), (_, _, _) = nozzle_bounds(cur_idx)
|
| 1241 |
-
dx = (prev_xmax + prev_offset_x + gap_x) - cur_xmin
|
| 1242 |
-
dy = prev_offset_y + y_step
|
| 1243 |
-
offsets[cur_idx] = (dx, dy)
|
| 1244 |
-
spacings.append({
|
| 1245 |
-
"from": prev_idx,
|
| 1246 |
-
"to": cur_idx,
|
| 1247 |
-
"dx": dx - prev_offset_x,
|
| 1248 |
-
"dy": y_step,
|
| 1249 |
-
})
|
| 1250 |
-
|
| 1251 |
-
return offsets, spacings
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
|
| 1255 |
grouped: dict[int, list[dict]] = {}
|
| 1256 |
for part in parts:
|
|
@@ -1390,44 +1296,6 @@ def _resolve_nozzle_grid_layout(
|
|
| 1390 |
return offsets, spacings
|
| 1391 |
|
| 1392 |
|
| 1393 |
-
def _resolve_layout_from_spacing_controls(
|
| 1394 |
-
parts: list[dict],
|
| 1395 |
-
layout_mode: str | None,
|
| 1396 |
-
columns: Any,
|
| 1397 |
-
rows: Any,
|
| 1398 |
-
column_spacing: Any,
|
| 1399 |
-
row_spacing: Any,
|
| 1400 |
-
use_grid_individual_spacing: bool | None,
|
| 1401 |
-
grid_spacing_table: Any,
|
| 1402 |
-
use_individual_spacing: bool,
|
| 1403 |
-
spacing_table: Any,
|
| 1404 |
-
) -> tuple[dict[int, tuple[float, float]], list[dict]]:
|
| 1405 |
-
if layout_mode != NOZZLE_LAYOUT_PAIR_TABLE:
|
| 1406 |
-
return _resolve_nozzle_grid_layout(
|
| 1407 |
-
parts,
|
| 1408 |
-
columns,
|
| 1409 |
-
rows,
|
| 1410 |
-
column_spacing,
|
| 1411 |
-
row_spacing,
|
| 1412 |
-
use_grid_individual_spacing,
|
| 1413 |
-
grid_spacing_table,
|
| 1414 |
-
)
|
| 1415 |
-
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(spacing_table, use_individual_spacing)
|
| 1416 |
-
return _resolve_nozzle_layout(
|
| 1417 |
-
parts,
|
| 1418 |
-
_same_spacing_from_individual(use_individual_spacing),
|
| 1419 |
-
gap12x,
|
| 1420 |
-
gap12y,
|
| 1421 |
-
gap23x,
|
| 1422 |
-
gap23y,
|
| 1423 |
-
*extra,
|
| 1424 |
-
)
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
def _same_spacing_from_individual(use_individual_spacing: bool | None) -> bool:
|
| 1428 |
-
return not bool(use_individual_spacing)
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
def _format_shape_dimensions(parts: list[dict]) -> list[str]:
|
| 1432 |
lines = ["**Shape dimensions from generated G-code:**"]
|
| 1433 |
for part in sorted(parts, key=lambda item: item["idx"]):
|
|
@@ -1492,6 +1360,7 @@ SHAPE_SETTINGS_HEADERS = [
|
|
| 1492 |
"Nozzle",
|
| 1493 |
"Port",
|
| 1494 |
"Color",
|
|
|
|
| 1495 |
"Contour Tracing",
|
| 1496 |
"Delete",
|
| 1497 |
]
|
|
@@ -1506,22 +1375,16 @@ SHAPE_SETTINGS_DATATYPES = [
|
|
| 1506 |
"number",
|
| 1507 |
"number",
|
| 1508 |
"str",
|
|
|
|
| 1509 |
"bool",
|
| 1510 |
"str",
|
| 1511 |
]
|
| 1512 |
-
SIMPLE_NOZZLE_SPACING_HEADERS = [
|
| 1513 |
-
"Spacing Mode",
|
| 1514 |
-
"Applies To",
|
| 1515 |
-
"X edge spacing (mm)",
|
| 1516 |
-
"Y nozzle spacing (mm)",
|
| 1517 |
-
]
|
| 1518 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
| 1519 |
"From Nozzle",
|
| 1520 |
"To Nozzle",
|
| 1521 |
"X edge spacing (mm)",
|
| 1522 |
"Y nozzle spacing (mm)",
|
| 1523 |
]
|
| 1524 |
-
NOZZLE_SPACING_HEADERS = SIMPLE_NOZZLE_SPACING_HEADERS
|
| 1525 |
|
| 1526 |
|
| 1527 |
def _normalise_rows(table: Any) -> list[list[Any]]:
|
|
@@ -1660,6 +1523,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 1660 |
"nozzle": nozzle,
|
| 1661 |
"port": previous.get("port", 1),
|
| 1662 |
"color": previous.get("color", _default_color(index)),
|
|
|
|
| 1663 |
"contour_tracing": previous.get("contour_tracing", False),
|
| 1664 |
"layer_stack": previous.get("layer_stack"),
|
| 1665 |
"slice_params": previous.get("slice_params"),
|
|
@@ -1690,6 +1554,7 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
|
| 1690 |
_record_nozzle_number(record, int(record["idx"])),
|
| 1691 |
record.get("port", 1),
|
| 1692 |
record.get("color", _default_color(record["idx"])),
|
|
|
|
| 1693 |
bool(record.get("contour_tracing", False)),
|
| 1694 |
"Delete",
|
| 1695 |
]
|
|
@@ -1733,7 +1598,8 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
|
|
| 1733 |
nozzle_pos = 7 if has_nozzle_column else None
|
| 1734 |
port_pos = 8 if has_nozzle_column else 7
|
| 1735 |
color_pos = 9 if has_nozzle_column else 8
|
| 1736 |
-
|
|
|
|
| 1737 |
copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
|
| 1738 |
copy["nozzle"] = _coerce_int(
|
| 1739 |
row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
|
|
@@ -1747,6 +1613,13 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
|
|
| 1747 |
copy["nozzle"] = _record_nozzle_number(copy)
|
| 1748 |
if len(row) > color_pos and row[color_pos]:
|
| 1749 |
copy["color"] = str(row[color_pos])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1750 |
try:
|
| 1751 |
copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
|
| 1752 |
except IndexError:
|
|
@@ -1821,38 +1694,6 @@ def _spacing_pairs_from_table(spacing_table: Any) -> list[tuple[float, float]]:
|
|
| 1821 |
return pairs
|
| 1822 |
|
| 1823 |
|
| 1824 |
-
def _spacing_table_update(records: list[dict], existing_table: Any | None = None, use_individual_spacing: bool | None = False) -> dict[str, Any]:
|
| 1825 |
-
pairs = _spacing_pairs_from_table(existing_table)
|
| 1826 |
-
first_pair = pairs[0] if pairs else (5.0, 0.0)
|
| 1827 |
-
|
| 1828 |
-
if not use_individual_spacing:
|
| 1829 |
-
return gr.update(
|
| 1830 |
-
headers=SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 1831 |
-
value=[["Same spacing", "All neighboring nozzles", first_pair[0], first_pair[1]]],
|
| 1832 |
-
row_count=(1, "fixed"),
|
| 1833 |
-
column_count=(len(SIMPLE_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 1834 |
-
label="Nozzle Spacing",
|
| 1835 |
-
)
|
| 1836 |
-
|
| 1837 |
-
rows: list[list[Any]] = []
|
| 1838 |
-
ordered_nozzles = _ordered_nozzle_numbers(records)
|
| 1839 |
-
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
|
| 1840 |
-
gap_x, gap_y = pairs[index] if index < len(pairs) else first_pair
|
| 1841 |
-
rows.append([
|
| 1842 |
-
_nozzle_spacing_label(first, records),
|
| 1843 |
-
_nozzle_spacing_label(second, records),
|
| 1844 |
-
gap_x,
|
| 1845 |
-
gap_y,
|
| 1846 |
-
])
|
| 1847 |
-
return gr.update(
|
| 1848 |
-
headers=ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 1849 |
-
value=rows,
|
| 1850 |
-
row_count=(len(rows), "fixed"),
|
| 1851 |
-
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 1852 |
-
label="Advanced Nozzle Spacing",
|
| 1853 |
-
)
|
| 1854 |
-
|
| 1855 |
-
|
| 1856 |
def _grid_spacing_rows(
|
| 1857 |
records: list[dict],
|
| 1858 |
columns: Any,
|
|
@@ -1904,8 +1745,6 @@ def _grid_spacing_table_update(
|
|
| 1904 |
value=spacing_rows,
|
| 1905 |
row_count=(len(spacing_rows), "fixed"),
|
| 1906 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 1907 |
-
label="Advanced Grid Spacing",
|
| 1908 |
-
visible=bool(use_individual_grid_spacing),
|
| 1909 |
)
|
| 1910 |
|
| 1911 |
|
|
@@ -1932,14 +1771,73 @@ def _split_pair_was_created_together(records_by_nozzle: dict[int, list[dict]], f
|
|
| 1932 |
return False
|
| 1933 |
|
| 1934 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1935 |
def _auto_align_grid_spacing_rows(
|
| 1936 |
records: list[dict],
|
| 1937 |
columns: Any,
|
| 1938 |
rows: Any,
|
| 1939 |
column_spacing: Any,
|
| 1940 |
row_spacing: Any,
|
| 1941 |
-
|
| 1942 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1943 |
spacing_rows, column_count, row_count = _grid_spacing_rows(
|
| 1944 |
records,
|
| 1945 |
columns,
|
|
@@ -1947,18 +1845,42 @@ def _auto_align_grid_spacing_rows(
|
|
| 1947 |
column_spacing,
|
| 1948 |
row_spacing,
|
| 1949 |
)
|
| 1950 |
-
|
|
|
|
| 1951 |
records_by_nozzle = _records_by_nozzle(records)
|
| 1952 |
ordered_nozzles = _ordered_nozzle_numbers(records)
|
|
|
|
| 1953 |
aligned_count = 0
|
|
|
|
| 1954 |
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
|
| 1955 |
if not _split_pair_was_created_together(records_by_nozzle, first, second):
|
| 1956 |
continue
|
| 1957 |
-
|
| 1958 |
-
|
| 1959 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1960 |
aligned_count += 1
|
| 1961 |
-
return spacing_rows, column_count, row_count, aligned_count
|
| 1962 |
|
| 1963 |
|
| 1964 |
def apply_nozzle_grid_spacing(
|
|
@@ -1988,46 +1910,57 @@ def apply_nozzle_grid_spacing(
|
|
| 1988 |
)
|
| 1989 |
|
| 1990 |
|
| 1991 |
-
def
|
| 1992 |
-
if raster_pattern == RASTER_PATTERN_Y_DIRECTION:
|
| 1993 |
-
return AUTO_ALIGN_Y_RASTER_OFFSETS
|
| 1994 |
-
return AUTO_ALIGN_X_RASTER_OFFSETS
|
| 1995 |
-
|
| 1996 |
-
|
| 1997 |
-
def auto_align_split_parts_for_raster(
|
| 1998 |
records: list[dict] | None,
|
| 1999 |
columns: Any,
|
| 2000 |
rows: Any,
|
| 2001 |
column_spacing: Any,
|
| 2002 |
row_spacing: Any,
|
| 2003 |
-
raster_pattern: str | None,
|
| 2004 |
) -> tuple:
|
| 2005 |
-
|
| 2006 |
-
|
| 2007 |
-
|
| 2008 |
-
|
| 2009 |
-
|
| 2010 |
-
|
| 2011 |
-
|
| 2012 |
-
|
| 2013 |
-
|
| 2014 |
-
|
|
|
|
|
|
|
| 2015 |
if aligned_count <= 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2016 |
return (
|
| 2017 |
-
gr.update(value=NOZZLE_LAYOUT_GRID),
|
| 2018 |
-
gr.update(visible=True),
|
| 2019 |
-
gr.update(visible=False),
|
| 2020 |
gr.update(),
|
| 2021 |
gr.update(),
|
| 2022 |
gr.update(),
|
| 2023 |
gr.update(),
|
| 2024 |
-
|
|
|
|
|
|
|
| 2025 |
)
|
| 2026 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2027 |
return (
|
| 2028 |
-
gr.update(value=
|
| 2029 |
-
gr.update(
|
| 2030 |
-
gr.update(visible=False),
|
| 2031 |
gr.update(),
|
| 2032 |
gr.update(),
|
| 2033 |
gr.update(value=True),
|
|
@@ -2036,13 +1969,8 @@ def auto_align_split_parts_for_raster(
|
|
| 2036 |
value=spacing_rows,
|
| 2037 |
row_count=(len(spacing_rows), "fixed"),
|
| 2038 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 2039 |
-
label="Advanced Grid Spacing",
|
| 2040 |
-
visible=True,
|
| 2041 |
-
),
|
| 2042 |
-
(
|
| 2043 |
-
f"Auto aligned {aligned_count} split nozzle connection(s) for {raster_label} "
|
| 2044 |
-
f"in a {column_count} x {row_count} grid: Column Gap X {x_gap:.2f} mm, Row Gap Y {y_gap:.2f} mm."
|
| 2045 |
),
|
|
|
|
| 2046 |
)
|
| 2047 |
|
| 2048 |
|
|
@@ -2063,11 +1991,6 @@ def update_nozzle_grid_preset(
|
|
| 2063 |
return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
|
| 2064 |
|
| 2065 |
|
| 2066 |
-
def update_nozzle_spacing_mode(layout_mode: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 2067 |
-
custom_selected = layout_mode == NOZZLE_LAYOUT_PAIR_TABLE
|
| 2068 |
-
return gr.update(visible=not custom_selected), gr.update(visible=custom_selected)
|
| 2069 |
-
|
| 2070 |
-
|
| 2071 |
def update_lead_in_options_visibility(enabled: bool | None) -> dict[str, Any]:
|
| 2072 |
return gr.update(visible=bool(enabled))
|
| 2073 |
|
|
@@ -2110,17 +2033,13 @@ def sync_uploaded_shapes(
|
|
| 2110 |
files: Any,
|
| 2111 |
records: list[dict] | None,
|
| 2112 |
settings_table: Any | None = None,
|
| 2113 |
-
existing_spacing: Any | None = None,
|
| 2114 |
-
use_individual_spacing: bool | None = False,
|
| 2115 |
) -> tuple:
|
| 2116 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2117 |
next_records = _records_from_files(files, records)
|
| 2118 |
settings = _shape_settings_rows(next_records)
|
| 2119 |
-
spacing = _spacing_table_update(next_records, existing_spacing, use_individual_spacing)
|
| 2120 |
return (
|
| 2121 |
next_records,
|
| 2122 |
settings,
|
| 2123 |
-
spacing,
|
| 2124 |
_dropdown_update(next_records),
|
| 2125 |
_gcode_dropdown_update(next_records),
|
| 2126 |
_gcode_dropdown_update(next_records, include_upload=True),
|
|
@@ -2132,30 +2051,18 @@ def load_sample_shapes(
|
|
| 2132 |
files: Any,
|
| 2133 |
records: list[dict] | None,
|
| 2134 |
settings_table: Any | None = None,
|
| 2135 |
-
existing_spacing: Any | None = None,
|
| 2136 |
-
use_individual_spacing: bool | None = False,
|
| 2137 |
) -> tuple:
|
| 2138 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2139 |
paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
|
| 2140 |
merged_paths = _append_file_paths(files, paths)
|
| 2141 |
return (
|
| 2142 |
gr.update(value=merged_paths),
|
| 2143 |
-
*sync_uploaded_shapes(merged_paths, records, None
|
| 2144 |
)
|
| 2145 |
|
| 2146 |
|
| 2147 |
-
def update_nozzle_spacing_table_mode(
|
| 2148 |
-
records: list[dict] | None,
|
| 2149 |
-
existing_spacing: Any | None,
|
| 2150 |
-
use_individual_spacing: bool | None,
|
| 2151 |
-
) -> dict[str, Any]:
|
| 2152 |
-
return _spacing_table_update(records or [], existing_spacing, use_individual_spacing)
|
| 2153 |
-
|
| 2154 |
-
|
| 2155 |
def _shape_delete_outputs(
|
| 2156 |
records: list[dict],
|
| 2157 |
-
existing_spacing: Any | None,
|
| 2158 |
-
use_individual_spacing: bool | None,
|
| 2159 |
last_delete_at: float | None,
|
| 2160 |
upload_update: Any | None = None,
|
| 2161 |
) -> tuple:
|
|
@@ -2163,7 +2070,6 @@ def _shape_delete_outputs(
|
|
| 2163 |
upload_update if upload_update is not None else gr.update(),
|
| 2164 |
records,
|
| 2165 |
_shape_settings_rows(records),
|
| 2166 |
-
_spacing_table_update(records, existing_spacing, use_individual_spacing),
|
| 2167 |
_dropdown_update(records),
|
| 2168 |
_gcode_dropdown_update(records),
|
| 2169 |
_gcode_dropdown_update(records, include_upload=True),
|
|
@@ -2175,8 +2081,6 @@ def _shape_delete_outputs(
|
|
| 2175 |
def delete_shape_from_settings(
|
| 2176 |
records: list[dict] | None,
|
| 2177 |
settings_table: Any | None,
|
| 2178 |
-
existing_spacing: Any | None,
|
| 2179 |
-
use_individual_spacing: bool | None,
|
| 2180 |
last_delete_at: float | None,
|
| 2181 |
evt: gr.SelectData,
|
| 2182 |
) -> tuple:
|
|
@@ -2185,17 +2089,17 @@ def delete_shape_from_settings(
|
|
| 2185 |
selected = getattr(evt, "index", None)
|
| 2186 |
current_records = _apply_shape_settings(records or [], settings_table)
|
| 2187 |
if not isinstance(selected, (list, tuple)) or len(selected) < 2:
|
| 2188 |
-
return _shape_delete_outputs(current_records,
|
| 2189 |
|
| 2190 |
try:
|
| 2191 |
row_index, column_index = int(selected[0]), int(selected[1])
|
| 2192 |
except (TypeError, ValueError):
|
| 2193 |
-
return _shape_delete_outputs(current_records,
|
| 2194 |
delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
|
| 2195 |
if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
|
| 2196 |
-
return _shape_delete_outputs(current_records,
|
| 2197 |
if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
|
| 2198 |
-
return _shape_delete_outputs(current_records,
|
| 2199 |
|
| 2200 |
try:
|
| 2201 |
delete_idx = int(float(rows[row_index][0]))
|
|
@@ -2208,8 +2112,6 @@ def delete_shape_from_settings(
|
|
| 2208 |
upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
|
| 2209 |
return _shape_delete_outputs(
|
| 2210 |
next_records,
|
| 2211 |
-
existing_spacing,
|
| 2212 |
-
use_individual_spacing,
|
| 2213 |
now,
|
| 2214 |
gr.update(value=upload_paths),
|
| 2215 |
)
|
|
@@ -2289,21 +2191,6 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2289 |
return normalized, _shape_settings_rows(normalized)
|
| 2290 |
|
| 2291 |
|
| 2292 |
-
def normalize_shape_settings_and_spacing(
|
| 2293 |
-
records: list[dict] | None,
|
| 2294 |
-
settings_table: Any | None,
|
| 2295 |
-
scale_mode: str | None,
|
| 2296 |
-
existing_spacing: Any | None,
|
| 2297 |
-
use_individual_spacing: bool | None,
|
| 2298 |
-
) -> tuple:
|
| 2299 |
-
updated_records, updated_settings = normalize_shape_dimensions_for_mode(records, settings_table, scale_mode)
|
| 2300 |
-
return (
|
| 2301 |
-
updated_records,
|
| 2302 |
-
updated_settings,
|
| 2303 |
-
_spacing_table_update(updated_records, existing_spacing, use_individual_spacing),
|
| 2304 |
-
)
|
| 2305 |
-
|
| 2306 |
-
|
| 2307 |
def show_selected_model(
|
| 2308 |
records: list[dict] | None,
|
| 2309 |
selected: str | None,
|
|
@@ -2370,6 +2257,7 @@ def generate_dynamic_layer_stacks(
|
|
| 2370 |
settings_table: Any,
|
| 2371 |
layer_height: float,
|
| 2372 |
scale_mode: str | None,
|
|
|
|
| 2373 |
progress: gr.Progress = gr.Progress(),
|
| 2374 |
) -> tuple:
|
| 2375 |
records = _apply_shape_settings(records or [], settings_table)
|
|
@@ -2398,7 +2286,7 @@ def generate_dynamic_layer_stacks(
|
|
| 2398 |
)
|
| 2399 |
except Exception as exc:
|
| 2400 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 2401 |
-
ref_layers = generate_dynamic_reference_stack(records)
|
| 2402 |
if ref_layers is not None:
|
| 2403 |
messages.append("Reference layers: updated automatically.")
|
| 2404 |
else:
|
|
@@ -2406,16 +2294,22 @@ def generate_dynamic_layer_stacks(
|
|
| 2406 |
return records, "\n".join(messages), ref_layers
|
| 2407 |
|
| 2408 |
|
| 2409 |
-
def generate_dynamic_reference_stack(
|
| 2410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2411 |
|
| 2412 |
|
| 2413 |
def split_selected_shape_for_grid(
|
| 2414 |
records: list[dict] | None,
|
| 2415 |
selected: str | None,
|
| 2416 |
settings_table: Any | None,
|
| 2417 |
-
existing_spacing: Any | None,
|
| 2418 |
-
use_individual_spacing: bool | None,
|
| 2419 |
columns: float,
|
| 2420 |
rows: float,
|
| 2421 |
overlapping_layers: bool,
|
|
@@ -2429,7 +2323,6 @@ def split_selected_shape_for_grid(
|
|
| 2429 |
return (
|
| 2430 |
next_records,
|
| 2431 |
_shape_settings_rows(next_records),
|
| 2432 |
-
_spacing_table_update(next_records, existing_spacing, use_individual_spacing),
|
| 2433 |
_dropdown_update(next_records, selected_value),
|
| 2434 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2435 |
_gcode_dropdown_update(next_records),
|
|
@@ -2462,6 +2355,9 @@ def split_selected_shape_for_grid(
|
|
| 2462 |
rows=split_row_count,
|
| 2463 |
overlapping_layers=bool(overlapping_layers),
|
| 2464 |
overlap=float(fil_width) if overlapping_layers else 0.0,
|
|
|
|
|
|
|
|
|
|
| 2465 |
)
|
| 2466 |
except Exception as exc:
|
| 2467 |
return _outputs(records, selected, f"Split failed: {exc}")
|
|
@@ -2571,9 +2467,13 @@ def generate_dynamic_gcode(
|
|
| 2571 |
) -> tuple:
|
| 2572 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2573 |
messages: list[str] = []
|
| 2574 |
-
|
| 2575 |
-
|
| 2576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2577 |
contour_sources = _contour_tracing_sources(records)
|
| 2578 |
if contour_sources:
|
| 2579 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
|
@@ -2602,6 +2502,7 @@ def generate_dynamic_gcode(
|
|
| 2602 |
raster_pattern=raster_pattern,
|
| 2603 |
contour_sources=contour_sources,
|
| 2604 |
active_contour_owner=int(record.get("idx", 0)),
|
|
|
|
| 2605 |
lead_in_enabled=bool(lead_in_enabled),
|
| 2606 |
lead_in_length=float(lead_in_length),
|
| 2607 |
lead_in_clearance=float(lead_in_clearance),
|
|
@@ -2657,44 +2558,26 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
|
|
| 2657 |
return parts, messages
|
| 2658 |
|
| 2659 |
|
| 2660 |
-
def _spacing_args_from_table(spacing_table: Any, use_individual_spacing: bool | None = False) -> tuple[float, float, float, float, list[float]]:
|
| 2661 |
-
pairs = _spacing_pairs_from_table(spacing_table)
|
| 2662 |
-
if not pairs:
|
| 2663 |
-
pairs = [(5.0, 0.0)]
|
| 2664 |
-
if not use_individual_spacing:
|
| 2665 |
-
pairs = [pairs[0]]
|
| 2666 |
-
while len(pairs) < 2:
|
| 2667 |
-
pairs.append(pairs[0])
|
| 2668 |
-
extra = [value for pair in pairs[2:] for value in pair]
|
| 2669 |
-
return pairs[0][0], pairs[0][1], pairs[1][0], pairs[1][1], extra
|
| 2670 |
-
|
| 2671 |
-
|
| 2672 |
def render_dynamic_nozzle_spacing(
|
| 2673 |
records: list[dict] | None,
|
| 2674 |
-
layout_mode: str | None,
|
| 2675 |
columns: Any,
|
| 2676 |
rows: Any,
|
| 2677 |
column_spacing: Any,
|
| 2678 |
row_spacing: Any,
|
| 2679 |
use_grid_individual_spacing: bool,
|
| 2680 |
grid_spacing_table: Any,
|
| 2681 |
-
use_individual_spacing: bool,
|
| 2682 |
-
spacing_table: Any,
|
| 2683 |
) -> tuple[Any, str]:
|
| 2684 |
parts, _messages = _parts_from_records(records)
|
| 2685 |
if not parts:
|
| 2686 |
return None, "No shape G-code available. Generate G-code first."
|
| 2687 |
-
offsets, spacings =
|
| 2688 |
parts,
|
| 2689 |
-
layout_mode,
|
| 2690 |
columns,
|
| 2691 |
rows,
|
| 2692 |
column_spacing,
|
| 2693 |
row_spacing,
|
| 2694 |
use_grid_individual_spacing,
|
| 2695 |
grid_spacing_table,
|
| 2696 |
-
use_individual_spacing,
|
| 2697 |
-
spacing_table,
|
| 2698 |
)
|
| 2699 |
return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 2700 |
|
|
@@ -2765,32 +2648,26 @@ def render_dynamic_parallel(
|
|
| 2765 |
travel_opacity: float,
|
| 2766 |
filament_width: float,
|
| 2767 |
travel_width: float,
|
| 2768 |
-
layout_mode: str | None,
|
| 2769 |
columns: Any,
|
| 2770 |
rows: Any,
|
| 2771 |
column_spacing: Any,
|
| 2772 |
row_spacing: Any,
|
| 2773 |
use_grid_individual_spacing: bool,
|
| 2774 |
grid_spacing_table: Any,
|
| 2775 |
-
use_individual_spacing: bool,
|
| 2776 |
-
spacing_table: Any,
|
| 2777 |
tube: bool = True,
|
| 2778 |
) -> tuple[Any, str]:
|
| 2779 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2780 |
parts, messages = _parts_from_records(records)
|
| 2781 |
if not parts:
|
| 2782 |
return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
|
| 2783 |
-
offsets, spacings =
|
| 2784 |
parts,
|
| 2785 |
-
layout_mode,
|
| 2786 |
columns,
|
| 2787 |
rows,
|
| 2788 |
column_spacing,
|
| 2789 |
row_spacing,
|
| 2790 |
use_grid_individual_spacing,
|
| 2791 |
grid_spacing_table,
|
| 2792 |
-
use_individual_spacing,
|
| 2793 |
-
spacing_table,
|
| 2794 |
)
|
| 2795 |
figure = build_parallel_figure(
|
| 2796 |
parts,
|
|
@@ -2824,15 +2701,12 @@ def export_dynamic_parallel_gif(
|
|
| 2824 |
records: list[dict] | None,
|
| 2825 |
settings_table: Any,
|
| 2826 |
travel_opacity: float,
|
| 2827 |
-
layout_mode: str | None,
|
| 2828 |
columns: Any,
|
| 2829 |
rows: Any,
|
| 2830 |
column_spacing: Any,
|
| 2831 |
row_spacing: Any,
|
| 2832 |
use_grid_individual_spacing: bool,
|
| 2833 |
grid_spacing_table: Any,
|
| 2834 |
-
use_individual_spacing: bool,
|
| 2835 |
-
spacing_table: Any,
|
| 2836 |
duration: float,
|
| 2837 |
fps: float,
|
| 2838 |
elev: float,
|
|
@@ -2843,17 +2717,14 @@ def export_dynamic_parallel_gif(
|
|
| 2843 |
parts, _messages = _parts_from_records(records)
|
| 2844 |
if not parts:
|
| 2845 |
return None
|
| 2846 |
-
offsets, _spacings =
|
| 2847 |
parts,
|
| 2848 |
-
layout_mode,
|
| 2849 |
columns,
|
| 2850 |
rows,
|
| 2851 |
column_spacing,
|
| 2852 |
row_spacing,
|
| 2853 |
use_grid_individual_spacing,
|
| 2854 |
grid_spacing_table,
|
| 2855 |
-
use_individual_spacing,
|
| 2856 |
-
spacing_table,
|
| 2857 |
)
|
| 2858 |
|
| 2859 |
def report(frame: int, total: int) -> None:
|
|
@@ -2986,12 +2857,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 2986 |
gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
|
| 2987 |
|
| 2988 |
with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
|
| 2989 |
-
|
| 2990 |
-
label="Spacing Mode",
|
| 2991 |
-
choices=[NOZZLE_LAYOUT_GRID, NOZZLE_LAYOUT_PAIR_TABLE],
|
| 2992 |
-
value=NOZZLE_LAYOUT_GRID,
|
| 2993 |
-
)
|
| 2994 |
-
with gr.Group(visible=True) as nozzle_grid_group:
|
| 2995 |
with gr.Row():
|
| 2996 |
nozzle_grid_preset = gr.Dropdown(
|
| 2997 |
label="Common Layout",
|
|
@@ -3005,28 +2871,21 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3005 |
nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
|
| 3006 |
with gr.Row():
|
| 3007 |
auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
|
| 3008 |
-
nozzle_grid_use_individual_spacing = gr.Checkbox(label="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3009 |
nozzle_grid_spacing_table = gr.Dataframe(
|
| 3010 |
headers=ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 3011 |
value=[],
|
| 3012 |
row_count=(0, "fixed"),
|
| 3013 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 3014 |
interactive=True,
|
| 3015 |
-
label="Advanced Grid Spacing",
|
| 3016 |
-
visible=False,
|
| 3017 |
elem_id="nozzle-grid-spacing-table",
|
| 3018 |
)
|
| 3019 |
-
with gr.Group(visible=False) as nozzle_custom_group:
|
| 3020 |
-
nozzle_use_individual_spacing = gr.Checkbox(label="Use Different Values for Each Nozzle Connection", value=False)
|
| 3021 |
-
nozzle_spacing_table = gr.Dataframe(
|
| 3022 |
-
headers=NOZZLE_SPACING_HEADERS,
|
| 3023 |
-
value=[["Same spacing", "All neighboring nozzles", 5.0, 0.0]],
|
| 3024 |
-
row_count=(1, "fixed"),
|
| 3025 |
-
column_count=(len(NOZZLE_SPACING_HEADERS), "fixed"),
|
| 3026 |
-
interactive=True,
|
| 3027 |
-
label="Custom Spacing",
|
| 3028 |
-
elem_id="nozzle-spacing-table",
|
| 3029 |
-
)
|
| 3030 |
nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
|
| 3031 |
with gr.Row():
|
| 3032 |
with gr.Column(scale=3, min_width=420):
|
|
@@ -3120,8 +2979,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3120 |
nozzle_grid_use_individual_spacing,
|
| 3121 |
]
|
| 3122 |
|
| 3123 |
-
shape_sync_outputs = [shape_records, shape_settings,
|
| 3124 |
-
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings
|
| 3125 |
fn=lambda records: _dropdown_update(records),
|
| 3126 |
inputs=[shape_records],
|
| 3127 |
outputs=[split_source],
|
|
@@ -3132,7 +2991,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3132 |
outputs=[nozzle_grid_spacing_table],
|
| 3133 |
queue=False,
|
| 3134 |
)
|
| 3135 |
-
sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings
|
| 3136 |
fn=lambda records: _dropdown_update(records),
|
| 3137 |
inputs=[shape_records],
|
| 3138 |
outputs=[split_source],
|
|
@@ -3143,7 +3002,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3143 |
outputs=[nozzle_grid_spacing_table],
|
| 3144 |
queue=False,
|
| 3145 |
)
|
| 3146 |
-
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings
|
| 3147 |
fn=lambda records: _dropdown_update(records),
|
| 3148 |
inputs=[shape_records],
|
| 3149 |
outputs=[split_source],
|
|
@@ -3156,7 +3015,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3156 |
)
|
| 3157 |
shape_settings.select(
|
| 3158 |
fn=delete_shape_from_settings,
|
| 3159 |
-
inputs=[shape_records, shape_settings,
|
| 3160 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 3161 |
).then(
|
| 3162 |
fn=lambda records: _dropdown_update(records),
|
|
@@ -3172,9 +3031,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3172 |
|
| 3173 |
preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
|
| 3174 |
shape_settings.change(
|
| 3175 |
-
fn=
|
| 3176 |
-
inputs=[shape_records, shape_settings, scale_mode
|
| 3177 |
-
outputs=[shape_records, shape_settings
|
| 3178 |
queue=False,
|
| 3179 |
).then(
|
| 3180 |
fn=_grid_spacing_table_update,
|
|
@@ -3207,7 +3066,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3207 |
|
| 3208 |
generate_button.click(
|
| 3209 |
fn=generate_dynamic_layer_stacks,
|
| 3210 |
-
inputs=[shape_records, shape_settings, layer_height, scale_mode],
|
| 3211 |
outputs=[shape_records, slicer_status, ref_layers],
|
| 3212 |
).then(
|
| 3213 |
fn=lambda records: _dropdown_update(records),
|
|
@@ -3223,8 +3082,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3223 |
shape_records,
|
| 3224 |
split_source,
|
| 3225 |
shape_settings,
|
| 3226 |
-
nozzle_spacing_table,
|
| 3227 |
-
nozzle_use_individual_spacing,
|
| 3228 |
split_columns,
|
| 3229 |
split_rows,
|
| 3230 |
split_overlapping_layers,
|
|
@@ -3235,7 +3092,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3235 |
outputs=[
|
| 3236 |
shape_records,
|
| 3237 |
shape_settings,
|
| 3238 |
-
nozzle_spacing_table,
|
| 3239 |
selected_shape,
|
| 3240 |
gcode_downloads,
|
| 3241 |
gcode_text_source,
|
|
@@ -3245,7 +3101,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3245 |
],
|
| 3246 |
).then(
|
| 3247 |
fn=generate_dynamic_reference_stack,
|
| 3248 |
-
inputs=[shape_records],
|
| 3249 |
outputs=[ref_layers],
|
| 3250 |
).then(
|
| 3251 |
fn=_grid_spacing_table_update,
|
|
@@ -3286,26 +3142,18 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3286 |
)
|
| 3287 |
gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
| 3288 |
refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
| 3289 |
-
nozzle_layout_mode.change(
|
| 3290 |
-
fn=update_nozzle_spacing_mode,
|
| 3291 |
-
inputs=[nozzle_layout_mode],
|
| 3292 |
-
outputs=[nozzle_grid_group, nozzle_custom_group],
|
| 3293 |
-
queue=False,
|
| 3294 |
-
)
|
| 3295 |
auto_align_split_parts_button.click(
|
| 3296 |
-
fn=
|
| 3297 |
inputs=[
|
| 3298 |
shape_records,
|
| 3299 |
nozzle_grid_columns,
|
| 3300 |
nozzle_grid_rows,
|
| 3301 |
nozzle_grid_column_spacing,
|
| 3302 |
nozzle_grid_row_spacing,
|
| 3303 |
-
gcode_raster_pattern,
|
| 3304 |
],
|
| 3305 |
outputs=[
|
| 3306 |
-
|
| 3307 |
-
|
| 3308 |
-
nozzle_custom_group,
|
| 3309 |
nozzle_grid_column_spacing,
|
| 3310 |
nozzle_grid_row_spacing,
|
| 3311 |
nozzle_grid_use_individual_spacing,
|
|
@@ -3332,26 +3180,27 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3332 |
nozzle_grid_row_spacing,
|
| 3333 |
nozzle_grid_use_individual_spacing,
|
| 3334 |
):
|
| 3335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3336 |
fn=_grid_spacing_table_update,
|
| 3337 |
inputs=grid_spacing_refresh_inputs,
|
| 3338 |
outputs=[nozzle_grid_spacing_table],
|
| 3339 |
queue=False,
|
| 3340 |
)
|
| 3341 |
-
nozzle_use_individual_spacing.change(fn=update_nozzle_spacing_table_mode, inputs=[shape_records, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=[nozzle_spacing_table], queue=False)
|
| 3342 |
nozzle_preview_button.click(
|
| 3343 |
fn=render_dynamic_nozzle_spacing,
|
| 3344 |
inputs=[
|
| 3345 |
shape_records,
|
| 3346 |
-
nozzle_layout_mode,
|
| 3347 |
nozzle_grid_columns,
|
| 3348 |
nozzle_grid_rows,
|
| 3349 |
nozzle_grid_column_spacing,
|
| 3350 |
nozzle_grid_row_spacing,
|
| 3351 |
nozzle_grid_use_individual_spacing,
|
| 3352 |
nozzle_grid_spacing_table,
|
| 3353 |
-
nozzle_use_individual_spacing,
|
| 3354 |
-
nozzle_spacing_table,
|
| 3355 |
],
|
| 3356 |
outputs=[nozzle_spacing_plot, nozzle_spacing_status],
|
| 3357 |
)
|
|
@@ -3398,15 +3247,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3398 |
pp_travel_opacity,
|
| 3399 |
pp_filament_width,
|
| 3400 |
pp_travel_width,
|
| 3401 |
-
nozzle_layout_mode,
|
| 3402 |
nozzle_grid_columns,
|
| 3403 |
nozzle_grid_rows,
|
| 3404 |
nozzle_grid_column_spacing,
|
| 3405 |
nozzle_grid_row_spacing,
|
| 3406 |
nozzle_grid_use_individual_spacing,
|
| 3407 |
nozzle_grid_spacing_table,
|
| 3408 |
-
nozzle_use_individual_spacing,
|
| 3409 |
-
nozzle_spacing_table,
|
| 3410 |
]
|
| 3411 |
parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
|
| 3412 |
parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
|
|
@@ -3419,15 +3265,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3419 |
shape_records,
|
| 3420 |
shape_settings,
|
| 3421 |
pp_gif_travel_opacity,
|
| 3422 |
-
nozzle_layout_mode,
|
| 3423 |
nozzle_grid_columns,
|
| 3424 |
nozzle_grid_rows,
|
| 3425 |
nozzle_grid_column_spacing,
|
| 3426 |
nozzle_grid_row_spacing,
|
| 3427 |
nozzle_grid_use_individual_spacing,
|
| 3428 |
nozzle_grid_spacing_table,
|
| 3429 |
-
nozzle_use_individual_spacing,
|
| 3430 |
-
nozzle_spacing_table,
|
| 3431 |
pp_gif_duration,
|
| 3432 |
pp_gif_fps,
|
| 3433 |
pp_elev,
|
|
|
|
| 55 |
SCALE_MODE_UNIFORM_FACTOR = "Keep Proportions"
|
| 56 |
TARGET_DIMENSION_KEYS = ("target_x", "target_y", "target_z")
|
| 57 |
FRONT_CAMERA = (90, 80, None)
|
|
|
|
|
|
|
| 58 |
NOZZLE_LAYOUT_PRESETS = [
|
| 59 |
"Custom",
|
| 60 |
"One row",
|
|
|
|
| 66 |
"2 x 5",
|
| 67 |
"5 x 2",
|
| 68 |
]
|
|
|
|
|
|
|
| 69 |
APP_CSS = """
|
| 70 |
.gradio-container {
|
| 71 |
font-size: 90%;
|
|
|
|
| 1157 |
DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
|
| 1158 |
|
| 1159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
def _group_parts_by_nozzle(parts: list[dict]) -> dict[int, list[dict]]:
|
| 1161 |
grouped: dict[int, list[dict]] = {}
|
| 1162 |
for part in parts:
|
|
|
|
| 1296 |
return offsets, spacings
|
| 1297 |
|
| 1298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1299 |
def _format_shape_dimensions(parts: list[dict]) -> list[str]:
|
| 1300 |
lines = ["**Shape dimensions from generated G-code:**"]
|
| 1301 |
for part in sorted(parts, key=lambda item: item["idx"]):
|
|
|
|
| 1360 |
"Nozzle",
|
| 1361 |
"Port",
|
| 1362 |
"Color",
|
| 1363 |
+
"Infill %",
|
| 1364 |
"Contour Tracing",
|
| 1365 |
"Delete",
|
| 1366 |
]
|
|
|
|
| 1375 |
"number",
|
| 1376 |
"number",
|
| 1377 |
"str",
|
| 1378 |
+
"number",
|
| 1379 |
"bool",
|
| 1380 |
"str",
|
| 1381 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1382 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
| 1383 |
"From Nozzle",
|
| 1384 |
"To Nozzle",
|
| 1385 |
"X edge spacing (mm)",
|
| 1386 |
"Y nozzle spacing (mm)",
|
| 1387 |
]
|
|
|
|
| 1388 |
|
| 1389 |
|
| 1390 |
def _normalise_rows(table: Any) -> list[list[Any]]:
|
|
|
|
| 1523 |
"nozzle": nozzle,
|
| 1524 |
"port": previous.get("port", 1),
|
| 1525 |
"color": previous.get("color", _default_color(index)),
|
| 1526 |
+
"infill": previous.get("infill", 100.0),
|
| 1527 |
"contour_tracing": previous.get("contour_tracing", False),
|
| 1528 |
"layer_stack": previous.get("layer_stack"),
|
| 1529 |
"slice_params": previous.get("slice_params"),
|
|
|
|
| 1554 |
_record_nozzle_number(record, int(record["idx"])),
|
| 1555 |
record.get("port", 1),
|
| 1556 |
record.get("color", _default_color(record["idx"])),
|
| 1557 |
+
_coerce_float(record.get("infill", 100.0), 100.0),
|
| 1558 |
bool(record.get("contour_tracing", False)),
|
| 1559 |
"Delete",
|
| 1560 |
]
|
|
|
|
| 1598 |
nozzle_pos = 7 if has_nozzle_column else None
|
| 1599 |
port_pos = 8 if has_nozzle_column else 7
|
| 1600 |
color_pos = 9 if has_nozzle_column else 8
|
| 1601 |
+
infill_pos = 10 if has_nozzle_column else 9
|
| 1602 |
+
contour_pos = 11 if has_nozzle_column else 10
|
| 1603 |
copy["valve"] = _coerce_int(copy.get("valve", 4), 4)
|
| 1604 |
copy["nozzle"] = _coerce_int(
|
| 1605 |
row[nozzle_pos] if nozzle_pos is not None else copy.get("nozzle", copy.get("idx", 1)),
|
|
|
|
| 1613 |
copy["nozzle"] = _record_nozzle_number(copy)
|
| 1614 |
if len(row) > color_pos and row[color_pos]:
|
| 1615 |
copy["color"] = str(row[color_pos])
|
| 1616 |
+
try:
|
| 1617 |
+
copy["infill"] = max(
|
| 1618 |
+
0.0,
|
| 1619 |
+
min(100.0, float(row[infill_pos])),
|
| 1620 |
+
)
|
| 1621 |
+
except (IndexError, TypeError, ValueError):
|
| 1622 |
+
copy["infill"] = _coerce_float(copy.get("infill", 100.0), 100.0)
|
| 1623 |
try:
|
| 1624 |
copy["contour_tracing"] = _coerce_bool(row[contour_pos], bool(copy.get("contour_tracing", False)))
|
| 1625 |
except IndexError:
|
|
|
|
| 1694 |
return pairs
|
| 1695 |
|
| 1696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1697 |
def _grid_spacing_rows(
|
| 1698 |
records: list[dict],
|
| 1699 |
columns: Any,
|
|
|
|
| 1745 |
value=spacing_rows,
|
| 1746 |
row_count=(len(spacing_rows), "fixed"),
|
| 1747 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
|
|
|
|
|
|
| 1748 |
)
|
| 1749 |
|
| 1750 |
|
|
|
|
| 1771 |
return False
|
| 1772 |
|
| 1773 |
|
| 1774 |
+
def _part_world_bounds(part: dict) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
| 1775 |
+
"""XY bounding box of a part's toolpath in the shape's own world frame.
|
| 1776 |
+
|
| 1777 |
+
Requires the `; PathOrigin` header the G-code generator writes; parts
|
| 1778 |
+
generated before that header exists return None.
|
| 1779 |
+
"""
|
| 1780 |
+
parsed = part.get("parsed") or {}
|
| 1781 |
+
origin = parsed.get("path_origin")
|
| 1782 |
+
if origin is None:
|
| 1783 |
+
return None
|
| 1784 |
+
(x_min, y_min, _z_min), (x_max, y_max, _z_max) = parsed["bounds"]
|
| 1785 |
+
return (
|
| 1786 |
+
(x_min + origin[0], y_min + origin[1]),
|
| 1787 |
+
(x_max + origin[0], y_max + origin[1]),
|
| 1788 |
+
)
|
| 1789 |
+
|
| 1790 |
+
|
| 1791 |
+
def _nozzle_world_bounds(
|
| 1792 |
+
grouped: dict[int, list[dict]],
|
| 1793 |
+
) -> dict[int, tuple[tuple[float, float], tuple[float, float]]]:
|
| 1794 |
+
world: dict[int, tuple[tuple[float, float], tuple[float, float]]] = {}
|
| 1795 |
+
for nozzle, parts in grouped.items():
|
| 1796 |
+
boxes = [_part_world_bounds(part) for part in parts]
|
| 1797 |
+
if not boxes or any(box is None for box in boxes):
|
| 1798 |
+
continue
|
| 1799 |
+
world[nozzle] = (
|
| 1800 |
+
(min(box[0][0] for box in boxes), min(box[0][1] for box in boxes)),
|
| 1801 |
+
(max(box[1][0] for box in boxes), max(box[1][1] for box in boxes)),
|
| 1802 |
+
)
|
| 1803 |
+
return world
|
| 1804 |
+
|
| 1805 |
+
|
| 1806 |
+
def _split_grid_shape(records: list[dict]) -> tuple[int, int] | None:
|
| 1807 |
+
"""The split grid (columns, rows) when records hold exactly one split group."""
|
| 1808 |
+
groups = {
|
| 1809 |
+
record.get("split_group_id")
|
| 1810 |
+
for record in records
|
| 1811 |
+
if record.get("split_group_id")
|
| 1812 |
+
}
|
| 1813 |
+
if len(groups) != 1:
|
| 1814 |
+
return None
|
| 1815 |
+
group_id = next(iter(groups))
|
| 1816 |
+
for record in records:
|
| 1817 |
+
if record.get("split_group_id") == group_id:
|
| 1818 |
+
columns = _coerce_int(record.get("split_columns"), 0)
|
| 1819 |
+
rows = _coerce_int(record.get("split_rows"), 0)
|
| 1820 |
+
if columns >= 1 and rows >= 1:
|
| 1821 |
+
return columns, rows
|
| 1822 |
+
return None
|
| 1823 |
+
|
| 1824 |
+
|
| 1825 |
def _auto_align_grid_spacing_rows(
|
| 1826 |
records: list[dict],
|
| 1827 |
columns: Any,
|
| 1828 |
rows: Any,
|
| 1829 |
column_spacing: Any,
|
| 1830 |
row_spacing: Any,
|
| 1831 |
+
) -> tuple[list[list[Any]], int, int, int, int]:
|
| 1832 |
+
"""Exact per-pair gaps that reassemble split pieces in the parallel view.
|
| 1833 |
+
|
| 1834 |
+
Every generated G-code file records its PathOrigin: the world position of
|
| 1835 |
+
the relative toolpath's start. Anchoring each piece back into its world
|
| 1836 |
+
frame turns the required pair gaps into the actual world-frame gaps
|
| 1837 |
+
between the parts' toolpath bounding boxes — which automatically accounts
|
| 1838 |
+
for the raster pattern, filament width, travel buffers, reference-stack
|
| 1839 |
+
motion, and overlapping-layer splits. No hardcoded offsets.
|
| 1840 |
+
"""
|
| 1841 |
spacing_rows, column_count, row_count = _grid_spacing_rows(
|
| 1842 |
records,
|
| 1843 |
columns,
|
|
|
|
| 1845 |
column_spacing,
|
| 1846 |
row_spacing,
|
| 1847 |
)
|
| 1848 |
+
parts, _messages = _parts_from_records(records)
|
| 1849 |
+
world = _nozzle_world_bounds(_group_parts_by_nozzle(parts))
|
| 1850 |
records_by_nozzle = _records_by_nozzle(records)
|
| 1851 |
ordered_nozzles = _ordered_nozzle_numbers(records)
|
| 1852 |
+
|
| 1853 |
aligned_count = 0
|
| 1854 |
+
missing_count = 0
|
| 1855 |
for index, (first, second) in enumerate(zip(ordered_nozzles, ordered_nozzles[1:])):
|
| 1856 |
if not _split_pair_was_created_together(records_by_nozzle, first, second):
|
| 1857 |
continue
|
| 1858 |
+
|
| 1859 |
+
second_column = (index + 1) % column_count
|
| 1860 |
+
if second_column == 0:
|
| 1861 |
+
# Row transition: `second` opens a new grid row. The layout places
|
| 1862 |
+
# it relative to the previous row's first nozzle (x) and the
|
| 1863 |
+
# previous row's lowest edge (y).
|
| 1864 |
+
previous_row = ordered_nozzles[index + 1 - column_count : index + 1]
|
| 1865 |
+
anchors = [second, *previous_row]
|
| 1866 |
+
else:
|
| 1867 |
+
row_first = ordered_nozzles[index + 1 - second_column]
|
| 1868 |
+
anchors = [first, second, row_first]
|
| 1869 |
+
if any(nozzle not in world for nozzle in anchors):
|
| 1870 |
+
missing_count += 1
|
| 1871 |
+
continue
|
| 1872 |
+
|
| 1873 |
+
(second_min_x, second_min_y), _second_max = world[second]
|
| 1874 |
+
if second_column == 0:
|
| 1875 |
+
gap_x = second_min_x - world[previous_row[0]][0][0]
|
| 1876 |
+
gap_y = second_min_y - max(world[nozzle][1][1] for nozzle in previous_row)
|
| 1877 |
+
else:
|
| 1878 |
+
gap_x = second_min_x - world[first][1][0]
|
| 1879 |
+
gap_y = second_min_y - world[row_first][0][1]
|
| 1880 |
+
spacing_rows[index][2] = round(gap_x, 4)
|
| 1881 |
+
spacing_rows[index][3] = round(gap_y, 4)
|
| 1882 |
aligned_count += 1
|
| 1883 |
+
return spacing_rows, column_count, row_count, aligned_count, missing_count
|
| 1884 |
|
| 1885 |
|
| 1886 |
def apply_nozzle_grid_spacing(
|
|
|
|
| 1910 |
)
|
| 1911 |
|
| 1912 |
|
| 1913 |
+
def auto_align_split_parts(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1914 |
records: list[dict] | None,
|
| 1915 |
columns: Any,
|
| 1916 |
rows: Any,
|
| 1917 |
column_spacing: Any,
|
| 1918 |
row_spacing: Any,
|
|
|
|
| 1919 |
) -> tuple:
|
| 1920 |
+
records = records or []
|
| 1921 |
+
grid_shape = _split_grid_shape(records)
|
| 1922 |
+
if grid_shape is not None:
|
| 1923 |
+
columns, rows = grid_shape
|
| 1924 |
+
(
|
| 1925 |
+
spacing_rows,
|
| 1926 |
+
column_count,
|
| 1927 |
+
row_count,
|
| 1928 |
+
aligned_count,
|
| 1929 |
+
missing_count,
|
| 1930 |
+
) = _auto_align_grid_spacing_rows(records, columns, rows, column_spacing, row_spacing)
|
| 1931 |
+
|
| 1932 |
if aligned_count <= 0:
|
| 1933 |
+
if missing_count > 0:
|
| 1934 |
+
status = (
|
| 1935 |
+
"Auto align needs the split pieces' generated G-code: press "
|
| 1936 |
+
"Generate G-Code first (files generated before this feature "
|
| 1937 |
+
"lack the PathOrigin header), then align again."
|
| 1938 |
+
)
|
| 1939 |
+
else:
|
| 1940 |
+
status = "No split-sibling nozzle connections found. Auto align was not applied."
|
| 1941 |
return (
|
|
|
|
|
|
|
|
|
|
| 1942 |
gr.update(),
|
| 1943 |
gr.update(),
|
| 1944 |
gr.update(),
|
| 1945 |
gr.update(),
|
| 1946 |
+
gr.update(),
|
| 1947 |
+
gr.update(),
|
| 1948 |
+
status,
|
| 1949 |
)
|
| 1950 |
|
| 1951 |
+
status = (
|
| 1952 |
+
f"Auto aligned {aligned_count} split nozzle connection(s) from the generated "
|
| 1953 |
+
f"G-code in a {column_count} x {row_count} grid. The exact per-connection "
|
| 1954 |
+
"gaps are in the Advanced Grid Spacing table."
|
| 1955 |
+
)
|
| 1956 |
+
if missing_count:
|
| 1957 |
+
status += (
|
| 1958 |
+
f" \n{missing_count} connection(s) skipped: regenerate G-code for those "
|
| 1959 |
+
"shapes to add the PathOrigin header, then align again."
|
| 1960 |
+
)
|
| 1961 |
return (
|
| 1962 |
+
gr.update(value=column_count),
|
| 1963 |
+
gr.update(value=row_count),
|
|
|
|
| 1964 |
gr.update(),
|
| 1965 |
gr.update(),
|
| 1966 |
gr.update(value=True),
|
|
|
|
| 1969 |
value=spacing_rows,
|
| 1970 |
row_count=(len(spacing_rows), "fixed"),
|
| 1971 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1972 |
),
|
| 1973 |
+
status,
|
| 1974 |
)
|
| 1975 |
|
| 1976 |
|
|
|
|
| 1991 |
return gr.update(value=max(1, _coerce_int(columns, 1))), gr.update(value=max(1, _coerce_int(rows, 1)))
|
| 1992 |
|
| 1993 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1994 |
def update_lead_in_options_visibility(enabled: bool | None) -> dict[str, Any]:
|
| 1995 |
return gr.update(visible=bool(enabled))
|
| 1996 |
|
|
|
|
| 2033 |
files: Any,
|
| 2034 |
records: list[dict] | None,
|
| 2035 |
settings_table: Any | None = None,
|
|
|
|
|
|
|
| 2036 |
) -> tuple:
|
| 2037 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2038 |
next_records = _records_from_files(files, records)
|
| 2039 |
settings = _shape_settings_rows(next_records)
|
|
|
|
| 2040 |
return (
|
| 2041 |
next_records,
|
| 2042 |
settings,
|
|
|
|
| 2043 |
_dropdown_update(next_records),
|
| 2044 |
_gcode_dropdown_update(next_records),
|
| 2045 |
_gcode_dropdown_update(next_records, include_upload=True),
|
|
|
|
| 2051 |
files: Any,
|
| 2052 |
records: list[dict] | None,
|
| 2053 |
settings_table: Any | None = None,
|
|
|
|
|
|
|
| 2054 |
) -> tuple:
|
| 2055 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2056 |
paths = [str(SAMPLE_STL_DIR / filename) for filename in SAMPLE_STL_FILENAMES if (SAMPLE_STL_DIR / filename).exists()]
|
| 2057 |
merged_paths = _append_file_paths(files, paths)
|
| 2058 |
return (
|
| 2059 |
gr.update(value=merged_paths),
|
| 2060 |
+
*sync_uploaded_shapes(merged_paths, records, None),
|
| 2061 |
)
|
| 2062 |
|
| 2063 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2064 |
def _shape_delete_outputs(
|
| 2065 |
records: list[dict],
|
|
|
|
|
|
|
| 2066 |
last_delete_at: float | None,
|
| 2067 |
upload_update: Any | None = None,
|
| 2068 |
) -> tuple:
|
|
|
|
| 2070 |
upload_update if upload_update is not None else gr.update(),
|
| 2071 |
records,
|
| 2072 |
_shape_settings_rows(records),
|
|
|
|
| 2073 |
_dropdown_update(records),
|
| 2074 |
_gcode_dropdown_update(records),
|
| 2075 |
_gcode_dropdown_update(records, include_upload=True),
|
|
|
|
| 2081 |
def delete_shape_from_settings(
|
| 2082 |
records: list[dict] | None,
|
| 2083 |
settings_table: Any | None,
|
|
|
|
|
|
|
| 2084 |
last_delete_at: float | None,
|
| 2085 |
evt: gr.SelectData,
|
| 2086 |
) -> tuple:
|
|
|
|
| 2089 |
selected = getattr(evt, "index", None)
|
| 2090 |
current_records = _apply_shape_settings(records or [], settings_table)
|
| 2091 |
if not isinstance(selected, (list, tuple)) or len(selected) < 2:
|
| 2092 |
+
return _shape_delete_outputs(current_records, last_delete_at)
|
| 2093 |
|
| 2094 |
try:
|
| 2095 |
row_index, column_index = int(selected[0]), int(selected[1])
|
| 2096 |
except (TypeError, ValueError):
|
| 2097 |
+
return _shape_delete_outputs(current_records, last_delete_at)
|
| 2098 |
delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
|
| 2099 |
if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
|
| 2100 |
+
return _shape_delete_outputs(current_records, last_delete_at)
|
| 2101 |
if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
|
| 2102 |
+
return _shape_delete_outputs(current_records, last_delete_at)
|
| 2103 |
|
| 2104 |
try:
|
| 2105 |
delete_idx = int(float(rows[row_index][0]))
|
|
|
|
| 2112 |
upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
|
| 2113 |
return _shape_delete_outputs(
|
| 2114 |
next_records,
|
|
|
|
|
|
|
| 2115 |
now,
|
| 2116 |
gr.update(value=upload_paths),
|
| 2117 |
)
|
|
|
|
| 2191 |
return normalized, _shape_settings_rows(normalized)
|
| 2192 |
|
| 2193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2194 |
def show_selected_model(
|
| 2195 |
records: list[dict] | None,
|
| 2196 |
selected: str | None,
|
|
|
|
| 2257 |
settings_table: Any,
|
| 2258 |
layer_height: float,
|
| 2259 |
scale_mode: str | None,
|
| 2260 |
+
fil_width: float = 0.8,
|
| 2261 |
progress: gr.Progress = gr.Progress(),
|
| 2262 |
) -> tuple:
|
| 2263 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
| 2286 |
)
|
| 2287 |
except Exception as exc:
|
| 2288 |
messages.append(f"Shape {record['idx']}: failed ({exc}).")
|
| 2289 |
+
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
| 2290 |
if ref_layers is not None:
|
| 2291 |
messages.append("Reference layers: updated automatically.")
|
| 2292 |
else:
|
|
|
|
| 2294 |
return records, "\n".join(messages), ref_layers
|
| 2295 |
|
| 2296 |
|
| 2297 |
+
def generate_dynamic_reference_stack(
|
| 2298 |
+
records: list[dict] | None,
|
| 2299 |
+
fil_width: float = 0.8,
|
| 2300 |
+
) -> LayerStack | None:
|
| 2301 |
+
# Snapping the alignment to the fil grid keeps split pieces' scan-grid
|
| 2302 |
+
# phase intact under shared reference motion (exact one-fil seam pitch).
|
| 2303 |
+
return build_reference_stack(
|
| 2304 |
+
[record.get("layer_stack") for record in (records or [])],
|
| 2305 |
+
grid=float(fil_width) if fil_width else None,
|
| 2306 |
+
)
|
| 2307 |
|
| 2308 |
|
| 2309 |
def split_selected_shape_for_grid(
|
| 2310 |
records: list[dict] | None,
|
| 2311 |
selected: str | None,
|
| 2312 |
settings_table: Any | None,
|
|
|
|
|
|
|
| 2313 |
columns: float,
|
| 2314 |
rows: float,
|
| 2315 |
overlapping_layers: bool,
|
|
|
|
| 2323 |
return (
|
| 2324 |
next_records,
|
| 2325 |
_shape_settings_rows(next_records),
|
|
|
|
| 2326 |
_dropdown_update(next_records, selected_value),
|
| 2327 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2328 |
_gcode_dropdown_update(next_records),
|
|
|
|
| 2355 |
rows=split_row_count,
|
| 2356 |
overlapping_layers=bool(overlapping_layers),
|
| 2357 |
overlap=float(fil_width) if overlapping_layers else 0.0,
|
| 2358 |
+
# Whole-fil cells (last piece absorbs the remainder): keeps the
|
| 2359 |
+
# required nozzle spacing uniform under shared reference motion.
|
| 2360 |
+
grid=float(fil_width),
|
| 2361 |
)
|
| 2362 |
except Exception as exc:
|
| 2363 |
return _outputs(records, selected, f"Split failed: {exc}")
|
|
|
|
| 2467 |
) -> tuple:
|
| 2468 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2469 |
messages: list[str] = []
|
| 2470 |
+
resliced = _ensure_records_sliced(records, layer_height, scale_mode, messages)
|
| 2471 |
+
if use_reference_motion:
|
| 2472 |
+
# Always rebuild with the CURRENT fil width: the reference stack's
|
| 2473 |
+
# alignment snap grid must match the fil the G-code is generated with.
|
| 2474 |
+
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
| 2475 |
+
elif resliced:
|
| 2476 |
+
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
| 2477 |
contour_sources = _contour_tracing_sources(records)
|
| 2478 |
if contour_sources:
|
| 2479 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
|
|
|
| 2502 |
raster_pattern=raster_pattern,
|
| 2503 |
contour_sources=contour_sources,
|
| 2504 |
active_contour_owner=int(record.get("idx", 0)),
|
| 2505 |
+
infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
|
| 2506 |
lead_in_enabled=bool(lead_in_enabled),
|
| 2507 |
lead_in_length=float(lead_in_length),
|
| 2508 |
lead_in_clearance=float(lead_in_clearance),
|
|
|
|
| 2558 |
return parts, messages
|
| 2559 |
|
| 2560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2561 |
def render_dynamic_nozzle_spacing(
|
| 2562 |
records: list[dict] | None,
|
|
|
|
| 2563 |
columns: Any,
|
| 2564 |
rows: Any,
|
| 2565 |
column_spacing: Any,
|
| 2566 |
row_spacing: Any,
|
| 2567 |
use_grid_individual_spacing: bool,
|
| 2568 |
grid_spacing_table: Any,
|
|
|
|
|
|
|
| 2569 |
) -> tuple[Any, str]:
|
| 2570 |
parts, _messages = _parts_from_records(records)
|
| 2571 |
if not parts:
|
| 2572 |
return None, "No shape G-code available. Generate G-code first."
|
| 2573 |
+
offsets, spacings = _resolve_nozzle_grid_layout(
|
| 2574 |
parts,
|
|
|
|
| 2575 |
columns,
|
| 2576 |
rows,
|
| 2577 |
column_spacing,
|
| 2578 |
row_spacing,
|
| 2579 |
use_grid_individual_spacing,
|
| 2580 |
grid_spacing_table,
|
|
|
|
|
|
|
| 2581 |
)
|
| 2582 |
return build_nozzle_spacing_figure(parts, offsets, spacings), _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 2583 |
|
|
|
|
| 2648 |
travel_opacity: float,
|
| 2649 |
filament_width: float,
|
| 2650 |
travel_width: float,
|
|
|
|
| 2651 |
columns: Any,
|
| 2652 |
rows: Any,
|
| 2653 |
column_spacing: Any,
|
| 2654 |
row_spacing: Any,
|
| 2655 |
use_grid_individual_spacing: bool,
|
| 2656 |
grid_spacing_table: Any,
|
|
|
|
|
|
|
| 2657 |
tube: bool = True,
|
| 2658 |
) -> tuple[Any, str]:
|
| 2659 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2660 |
parts, messages = _parts_from_records(records)
|
| 2661 |
if not parts:
|
| 2662 |
return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
|
| 2663 |
+
offsets, spacings = _resolve_nozzle_grid_layout(
|
| 2664 |
parts,
|
|
|
|
| 2665 |
columns,
|
| 2666 |
rows,
|
| 2667 |
column_spacing,
|
| 2668 |
row_spacing,
|
| 2669 |
use_grid_individual_spacing,
|
| 2670 |
grid_spacing_table,
|
|
|
|
|
|
|
| 2671 |
)
|
| 2672 |
figure = build_parallel_figure(
|
| 2673 |
parts,
|
|
|
|
| 2701 |
records: list[dict] | None,
|
| 2702 |
settings_table: Any,
|
| 2703 |
travel_opacity: float,
|
|
|
|
| 2704 |
columns: Any,
|
| 2705 |
rows: Any,
|
| 2706 |
column_spacing: Any,
|
| 2707 |
row_spacing: Any,
|
| 2708 |
use_grid_individual_spacing: bool,
|
| 2709 |
grid_spacing_table: Any,
|
|
|
|
|
|
|
| 2710 |
duration: float,
|
| 2711 |
fps: float,
|
| 2712 |
elev: float,
|
|
|
|
| 2717 |
parts, _messages = _parts_from_records(records)
|
| 2718 |
if not parts:
|
| 2719 |
return None
|
| 2720 |
+
offsets, _spacings = _resolve_nozzle_grid_layout(
|
| 2721 |
parts,
|
|
|
|
| 2722 |
columns,
|
| 2723 |
rows,
|
| 2724 |
column_spacing,
|
| 2725 |
row_spacing,
|
| 2726 |
use_grid_individual_spacing,
|
| 2727 |
grid_spacing_table,
|
|
|
|
|
|
|
| 2728 |
)
|
| 2729 |
|
| 2730 |
def report(frame: int, total: int) -> None:
|
|
|
|
| 2857 |
gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
|
| 2858 |
|
| 2859 |
with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
|
| 2860 |
+
with gr.Group():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2861 |
with gr.Row():
|
| 2862 |
nozzle_grid_preset = gr.Dropdown(
|
| 2863 |
label="Common Layout",
|
|
|
|
| 2871 |
nozzle_grid_row_spacing = gr.Number(label="Row Gap (Y, mm)", value=0.0, step=0.1)
|
| 2872 |
with gr.Row():
|
| 2873 |
auto_align_split_parts_button = gr.Button("Auto Align Split Parts", variant="secondary", size="sm")
|
| 2874 |
+
nozzle_grid_use_individual_spacing = gr.Checkbox(label="Advanced Grid Spacing", value=False)
|
| 2875 |
+
# Always visible: the "Advanced Grid Spacing" checkbox
|
| 2876 |
+
# controls whether these per-connection gaps are USED,
|
| 2877 |
+
# not whether the table shows. Toggling visibility from
|
| 2878 |
+
# events proved racy (the table wouldn't appear on the
|
| 2879 |
+
# first Auto Align click).
|
| 2880 |
nozzle_grid_spacing_table = gr.Dataframe(
|
| 2881 |
headers=ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 2882 |
value=[],
|
| 2883 |
row_count=(0, "fixed"),
|
| 2884 |
column_count=(len(ADVANCED_NOZZLE_SPACING_HEADERS), "fixed"),
|
| 2885 |
interactive=True,
|
| 2886 |
+
label="Advanced Grid Spacing (used when the checkbox is on)",
|
|
|
|
| 2887 |
elem_id="nozzle-grid-spacing-table",
|
| 2888 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2889 |
nozzle_preview_button = gr.Button("Visualize Nozzle Spacing", variant="secondary", elem_id="visualize-nozzle-spacing-button")
|
| 2890 |
with gr.Row():
|
| 2891 |
with gr.Column(scale=3, min_width=420):
|
|
|
|
| 2979 |
nozzle_grid_use_individual_spacing,
|
| 2980 |
]
|
| 2981 |
|
| 2982 |
+
shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
|
| 2983 |
+
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 2984 |
fn=lambda records: _dropdown_update(records),
|
| 2985 |
inputs=[shape_records],
|
| 2986 |
outputs=[split_source],
|
|
|
|
| 2991 |
outputs=[nozzle_grid_spacing_table],
|
| 2992 |
queue=False,
|
| 2993 |
)
|
| 2994 |
+
sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 2995 |
fn=lambda records: _dropdown_update(records),
|
| 2996 |
inputs=[shape_records],
|
| 2997 |
outputs=[split_source],
|
|
|
|
| 3002 |
outputs=[nozzle_grid_spacing_table],
|
| 3003 |
queue=False,
|
| 3004 |
)
|
| 3005 |
+
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 3006 |
fn=lambda records: _dropdown_update(records),
|
| 3007 |
inputs=[shape_records],
|
| 3008 |
outputs=[split_source],
|
|
|
|
| 3015 |
)
|
| 3016 |
shape_settings.select(
|
| 3017 |
fn=delete_shape_from_settings,
|
| 3018 |
+
inputs=[shape_records, shape_settings, last_shape_delete_at],
|
| 3019 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 3020 |
).then(
|
| 3021 |
fn=lambda records: _dropdown_update(records),
|
|
|
|
| 3031 |
|
| 3032 |
preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
|
| 3033 |
shape_settings.change(
|
| 3034 |
+
fn=normalize_shape_dimensions_for_mode,
|
| 3035 |
+
inputs=[shape_records, shape_settings, scale_mode],
|
| 3036 |
+
outputs=[shape_records, shape_settings],
|
| 3037 |
queue=False,
|
| 3038 |
).then(
|
| 3039 |
fn=_grid_spacing_table_update,
|
|
|
|
| 3066 |
|
| 3067 |
generate_button.click(
|
| 3068 |
fn=generate_dynamic_layer_stacks,
|
| 3069 |
+
inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
|
| 3070 |
outputs=[shape_records, slicer_status, ref_layers],
|
| 3071 |
).then(
|
| 3072 |
fn=lambda records: _dropdown_update(records),
|
|
|
|
| 3082 |
shape_records,
|
| 3083 |
split_source,
|
| 3084 |
shape_settings,
|
|
|
|
|
|
|
| 3085 |
split_columns,
|
| 3086 |
split_rows,
|
| 3087 |
split_overlapping_layers,
|
|
|
|
| 3092 |
outputs=[
|
| 3093 |
shape_records,
|
| 3094 |
shape_settings,
|
|
|
|
| 3095 |
selected_shape,
|
| 3096 |
gcode_downloads,
|
| 3097 |
gcode_text_source,
|
|
|
|
| 3101 |
],
|
| 3102 |
).then(
|
| 3103 |
fn=generate_dynamic_reference_stack,
|
| 3104 |
+
inputs=[shape_records, fil_width],
|
| 3105 |
outputs=[ref_layers],
|
| 3106 |
).then(
|
| 3107 |
fn=_grid_spacing_table_update,
|
|
|
|
| 3142 |
)
|
| 3143 |
gcode_text_source.change(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
| 3144 |
refresh_gcode_text_button.click(fn=load_selected_gcode_text, inputs=[shape_records, gcode_text_source], outputs=[gcode_text])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3145 |
auto_align_split_parts_button.click(
|
| 3146 |
+
fn=auto_align_split_parts,
|
| 3147 |
inputs=[
|
| 3148 |
shape_records,
|
| 3149 |
nozzle_grid_columns,
|
| 3150 |
nozzle_grid_rows,
|
| 3151 |
nozzle_grid_column_spacing,
|
| 3152 |
nozzle_grid_row_spacing,
|
|
|
|
| 3153 |
],
|
| 3154 |
outputs=[
|
| 3155 |
+
nozzle_grid_columns,
|
| 3156 |
+
nozzle_grid_rows,
|
|
|
|
| 3157 |
nozzle_grid_column_spacing,
|
| 3158 |
nozzle_grid_row_spacing,
|
| 3159 |
nozzle_grid_use_individual_spacing,
|
|
|
|
| 3180 |
nozzle_grid_row_spacing,
|
| 3181 |
nozzle_grid_use_individual_spacing,
|
| 3182 |
):
|
| 3183 |
+
# .input (user edits only), NOT .change: Auto Align sets these
|
| 3184 |
+
# controls programmatically and .change listeners would fire and
|
| 3185 |
+
# rebuild the spacing table, clobbering the aligned gaps it just
|
| 3186 |
+
# wrote. Flows that set them programmatically (preset dropdown,
|
| 3187 |
+
# Auto Align) update the table explicitly themselves.
|
| 3188 |
+
grid_spacing_control.input(
|
| 3189 |
fn=_grid_spacing_table_update,
|
| 3190 |
inputs=grid_spacing_refresh_inputs,
|
| 3191 |
outputs=[nozzle_grid_spacing_table],
|
| 3192 |
queue=False,
|
| 3193 |
)
|
|
|
|
| 3194 |
nozzle_preview_button.click(
|
| 3195 |
fn=render_dynamic_nozzle_spacing,
|
| 3196 |
inputs=[
|
| 3197 |
shape_records,
|
|
|
|
| 3198 |
nozzle_grid_columns,
|
| 3199 |
nozzle_grid_rows,
|
| 3200 |
nozzle_grid_column_spacing,
|
| 3201 |
nozzle_grid_row_spacing,
|
| 3202 |
nozzle_grid_use_individual_spacing,
|
| 3203 |
nozzle_grid_spacing_table,
|
|
|
|
|
|
|
| 3204 |
],
|
| 3205 |
outputs=[nozzle_spacing_plot, nozzle_spacing_status],
|
| 3206 |
)
|
|
|
|
| 3247 |
pp_travel_opacity,
|
| 3248 |
pp_filament_width,
|
| 3249 |
pp_travel_width,
|
|
|
|
| 3250 |
nozzle_grid_columns,
|
| 3251 |
nozzle_grid_rows,
|
| 3252 |
nozzle_grid_column_spacing,
|
| 3253 |
nozzle_grid_row_spacing,
|
| 3254 |
nozzle_grid_use_individual_spacing,
|
| 3255 |
nozzle_grid_spacing_table,
|
|
|
|
|
|
|
| 3256 |
]
|
| 3257 |
parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
|
| 3258 |
parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
|
|
|
|
| 3265 |
shape_records,
|
| 3266 |
shape_settings,
|
| 3267 |
pp_gif_travel_opacity,
|
|
|
|
| 3268 |
nozzle_grid_columns,
|
| 3269 |
nozzle_grid_rows,
|
| 3270 |
nozzle_grid_column_spacing,
|
| 3271 |
nozzle_grid_row_spacing,
|
| 3272 |
nozzle_grid_use_individual_spacing,
|
| 3273 |
nozzle_grid_spacing_table,
|
|
|
|
|
|
|
| 3274 |
pp_gif_duration,
|
| 3275 |
pp_gif_fps,
|
| 3276 |
pp_elev,
|
|
@@ -21,6 +21,12 @@ _AXIS_RE = re.compile(r"([XYZxyz])\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?)")
|
|
| 21 |
# Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
|
| 22 |
# generators emit every move as G1 and convey extrusion only through the valve.
|
| 23 |
_VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def parse_gcode_path(gcode_text: str) -> dict:
|
|
@@ -143,6 +149,13 @@ def parse_gcode_path(gcode_text: str) -> dict:
|
|
| 143 |
next_print_layer = z_to_layer[round(move["end"][2], 6)]
|
| 144 |
move["layer"] = next_print_layer
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return {
|
| 147 |
"print_segments": print_segments,
|
| 148 |
"travel_segments": travel_segments,
|
|
@@ -150,6 +163,7 @@ def parse_gcode_path(gcode_text: str) -> dict:
|
|
| 150 |
"layer_count": len(print_z),
|
| 151 |
"bounds": bounds,
|
| 152 |
"point_count": len(all_x),
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
|
|
|
|
| 21 |
# Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
|
| 22 |
# generators emit every move as G1 and convey extrusion only through the valve.
|
| 23 |
_VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
|
| 24 |
+
# World anchor written by this app's generator: the absolute position, in the
|
| 25 |
+
# shape's own coordinate frame, that the relative toolpath starts from.
|
| 26 |
+
_PATH_ORIGIN_RE = re.compile(
|
| 27 |
+
r"^;\s*PathOrigin\s+X([-+]?[\d.]+(?:e[-+]?\d+)?)\s+Y([-+]?[\d.]+(?:e[-+]?\d+)?)",
|
| 28 |
+
re.MULTILINE,
|
| 29 |
+
)
|
| 30 |
|
| 31 |
|
| 32 |
def parse_gcode_path(gcode_text: str) -> dict:
|
|
|
|
| 149 |
next_print_layer = z_to_layer[round(move["end"][2], 6)]
|
| 150 |
move["layer"] = next_print_layer
|
| 151 |
|
| 152 |
+
origin_match = _PATH_ORIGIN_RE.search(gcode_text)
|
| 153 |
+
path_origin = (
|
| 154 |
+
(float(origin_match.group(1)), float(origin_match.group(2)))
|
| 155 |
+
if origin_match
|
| 156 |
+
else None
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
return {
|
| 160 |
"print_segments": print_segments,
|
| 161 |
"travel_segments": travel_segments,
|
|
|
|
| 163 |
"layer_count": len(print_z),
|
| 164 |
"bounds": bounds,
|
| 165 |
"point_count": len(all_x),
|
| 166 |
+
"path_origin": path_origin,
|
| 167 |
}
|
| 168 |
|
| 169 |
|
|
@@ -25,6 +25,15 @@ class LayerStack:
|
|
| 25 |
`bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
|
| 26 |
pieces it is the piece's nominal cell box, which keeps reference-stack
|
| 27 |
centring stable even when the clipped geometry shrinks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
"""
|
| 29 |
|
| 30 |
layers: list[MultiPolygon]
|
|
@@ -32,6 +41,9 @@ class LayerStack:
|
|
| 32 |
bounds: Bounds3D
|
| 33 |
layer_height: float
|
| 34 |
name: str = ""
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
|
|
| 25 |
`bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
|
| 26 |
pieces it is the piece's nominal cell box, which keeps reference-stack
|
| 27 |
centring stable even when the clipped geometry shrinks.
|
| 28 |
+
|
| 29 |
+
`scan_frame` is the XY box the raster scan grid is anchored to. Grid-split
|
| 30 |
+
pieces inherit the parent shape's frame so all pieces raster on one
|
| 31 |
+
continuous line grid and seams keep an exact one-fil-width pitch.
|
| 32 |
+
|
| 33 |
+
`align_center`/`align_grid` are stamped by `build_reference_stack` on the
|
| 34 |
+
combined reference stack: the common centre shapes were aligned to, and
|
| 35 |
+
the grid the alignment deltas were snapped to — so later alignment of a
|
| 36 |
+
shape against the reference reproduces exactly the same translation.
|
| 37 |
"""
|
| 38 |
|
| 39 |
layers: list[MultiPolygon]
|
|
|
|
| 41 |
bounds: Bounds3D
|
| 42 |
layer_height: float
|
| 43 |
name: str = ""
|
| 44 |
+
scan_frame: tuple[float, float, float, float] | None = None
|
| 45 |
+
align_center: tuple[float, float] | None = None
|
| 46 |
+
align_grid: float | None = None
|
| 47 |
|
| 48 |
|
| 49 |
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
|
|
@@ -6,23 +6,21 @@ from app import (
|
|
| 6 |
ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 7 |
SCALE_MODE_UNIFORM_FACTOR,
|
| 8 |
SHAPE_SETTINGS_HEADERS,
|
| 9 |
-
SIMPLE_NOZZLE_SPACING_HEADERS,
|
| 10 |
-
_auto_align_split_offsets,
|
| 11 |
_auto_align_grid_spacing_rows,
|
| 12 |
_apply_shape_settings,
|
| 13 |
_contour_tracing_sources,
|
| 14 |
delete_shape_from_settings,
|
| 15 |
_format_nozzle_spacing_status,
|
| 16 |
_grid_spacing_rows,
|
|
|
|
| 17 |
_records_from_files,
|
| 18 |
_shape_settings_rows,
|
| 19 |
-
_spacing_args_from_table,
|
| 20 |
-
_spacing_table_update,
|
| 21 |
normalize_shape_dimensions_for_mode,
|
| 22 |
_resolve_nozzle_grid_layout,
|
| 23 |
-
_resolve_nozzle_layout,
|
| 24 |
)
|
| 25 |
from vector_toolpath import (
|
|
|
|
|
|
|
| 26 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 27 |
RASTER_PATTERN_Y_DIRECTION,
|
| 28 |
)
|
|
@@ -41,145 +39,17 @@ def _part(
|
|
| 41 |
}
|
| 42 |
|
| 43 |
|
| 44 |
-
def test_calculated_nozzle_layout_uses_requested_part_gap() -> None:
|
| 45 |
-
parts = [
|
| 46 |
-
_part(1, ((0.0, 0.0, 0.0), (10.0, 20.0, 1.0))),
|
| 47 |
-
_part(2, ((0.0, 0.0, 0.0), (5.0, 6.0, 1.0))),
|
| 48 |
-
_part(3, ((-2.0, -1.0, 0.0), (2.0, 3.0, 1.0))),
|
| 49 |
-
]
|
| 50 |
-
|
| 51 |
-
offsets, spacings = _resolve_nozzle_layout(
|
| 52 |
-
parts,
|
| 53 |
-
same_spacing=False,
|
| 54 |
-
part_gap_12_x=2.5,
|
| 55 |
-
part_gap_12_y=-1.0,
|
| 56 |
-
part_gap_23_x=8.0,
|
| 57 |
-
part_gap_23_y=4.0,
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 61 |
-
np.testing.assert_allclose(offsets[2], (12.5, -1.0))
|
| 62 |
-
np.testing.assert_allclose(offsets[3], (27.5, 3.0))
|
| 63 |
-
assert spacings[0] == {"from": 1, "to": 2, "dx": 12.5, "dy": -1.0}
|
| 64 |
-
assert spacings[1] == {"from": 2, "to": 3, "dx": 15.0, "dy": 4.0}
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def test_calculated_nozzle_layout_allows_negative_x_overlap() -> None:
|
| 68 |
-
parts = [
|
| 69 |
-
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 70 |
-
_part(2, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 71 |
-
]
|
| 72 |
-
|
| 73 |
-
offsets, spacings = _resolve_nozzle_layout(
|
| 74 |
-
parts,
|
| 75 |
-
same_spacing=True,
|
| 76 |
-
part_gap_12_x=-2.0,
|
| 77 |
-
part_gap_12_y=3.0,
|
| 78 |
-
part_gap_23_x=0.0,
|
| 79 |
-
part_gap_23_y=0.0,
|
| 80 |
-
)
|
| 81 |
-
|
| 82 |
-
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 83 |
-
np.testing.assert_allclose(offsets[2], (8.0, 3.0))
|
| 84 |
-
assert spacings == [{"from": 1, "to": 2, "dx": 8.0, "dy": 3.0}]
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def test_nozzle_layout_groups_shapes_that_share_a_nozzle() -> None:
|
| 88 |
-
parts = [
|
| 89 |
-
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0)), nozzle=1),
|
| 90 |
-
_part(2, ((-1.0, -2.0, 0.0), (12.0, 4.0, 1.0)), nozzle=1),
|
| 91 |
-
_part(3, ((0.0, 0.0, 0.0), (6.0, 5.0, 1.0)), nozzle=2),
|
| 92 |
-
]
|
| 93 |
-
|
| 94 |
-
offsets, spacings = _resolve_nozzle_layout(
|
| 95 |
-
parts,
|
| 96 |
-
same_spacing=True,
|
| 97 |
-
part_gap_12_x=5.0,
|
| 98 |
-
part_gap_12_y=1.5,
|
| 99 |
-
part_gap_23_x=0.0,
|
| 100 |
-
part_gap_23_y=0.0,
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
assert sorted(offsets) == [1, 2]
|
| 104 |
-
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 105 |
-
np.testing.assert_allclose(offsets[2], (17.0, 1.5))
|
| 106 |
-
assert spacings == [{"from": 1, "to": 2, "dx": 17.0, "dy": 1.5}]
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def test_same_spacing_reuses_first_pair_values_for_second_pair() -> None:
|
| 110 |
-
parts = [
|
| 111 |
-
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 112 |
-
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 113 |
-
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 114 |
-
]
|
| 115 |
-
|
| 116 |
-
offsets, spacings = _resolve_nozzle_layout(
|
| 117 |
-
parts,
|
| 118 |
-
same_spacing=True,
|
| 119 |
-
part_gap_12_x=3.0,
|
| 120 |
-
part_gap_12_y=2.0,
|
| 121 |
-
part_gap_23_x=20.0,
|
| 122 |
-
part_gap_23_y=10.0,
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 126 |
-
np.testing.assert_allclose(offsets[2], (13.0, 2.0))
|
| 127 |
-
np.testing.assert_allclose(offsets[3], (20.0, 4.0))
|
| 128 |
-
assert spacings[0] == {"from": 1, "to": 2, "dx": 13.0, "dy": 2.0}
|
| 129 |
-
assert spacings[1] == {"from": 2, "to": 3, "dx": 7.0, "dy": 2.0}
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def test_individual_spacing_has_no_fixed_shape_limit() -> None:
|
| 133 |
-
parts = [
|
| 134 |
-
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 135 |
-
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 136 |
-
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 137 |
-
_part(4, ((0.0, 0.0, 0.0), (2.0, 10.0, 1.0))),
|
| 138 |
-
_part(5, ((0.0, 0.0, 0.0), (3.0, 10.0, 1.0))),
|
| 139 |
-
_part(6, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 140 |
-
_part(7, ((0.0, 0.0, 0.0), (5.0, 10.0, 1.0))),
|
| 141 |
-
]
|
| 142 |
-
|
| 143 |
-
offsets, spacings = _resolve_nozzle_layout(
|
| 144 |
-
parts,
|
| 145 |
-
False,
|
| 146 |
-
1.0,
|
| 147 |
-
0.0,
|
| 148 |
-
2.0,
|
| 149 |
-
3.0,
|
| 150 |
-
4.0,
|
| 151 |
-
-1.0,
|
| 152 |
-
6.0,
|
| 153 |
-
2.0,
|
| 154 |
-
1.0,
|
| 155 |
-
1.0,
|
| 156 |
-
-3.0,
|
| 157 |
-
0.5,
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
np.testing.assert_allclose(offsets[1], (0.0, 0.0))
|
| 161 |
-
np.testing.assert_allclose(offsets[2], (11.0, 0.0))
|
| 162 |
-
np.testing.assert_allclose(offsets[3], (17.0, 3.0))
|
| 163 |
-
np.testing.assert_allclose(offsets[4], (27.0, 2.0))
|
| 164 |
-
np.testing.assert_allclose(offsets[7], (40.0, 5.5))
|
| 165 |
-
assert spacings[2] == {"from": 3, "to": 4, "dx": 10.0, "dy": -1.0}
|
| 166 |
-
assert spacings[5] == {"from": 6, "to": 7, "dx": 1.0, "dy": 0.5}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
| 170 |
parts = [
|
| 171 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 172 |
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 173 |
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 174 |
]
|
| 175 |
-
offsets,
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
part_gap_23_x=20.0,
|
| 181 |
-
part_gap_23_y=10.0,
|
| 182 |
-
)
|
| 183 |
|
| 184 |
status = _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 185 |
|
|
@@ -255,6 +125,39 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
|
|
| 255 |
assert updated[0]["contour_tracing"] is True
|
| 256 |
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
|
| 259 |
from shapely.geometry import MultiPolygon, box
|
| 260 |
|
|
@@ -300,105 +203,234 @@ def test_sample_reload_appends_next_nozzle_set() -> None:
|
|
| 300 |
assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
|
| 301 |
|
| 302 |
|
| 303 |
-
def
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
]
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
-
update = _spacing_table_update(records, [["Shape 1", "Shape 2", 7.0, -1.5]], False)
|
| 311 |
-
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], False)
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
assert (gap12x, gap12y, gap23x, gap23y, extra) == (7.0, -1.5, 7.0, -1.5, [])
|
| 316 |
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
{"idx": 3, "name": "third"},
|
| 323 |
-
]
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
)
|
| 330 |
-
gap12x, gap12y, gap23x, gap23y, extra = _spacing_args_from_table(update["value"], True)
|
| 331 |
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
assert (gap12x, gap12y, gap23x, gap23y, extra) == (4.0, 0.5, 8.0, 1.0, [])
|
| 336 |
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
def test_advanced_spacing_table_collapses_duplicate_nozzles() -> None:
|
| 339 |
-
records = [
|
| 340 |
-
{"idx": 1, "name": "first", "nozzle": 1},
|
| 341 |
-
{"idx": 2, "name": "second", "nozzle": 1},
|
| 342 |
-
{"idx": 3, "name": "third", "nozzle": 2},
|
| 343 |
-
]
|
| 344 |
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
-
|
| 348 |
-
|
|
|
|
| 349 |
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
-
def test_auto_align_split_offsets_follow_raster_axis() -> None:
|
| 352 |
-
assert _auto_align_split_offsets(RASTER_PATTERN_SAME_DIRECTION) == (-3.2, -0.8)
|
| 353 |
-
assert _auto_align_split_offsets(RASTER_PATTERN_Y_DIRECTION) == (-0.8, -3.2)
|
| 354 |
|
|
|
|
|
|
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
)
|
| 372 |
|
| 373 |
-
assert
|
| 374 |
-
assert
|
| 375 |
-
|
| 376 |
-
assert
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
-
|
|
|
|
| 384 |
records = [
|
| 385 |
{"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
|
| 386 |
{"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
|
| 387 |
-
{"idx": 3, "name": "third", "nozzle": 3, "split_group_id": "split-a", "split_index": 2},
|
| 388 |
]
|
| 389 |
|
| 390 |
-
rows, _column_count, _row_count, aligned_count =
|
| 391 |
-
records,
|
| 392 |
-
columns=2,
|
| 393 |
-
rows=2,
|
| 394 |
-
column_spacing=10.0,
|
| 395 |
-
row_spacing=3.0,
|
| 396 |
-
raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
|
| 397 |
)
|
| 398 |
|
| 399 |
-
assert aligned_count ==
|
| 400 |
-
assert
|
| 401 |
-
assert rows
|
| 402 |
|
| 403 |
|
| 404 |
def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
|
|
@@ -407,16 +439,12 @@ def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
|
|
| 407 |
{"idx": 2, "name": "second", "nozzle": 2},
|
| 408 |
]
|
| 409 |
|
| 410 |
-
rows, _column_count, _row_count, aligned_count =
|
| 411 |
-
records,
|
| 412 |
-
columns=2,
|
| 413 |
-
rows=1,
|
| 414 |
-
column_spacing=10.0,
|
| 415 |
-
row_spacing=3.0,
|
| 416 |
-
raster_pattern=RASTER_PATTERN_SAME_DIRECTION,
|
| 417 |
)
|
| 418 |
|
| 419 |
assert aligned_count == 0
|
|
|
|
| 420 |
assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
|
| 421 |
|
| 422 |
|
|
@@ -530,7 +558,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
|
| 530 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 531 |
]
|
| 532 |
|
| 533 |
-
outputs = delete_shape_from_settings(records, _shape_settings_rows(records),
|
| 534 |
updated_records = outputs[1]
|
| 535 |
updated_settings = outputs[2]
|
| 536 |
|
|
@@ -552,12 +580,10 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
|
| 552 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 553 |
]
|
| 554 |
|
| 555 |
-
first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records),
|
| 556 |
second_outputs = delete_shape_from_settings(
|
| 557 |
first_outputs[1],
|
| 558 |
first_outputs[2],
|
| 559 |
-
None,
|
| 560 |
-
False,
|
| 561 |
first_outputs[-1],
|
| 562 |
Event(),
|
| 563 |
)
|
|
|
|
| 6 |
ADVANCED_NOZZLE_SPACING_HEADERS,
|
| 7 |
SCALE_MODE_UNIFORM_FACTOR,
|
| 8 |
SHAPE_SETTINGS_HEADERS,
|
|
|
|
|
|
|
| 9 |
_auto_align_grid_spacing_rows,
|
| 10 |
_apply_shape_settings,
|
| 11 |
_contour_tracing_sources,
|
| 12 |
delete_shape_from_settings,
|
| 13 |
_format_nozzle_spacing_status,
|
| 14 |
_grid_spacing_rows,
|
| 15 |
+
_parts_from_records,
|
| 16 |
_records_from_files,
|
| 17 |
_shape_settings_rows,
|
|
|
|
|
|
|
| 18 |
normalize_shape_dimensions_for_mode,
|
| 19 |
_resolve_nozzle_grid_layout,
|
|
|
|
| 20 |
)
|
| 21 |
from vector_toolpath import (
|
| 22 |
+
RASTER_PATTERN_CIRCLE_SPIRAL,
|
| 23 |
+
RASTER_PATTERN_RECTANGULAR_SPIRAL,
|
| 24 |
RASTER_PATTERN_SAME_DIRECTION,
|
| 25 |
RASTER_PATTERN_Y_DIRECTION,
|
| 26 |
)
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def test_spacing_status_lists_all_nozzle_pair_distances() -> None:
|
| 43 |
parts = [
|
| 44 |
_part(1, ((0.0, 0.0, 0.0), (10.0, 10.0, 1.0))),
|
| 45 |
_part(2, ((0.0, 0.0, 0.0), (4.0, 10.0, 1.0))),
|
| 46 |
_part(3, ((0.0, 0.0, 0.0), (6.0, 10.0, 1.0))),
|
| 47 |
]
|
| 48 |
+
offsets = {1: (0.0, 0.0), 2: (13.0, 2.0), 3: (20.0, 4.0)}
|
| 49 |
+
spacings = [
|
| 50 |
+
{"from": 1, "to": 2, "dx": 13.0, "dy": 2.0},
|
| 51 |
+
{"from": 2, "to": 3, "dx": 7.0, "dy": 2.0},
|
| 52 |
+
]
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
status = _format_nozzle_spacing_status(parts, offsets, spacings)
|
| 55 |
|
|
|
|
| 125 |
assert updated[0]["contour_tracing"] is True
|
| 126 |
|
| 127 |
|
| 128 |
+
def test_shape_settings_round_trip_infill_column() -> None:
|
| 129 |
+
records = [
|
| 130 |
+
{
|
| 131 |
+
"idx": 1,
|
| 132 |
+
"name": "circle",
|
| 133 |
+
"stl_path": "circle.stl",
|
| 134 |
+
"target_x": 10.0,
|
| 135 |
+
"target_y": 11.0,
|
| 136 |
+
"target_z": 12.0,
|
| 137 |
+
"pressure": 25.0,
|
| 138 |
+
"valve": 4,
|
| 139 |
+
"nozzle": 1,
|
| 140 |
+
"port": 1,
|
| 141 |
+
"color": "#111111",
|
| 142 |
+
"contour_tracing": False,
|
| 143 |
+
}
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
rows = _shape_settings_rows(records)
|
| 147 |
+
infill_pos = SHAPE_SETTINGS_HEADERS.index("Infill %")
|
| 148 |
+
assert rows[0][infill_pos] == 100.0
|
| 149 |
+
|
| 150 |
+
rows[0][infill_pos] = 50
|
| 151 |
+
updated = _apply_shape_settings(records, rows)
|
| 152 |
+
assert updated[0]["infill"] == 50.0
|
| 153 |
+
|
| 154 |
+
# Out-of-range values clamp to 0..100.
|
| 155 |
+
rows[0][infill_pos] = 250
|
| 156 |
+
assert _apply_shape_settings(records, rows)[0]["infill"] == 100.0
|
| 157 |
+
rows[0][infill_pos] = -10
|
| 158 |
+
assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
|
| 159 |
+
|
| 160 |
+
|
| 161 |
def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
|
| 162 |
from shapely.geometry import MultiPolygon, box
|
| 163 |
|
|
|
|
| 203 |
assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
|
| 204 |
|
| 205 |
|
| 206 |
+
def _split_piece_records(
|
| 207 |
+
tmp_path,
|
| 208 |
+
columns: int = 2,
|
| 209 |
+
rows: int = 2,
|
| 210 |
+
use_reference_motion: bool = False,
|
| 211 |
+
overlapping_layers: bool = False,
|
| 212 |
+
raster_pattern: str | None = None,
|
| 213 |
+
) -> list[dict]:
|
| 214 |
+
"""Split a rectangle stack into grid pieces and generate real G-code."""
|
| 215 |
+
from shapely.geometry import MultiPolygon, box
|
| 216 |
+
|
| 217 |
+
from stl_slicer import LayerStack
|
| 218 |
+
from vector_gcode import generate_vector_gcode
|
| 219 |
+
from vector_toolpath import build_reference_stack, split_layer_stack_grid
|
| 220 |
+
|
| 221 |
+
layer = MultiPolygon([box(0.0, 0.0, 8.0, 6.0)])
|
| 222 |
+
stack = LayerStack(
|
| 223 |
+
layers=[layer, layer],
|
| 224 |
+
z_values=[0.5, 1.5],
|
| 225 |
+
bounds=((0.0, 0.0, 0.0), (8.0, 6.0, 2.0)),
|
| 226 |
+
layer_height=1.0,
|
| 227 |
+
name="source",
|
| 228 |
+
)
|
| 229 |
+
pieces = split_layer_stack_grid(
|
| 230 |
+
stack,
|
| 231 |
+
columns=columns,
|
| 232 |
+
rows=rows,
|
| 233 |
+
overlapping_layers=overlapping_layers,
|
| 234 |
+
overlap=1.0 if overlapping_layers else 0.0,
|
| 235 |
+
)
|
| 236 |
+
reference = build_reference_stack(pieces) if use_reference_motion else None
|
| 237 |
+
|
| 238 |
+
records: list[dict] = []
|
| 239 |
+
for index, piece in enumerate(pieces):
|
| 240 |
+
gcode_path = generate_vector_gcode(
|
| 241 |
+
piece,
|
| 242 |
+
shape_name=f"piece_{index}",
|
| 243 |
+
pressure=25,
|
| 244 |
+
valve=4 + index,
|
| 245 |
+
port=1,
|
| 246 |
+
fil_width=1.0,
|
| 247 |
+
motion=reference,
|
| 248 |
+
raster_pattern=raster_pattern,
|
| 249 |
+
output_dir=tmp_path / f"piece_{index}",
|
| 250 |
+
)
|
| 251 |
+
records.append(
|
| 252 |
+
{
|
| 253 |
+
"idx": index + 1,
|
| 254 |
+
"name": piece.name,
|
| 255 |
+
"nozzle": index + 1,
|
| 256 |
+
"color": "#000000",
|
| 257 |
+
"gcode_path": str(gcode_path),
|
| 258 |
+
"split_group_id": "split-a",
|
| 259 |
+
"split_index": index,
|
| 260 |
+
"split_row": index // columns + 1,
|
| 261 |
+
"split_col": index % columns + 1,
|
| 262 |
+
"split_rows": rows,
|
| 263 |
+
"split_columns": columns,
|
| 264 |
+
}
|
| 265 |
+
)
|
| 266 |
+
return records
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _assert_aligned_offsets_reassemble(records: list[dict], spacing_rows, columns, rows) -> None:
|
| 270 |
+
"""Offsets from the aligned table must equal each piece's world anchor + const."""
|
| 271 |
+
parts, _messages = _parts_from_records(records)
|
| 272 |
+
offsets, _spacings = _resolve_nozzle_grid_layout(
|
| 273 |
+
parts,
|
| 274 |
+
columns,
|
| 275 |
+
rows,
|
| 276 |
+
column_spacing=5.0,
|
| 277 |
+
row_spacing=5.0,
|
| 278 |
+
use_individual_spacing=True,
|
| 279 |
+
spacing_table=spacing_rows,
|
| 280 |
+
)
|
| 281 |
+
anchors = {part["nozzle"]: part["parsed"]["path_origin"] for part in parts}
|
| 282 |
+
constants = [
|
| 283 |
+
(offsets[nozzle][0] - anchors[nozzle][0], offsets[nozzle][1] - anchors[nozzle][1])
|
| 284 |
+
for nozzle in sorted(offsets)
|
| 285 |
]
|
| 286 |
+
for constant in constants[1:]:
|
| 287 |
+
assert abs(constant[0] - constants[0][0]) < 5e-4
|
| 288 |
+
assert abs(constant[1] - constants[0][1]) < 5e-4
|
| 289 |
|
|
|
|
|
|
|
| 290 |
|
| 291 |
+
def test_auto_align_reassembles_grid_split_pieces(tmp_path) -> None:
|
| 292 |
+
records = _split_piece_records(tmp_path, columns=2, rows=2)
|
|
|
|
| 293 |
|
| 294 |
+
spacing_rows, column_count, row_count, aligned_count, missing_count = (
|
| 295 |
+
_auto_align_grid_spacing_rows(records, 2, 2, 5.0, 5.0)
|
| 296 |
+
)
|
| 297 |
|
| 298 |
+
assert (column_count, row_count) == (2, 2)
|
| 299 |
+
assert aligned_count == 3
|
| 300 |
+
assert missing_count == 0
|
| 301 |
+
_assert_aligned_offsets_reassemble(records, spacing_rows, 2, 2)
|
|
|
|
|
|
|
| 302 |
|
| 303 |
+
|
| 304 |
+
def test_auto_align_handles_reference_motion_and_overlap(tmp_path) -> None:
|
| 305 |
+
records = _split_piece_records(
|
| 306 |
+
tmp_path,
|
| 307 |
+
columns=2,
|
| 308 |
+
rows=2,
|
| 309 |
+
use_reference_motion=True,
|
| 310 |
+
overlapping_layers=True,
|
| 311 |
)
|
|
|
|
| 312 |
|
| 313 |
+
spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
|
| 314 |
+
records, 2, 2, 5.0, 5.0
|
| 315 |
+
)
|
|
|
|
| 316 |
|
| 317 |
+
assert aligned_count == 3
|
| 318 |
+
assert missing_count == 0
|
| 319 |
+
_assert_aligned_offsets_reassemble(records, spacing_rows, 2, 2)
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
+
def test_auto_align_handles_y_raster_pattern(tmp_path) -> None:
|
| 323 |
+
records = _split_piece_records(
|
| 324 |
+
tmp_path,
|
| 325 |
+
columns=2,
|
| 326 |
+
rows=1,
|
| 327 |
+
raster_pattern=RASTER_PATTERN_Y_DIRECTION,
|
| 328 |
+
)
|
| 329 |
|
| 330 |
+
spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
|
| 331 |
+
records, 2, 1, 5.0, 5.0
|
| 332 |
+
)
|
| 333 |
|
| 334 |
+
assert aligned_count == 1
|
| 335 |
+
assert missing_count == 0
|
| 336 |
+
_assert_aligned_offsets_reassemble(records, spacing_rows, 2, 1)
|
| 337 |
|
|
|
|
|
|
|
|
|
|
| 338 |
|
| 339 |
+
def test_split_pieces_share_parent_scan_grid_so_column_gaps_equal_fil(tmp_path) -> None:
|
| 340 |
+
"""User scenario: 4-column split + Y raster -> every aligned gap is one fil width.
|
| 341 |
|
| 342 |
+
The parent width (7.9) is deliberately not a multiple of the fil width or
|
| 343 |
+
the cell width, so per-piece centred grids would drift out of phase at
|
| 344 |
+
every seam. With the shared parent scan grid the assembled columns run at
|
| 345 |
+
an exact fil pitch, so the world gap between adjacent pieces' toolpath
|
| 346 |
+
boxes is exactly one fil width.
|
| 347 |
+
"""
|
| 348 |
+
from shapely.geometry import MultiPolygon, box
|
| 349 |
|
| 350 |
+
from stl_slicer import LayerStack
|
| 351 |
+
from vector_gcode import generate_vector_gcode
|
| 352 |
+
from vector_toolpath import RASTER_PATTERN_Y_DIRECTION, split_layer_stack_grid
|
| 353 |
+
|
| 354 |
+
fil = 0.8
|
| 355 |
+
layer = MultiPolygon([box(0.0, 0.0, 7.9, 4.0)])
|
| 356 |
+
stack = LayerStack(
|
| 357 |
+
layers=[layer, layer],
|
| 358 |
+
z_values=[0.5, 1.5],
|
| 359 |
+
bounds=((0.0, 0.0, 0.0), (7.9, 4.0, 2.0)),
|
| 360 |
+
layer_height=1.0,
|
| 361 |
+
name="wide",
|
| 362 |
+
)
|
| 363 |
+
pieces = split_layer_stack_grid(stack, columns=4, rows=1)
|
| 364 |
+
records = []
|
| 365 |
+
for index, piece in enumerate(pieces):
|
| 366 |
+
gcode_path = generate_vector_gcode(
|
| 367 |
+
piece,
|
| 368 |
+
shape_name=f"col_{index}",
|
| 369 |
+
pressure=25,
|
| 370 |
+
valve=4 + index,
|
| 371 |
+
port=1,
|
| 372 |
+
fil_width=fil,
|
| 373 |
+
raster_pattern=RASTER_PATTERN_Y_DIRECTION,
|
| 374 |
+
output_dir=tmp_path / f"col_{index}",
|
| 375 |
+
)
|
| 376 |
+
records.append(
|
| 377 |
+
{
|
| 378 |
+
"idx": index + 1,
|
| 379 |
+
"name": piece.name,
|
| 380 |
+
"nozzle": index + 1,
|
| 381 |
+
"color": "#000000",
|
| 382 |
+
"gcode_path": str(gcode_path),
|
| 383 |
+
"split_group_id": "split-a",
|
| 384 |
+
"split_index": index,
|
| 385 |
+
"split_row": 1,
|
| 386 |
+
"split_col": index + 1,
|
| 387 |
+
"split_rows": 1,
|
| 388 |
+
"split_columns": 4,
|
| 389 |
+
}
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
spacing_rows, _cc, _rc, aligned_count, missing_count = _auto_align_grid_spacing_rows(
|
| 393 |
+
records, 4, 1, 5.0, 5.0
|
| 394 |
)
|
| 395 |
|
| 396 |
+
assert aligned_count == 3
|
| 397 |
+
assert missing_count == 0
|
| 398 |
+
# Adjacent columns continue the parent grid: every gap is exactly one fil.
|
| 399 |
+
assert [row[2] for row in spacing_rows] == [fil, fil, fil]
|
| 400 |
+
_assert_aligned_offsets_reassemble(records, spacing_rows, 4, 1)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_auto_align_handles_spiral_patterns(tmp_path) -> None:
|
| 404 |
+
for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
|
| 405 |
+
records = _split_piece_records(
|
| 406 |
+
tmp_path / pattern.replace(" ", "_"),
|
| 407 |
+
columns=2,
|
| 408 |
+
rows=1,
|
| 409 |
+
raster_pattern=pattern,
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
spacing_rows, _cc, _rc, aligned_count, missing_count = (
|
| 413 |
+
_auto_align_grid_spacing_rows(records, 2, 1, 5.0, 5.0)
|
| 414 |
+
)
|
| 415 |
|
| 416 |
+
assert aligned_count == 1, pattern
|
| 417 |
+
assert missing_count == 0, pattern
|
| 418 |
+
_assert_aligned_offsets_reassemble(records, spacing_rows, 2, 1)
|
| 419 |
|
| 420 |
+
|
| 421 |
+
def test_auto_align_reports_missing_gcode_for_split_siblings(tmp_path) -> None:
|
| 422 |
records = [
|
| 423 |
{"idx": 1, "name": "first", "nozzle": 1, "split_group_id": "split-a", "split_index": 0},
|
| 424 |
{"idx": 2, "name": "second", "nozzle": 2, "split_group_id": "split-a", "split_index": 1},
|
|
|
|
| 425 |
]
|
| 426 |
|
| 427 |
+
rows, _column_count, _row_count, aligned_count, missing_count = (
|
| 428 |
+
_auto_align_grid_spacing_rows(records, 2, 1, 10.0, 3.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
)
|
| 430 |
|
| 431 |
+
assert aligned_count == 0
|
| 432 |
+
assert missing_count == 1
|
| 433 |
+
assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
|
| 434 |
|
| 435 |
|
| 436 |
def test_auto_align_grid_spacing_skips_unsplit_records() -> None:
|
|
|
|
| 439 |
{"idx": 2, "name": "second", "nozzle": 2},
|
| 440 |
]
|
| 441 |
|
| 442 |
+
rows, _column_count, _row_count, aligned_count, missing_count = (
|
| 443 |
+
_auto_align_grid_spacing_rows(records, 2, 1, 10.0, 3.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
)
|
| 445 |
|
| 446 |
assert aligned_count == 0
|
| 447 |
+
assert missing_count == 0
|
| 448 |
assert rows == [["Nozzle 1: Shape 1", "Nozzle 2: Shape 2", 10.0, 0.0]]
|
| 449 |
|
| 450 |
|
|
|
|
| 558 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 559 |
]
|
| 560 |
|
| 561 |
+
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 562 |
updated_records = outputs[1]
|
| 563 |
updated_settings = outputs[2]
|
| 564 |
|
|
|
|
| 580 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 581 |
]
|
| 582 |
|
| 583 |
+
first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 584 |
second_outputs = delete_shape_from_settings(
|
| 585 |
first_outputs[1],
|
| 586 |
first_outputs[2],
|
|
|
|
|
|
|
| 587 |
first_outputs[-1],
|
| 588 |
Event(),
|
| 589 |
)
|
|
@@ -192,11 +192,13 @@ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> No
|
|
| 192 |
]
|
| 193 |
|
| 194 |
assert lines[0] == "G91"
|
| 195 |
-
|
| 196 |
-
assert lines[
|
| 197 |
-
assert lines[
|
| 198 |
-
assert lines[
|
|
|
|
| 199 |
assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
|
|
@@ -515,6 +517,109 @@ def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
|
|
| 515 |
assert end_z == 1.0
|
| 516 |
|
| 517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
def test_layer_contour_loops_follow_polygon_rings() -> None:
|
| 519 |
hollow = MultiPolygon(
|
| 520 |
[
|
|
@@ -753,6 +858,92 @@ def test_reference_motion_shares_path_and_gates_valve_per_shape(tmp_path) -> Non
|
|
| 753 |
assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
|
| 754 |
|
| 755 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
def test_build_reference_stack_unions_center_aligned_layers() -> None:
|
| 757 |
first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
|
| 758 |
second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
|
|
@@ -798,6 +989,76 @@ def test_split_layer_stack_grid_orders_rows_top_down() -> None:
|
|
| 798 |
assert all(piece.layers[0].area == 4.0 for piece in pieces)
|
| 799 |
|
| 800 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
|
| 802 |
layer = box(0.0, 0.0, 4.0, 2.0)
|
| 803 |
stack = _stack(layer, layer, name="interlock")
|
|
|
|
| 192 |
]
|
| 193 |
|
| 194 |
assert lines[0] == "G91"
|
| 195 |
+
# World anchor of the relative toolpath (origin at sweep start (-1, 0.5)).
|
| 196 |
+
assert lines[1] == "; PathOrigin X-1.0 Y0.5"
|
| 197 |
+
assert lines[2] == "{aux_command}WAGO_ValveCommands(7, 0)"
|
| 198 |
+
assert lines[3] == "serialPort3.write(eval(setpress(25)))"
|
| 199 |
+
assert lines[4] == "serialPort3.write(eval(togglepress()))"
|
| 200 |
assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
|
| 201 |
+
assert lines[6].startswith("{aux_command}WAGO_ValveCommands(")
|
| 202 |
|
| 203 |
|
| 204 |
def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
|
|
|
|
| 517 |
assert end_z == 1.0
|
| 518 |
|
| 519 |
|
| 520 |
+
def _total_length(moves: list[dict]) -> float:
|
| 521 |
+
return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def _print_length(moves: list[dict]) -> float:
|
| 525 |
+
return sum(
|
| 526 |
+
math.dist(move["start"][:2], move["end"][:2])
|
| 527 |
+
for move in moves
|
| 528 |
+
if move["color"] == 255
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def test_half_infill_skips_alternate_lines_but_keeps_the_same_path(tmp_path) -> None:
|
| 533 |
+
layer = box(0.0, 0.0, 4.0, 4.0)
|
| 534 |
+
stack = _stack(layer, layer)
|
| 535 |
+
|
| 536 |
+
def _generate(infill: float, label: str):
|
| 537 |
+
path = generate_vector_gcode(
|
| 538 |
+
stack,
|
| 539 |
+
shape_name=label,
|
| 540 |
+
pressure=25,
|
| 541 |
+
valve=7,
|
| 542 |
+
port=3,
|
| 543 |
+
fil_width=1.0,
|
| 544 |
+
layer_height=1.0,
|
| 545 |
+
infill=infill,
|
| 546 |
+
output_dir=tmp_path / label,
|
| 547 |
+
)
|
| 548 |
+
return _moves_with_colors(path.read_text())
|
| 549 |
+
|
| 550 |
+
full = _generate(1.0, "full")
|
| 551 |
+
half = _generate(0.5, "half")
|
| 552 |
+
|
| 553 |
+
# Identical motion: same final position and same total traversed length.
|
| 554 |
+
assert full[-1]["end"] == half[-1]["end"]
|
| 555 |
+
assert abs(_total_length(full) - _total_length(half)) < 1e-6
|
| 556 |
+
|
| 557 |
+
# Half the lines dispense: 2 of the 4 sweeps per layer print.
|
| 558 |
+
assert abs(_print_length(half) - _print_length(full) / 2) < 1e-6
|
| 559 |
+
|
| 560 |
+
# The printing sweeps sit on alternating scanlines (one fil apart x2).
|
| 561 |
+
half_print_rows = sorted({round(m["start"][1], 6) for m in half if m["color"] == 255 and m["start"][2] == 0.0})
|
| 562 |
+
assert len(half_print_rows) == 2
|
| 563 |
+
assert abs((half_print_rows[1] - half_print_rows[0]) - 2.0) < 1e-9
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def test_infill_selection_is_shared_across_reference_motion(tmp_path) -> None:
|
| 567 |
+
small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
|
| 568 |
+
big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
|
| 569 |
+
reference = build_reference_stack([small, big])
|
| 570 |
+
|
| 571 |
+
def _generate(stack: LayerStack, infill: float, label: str):
|
| 572 |
+
path = generate_vector_gcode(
|
| 573 |
+
stack,
|
| 574 |
+
shape_name=label,
|
| 575 |
+
pressure=25,
|
| 576 |
+
valve=7,
|
| 577 |
+
port=3,
|
| 578 |
+
fil_width=1.0,
|
| 579 |
+
motion=reference,
|
| 580 |
+
infill=infill,
|
| 581 |
+
output_dir=tmp_path / label,
|
| 582 |
+
)
|
| 583 |
+
return _moves_with_colors(path.read_text())
|
| 584 |
+
|
| 585 |
+
sparse = _generate(small, 0.5, "sparse")
|
| 586 |
+
dense = _generate(big, 1.0, "dense")
|
| 587 |
+
|
| 588 |
+
# Different infill per shape, one shared motion path.
|
| 589 |
+
assert sparse[-1]["end"] == dense[-1]["end"]
|
| 590 |
+
assert abs(_total_length(sparse) - _total_length(dense)) < 1e-6
|
| 591 |
+
assert 0 < _print_length(sparse) < _print_length(dense)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def test_spiral_infill_skips_rings_and_keeps_the_path(tmp_path) -> None:
|
| 595 |
+
layer = box(0.0, 0.0, 6.0, 6.0)
|
| 596 |
+
stack = _stack(layer)
|
| 597 |
+
|
| 598 |
+
def _generate(pattern: str, infill: float, label: str):
|
| 599 |
+
path = generate_vector_gcode(
|
| 600 |
+
stack,
|
| 601 |
+
shape_name=label,
|
| 602 |
+
pressure=25,
|
| 603 |
+
valve=7,
|
| 604 |
+
port=3,
|
| 605 |
+
fil_width=1.0,
|
| 606 |
+
raster_pattern=pattern,
|
| 607 |
+
infill=infill,
|
| 608 |
+
output_dir=tmp_path / label,
|
| 609 |
+
)
|
| 610 |
+
return _moves_with_colors(path.read_text())
|
| 611 |
+
|
| 612 |
+
for pattern in (RASTER_PATTERN_RECTANGULAR_SPIRAL, RASTER_PATTERN_CIRCLE_SPIRAL):
|
| 613 |
+
full = _generate(pattern, 1.0, f"{pattern}-full".replace(" ", "_"))
|
| 614 |
+
half = _generate(pattern, 0.5, f"{pattern}-half".replace(" ", "_"))
|
| 615 |
+
# Identical path within the writer's micron-level delta rounding
|
| 616 |
+
# (segment split points differ, so the rounding accumulates
|
| 617 |
+
# differently by up to ~1 um over tens of thousands of moves).
|
| 618 |
+
assert math.dist(full[-1]["end"], half[-1]["end"]) < 1e-4, pattern
|
| 619 |
+
assert abs(_total_length(full) - _total_length(half)) < 1e-3, pattern
|
| 620 |
+
assert 0 < _print_length(half) < _print_length(full), pattern
|
| 621 |
+
|
| 622 |
+
|
| 623 |
def test_layer_contour_loops_follow_polygon_rings() -> None:
|
| 624 |
hollow = MultiPolygon(
|
| 625 |
[
|
|
|
|
| 858 |
assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
|
| 859 |
|
| 860 |
|
| 861 |
+
def test_reference_motion_contours_share_path_and_gate_valve_per_shape(tmp_path) -> None:
|
| 862 |
+
small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
|
| 863 |
+
big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
|
| 864 |
+
reference = build_reference_stack([small, big])
|
| 865 |
+
assert reference is not None
|
| 866 |
+
contour_sources = [
|
| 867 |
+
ContourSource(owner_idx=1, stack=small),
|
| 868 |
+
ContourSource(owner_idx=2, stack=big),
|
| 869 |
+
]
|
| 870 |
+
|
| 871 |
+
def _generate(stack: LayerStack, owner: int, label: str):
|
| 872 |
+
path = generate_vector_gcode(
|
| 873 |
+
stack,
|
| 874 |
+
shape_name=label,
|
| 875 |
+
pressure=25,
|
| 876 |
+
valve=7,
|
| 877 |
+
port=3,
|
| 878 |
+
fil_width=1.0,
|
| 879 |
+
motion=reference,
|
| 880 |
+
contour_sources=contour_sources,
|
| 881 |
+
active_contour_owner=owner,
|
| 882 |
+
output_dir=tmp_path / label,
|
| 883 |
+
)
|
| 884 |
+
return _moves_with_colors(path.read_text())
|
| 885 |
+
|
| 886 |
+
small_moves = _generate(small, 1, "small")
|
| 887 |
+
big_moves = _generate(big, 2, "big")
|
| 888 |
+
|
| 889 |
+
# The motion including EVERY shape's contour tour is identical: same final
|
| 890 |
+
# position and same total traversed length for both heads.
|
| 891 |
+
assert small_moves[-1]["end"] == big_moves[-1]["end"]
|
| 892 |
+
|
| 893 |
+
def _total_length(moves: list[dict]) -> float:
|
| 894 |
+
return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
|
| 895 |
+
|
| 896 |
+
assert abs(_total_length(small_moves) - _total_length(big_moves)) < 1e-6
|
| 897 |
+
|
| 898 |
+
# In the shared frame (origin at the motion sweep start (-2, -0.5); big is
|
| 899 |
+
# re-centred to (-1,-1)..(3,3), small stays (0,0)..(2,2)):
|
| 900 |
+
small_corners = {(2.0, 0.5), (4.0, 0.5), (4.0, 2.5), (2.0, 2.5)}
|
| 901 |
+
big_corners = {(1.0, -0.5), (5.0, -0.5), (5.0, 3.5), (1.0, 3.5)}
|
| 902 |
+
|
| 903 |
+
def _endpoints(moves: list[dict], color: int) -> set[tuple[float, float]]:
|
| 904 |
+
return {
|
| 905 |
+
(round(move["end"][0], 6), round(move["end"][1], 6))
|
| 906 |
+
for move in moves
|
| 907 |
+
if move["color"] == color
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
# Each shape PRINTS its own outline and TRAVELS the other shape's outline.
|
| 911 |
+
assert small_corners <= _endpoints(small_moves, 255)
|
| 912 |
+
assert big_corners <= _endpoints(small_moves, 0)
|
| 913 |
+
assert big_corners <= _endpoints(big_moves, 255)
|
| 914 |
+
assert small_corners <= _endpoints(big_moves, 0)
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def test_solo_contours_still_trace_only_own_shape(tmp_path) -> None:
|
| 918 |
+
small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
|
| 919 |
+
big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
|
| 920 |
+
contour_sources = [
|
| 921 |
+
ContourSource(owner_idx=1, stack=small),
|
| 922 |
+
ContourSource(owner_idx=2, stack=big),
|
| 923 |
+
]
|
| 924 |
+
|
| 925 |
+
gcode_path = generate_vector_gcode(
|
| 926 |
+
small,
|
| 927 |
+
shape_name="solo",
|
| 928 |
+
pressure=25,
|
| 929 |
+
valve=7,
|
| 930 |
+
port=3,
|
| 931 |
+
fil_width=1.0,
|
| 932 |
+
contour_sources=contour_sources,
|
| 933 |
+
active_contour_owner=1,
|
| 934 |
+
output_dir=tmp_path,
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
moves = _moves_with_colors(gcode_path.read_text())
|
| 938 |
+
# Without reference motion the other shape's contour must NOT be traced:
|
| 939 |
+
# nothing ever moves outside the small shape's buffered footprint
|
| 940 |
+
# (origin at world (-1, 0.5), so relative x spans [0, 4], y [-0.5, 1.5];
|
| 941 |
+
# the big shape's contour would reach (5.0, 3.5)).
|
| 942 |
+
for move in moves:
|
| 943 |
+
assert -0.5 <= move["end"][0] <= 4.2
|
| 944 |
+
assert -1.0 <= move["end"][1] <= 2.0
|
| 945 |
+
|
| 946 |
+
|
| 947 |
def test_build_reference_stack_unions_center_aligned_layers() -> None:
|
| 948 |
first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
|
| 949 |
second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
|
|
|
|
| 989 |
assert all(piece.layers[0].area == 4.0 for piece in pieces)
|
| 990 |
|
| 991 |
|
| 992 |
+
def test_grid_split_pads_equal_whole_fil_cells() -> None:
|
| 993 |
+
# 20.0 / 4 = 5.0 mm cells, which is 6.25 fil widths — not representable.
|
| 994 |
+
# With `grid`, every cell rounds UP to 7 fils (5.6 mm) and the 2.4 mm
|
| 995 |
+
# leftover becomes blank margin split evenly outside the outer edges.
|
| 996 |
+
layer = box(0.0, 0.0, 20.0, 4.0)
|
| 997 |
+
stack = _stack(layer, name="wide")
|
| 998 |
+
|
| 999 |
+
pieces = split_layer_stack_grid(stack, columns=4, rows=1, grid=0.8)
|
| 1000 |
+
|
| 1001 |
+
widths = {round(piece.bounds[1][0] - piece.bounds[0][0], 6) for piece in pieces}
|
| 1002 |
+
assert widths == {5.6}
|
| 1003 |
+
# Padding is centred: 1.2 mm of blank space beyond each outer edge.
|
| 1004 |
+
assert round(pieces[0].bounds[0][0], 6) == -1.2
|
| 1005 |
+
assert round(pieces[-1].bounds[1][0], 6) == 21.2
|
| 1006 |
+
# No material is lost or duplicated by the padded cells.
|
| 1007 |
+
total_area = sum(piece.layers[0].area for piece in pieces)
|
| 1008 |
+
assert abs(total_area - layer.area) < 1e-9
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
def test_grid_split_reference_deltas_are_uniform() -> None:
|
| 1012 |
+
from vector_toolpath import _centering_delta
|
| 1013 |
+
|
| 1014 |
+
layer = box(0.0, 0.0, 20.0, 4.0)
|
| 1015 |
+
stack = _stack(layer, name="wide")
|
| 1016 |
+
pieces = split_layer_stack_grid(stack, columns=4, rows=1, grid=0.8)
|
| 1017 |
+
reference = build_reference_stack(pieces, grid=0.8)
|
| 1018 |
+
assert reference is not None
|
| 1019 |
+
|
| 1020 |
+
deltas = [_centering_delta(piece, reference)[0] for piece in pieces]
|
| 1021 |
+
diffs = {round(a - b, 6) for a, b in zip(deltas, deltas[1:])}
|
| 1022 |
+
# Uniform spacing between every consecutive pair (one cell = 7 fils),
|
| 1023 |
+
# so the physical nozzle offsets are the same for every connection.
|
| 1024 |
+
assert diffs == {5.6}
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
+
def test_split_overlap_seam_raster_distance_is_equal_on_both_sides() -> None:
|
| 1028 |
+
from vector_toolpath import _axis_raster_segments
|
| 1029 |
+
|
| 1030 |
+
# 7.5 mm is deliberately not a multiple of the 1 mm fil width, so raster
|
| 1031 |
+
# quantization leaves slack. The slack must be split evenly: both pieces'
|
| 1032 |
+
# lines sit the same distance from the (shifted) cut on every layer.
|
| 1033 |
+
layer = MultiPolygon([box(0.0, 0.0, 7.5, 4.0)])
|
| 1034 |
+
stack = LayerStack(
|
| 1035 |
+
layers=[layer, layer],
|
| 1036 |
+
z_values=[0.5, 1.5],
|
| 1037 |
+
bounds=((0.0, 0.0, 0.0), (7.5, 4.0, 2.0)),
|
| 1038 |
+
layer_height=1.0,
|
| 1039 |
+
name="seam",
|
| 1040 |
+
)
|
| 1041 |
+
left, right = split_layer_stack_grid(
|
| 1042 |
+
stack, columns=2, rows=1, overlapping_layers=True, overlap=0.5
|
| 1043 |
+
)
|
| 1044 |
+
|
| 1045 |
+
for layer_number in range(2):
|
| 1046 |
+
left_columns = sorted(
|
| 1047 |
+
{seg[0] for seg in _axis_raster_segments(
|
| 1048 |
+
left.layers[layer_number], left.layers[layer_number], 1.0, "Y"
|
| 1049 |
+
) if seg[4] == 255}
|
| 1050 |
+
)
|
| 1051 |
+
right_columns = sorted(
|
| 1052 |
+
{seg[0] for seg in _axis_raster_segments(
|
| 1053 |
+
right.layers[layer_number], right.layers[layer_number], 1.0, "Y"
|
| 1054 |
+
) if seg[4] == 255}
|
| 1055 |
+
)
|
| 1056 |
+
seam = left.layers[layer_number].bounds[2]
|
| 1057 |
+
left_distance = seam - left_columns[-1]
|
| 1058 |
+
right_distance = right_columns[0] - seam
|
| 1059 |
+
assert abs(left_distance - right_distance) < 1e-9
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
|
| 1063 |
layer = box(0.0, 0.0, 4.0, 2.0)
|
| 1064 |
stack = _stack(layer, layer, name="interlock")
|
|
@@ -22,8 +22,10 @@ from vector_toolpath import (
|
|
| 22 |
RASTER_PATTERN_WOODPILE,
|
| 23 |
RASTER_PATTERN_Y_DIRECTION,
|
| 24 |
ContourSource,
|
|
|
|
| 25 |
_lead_in_moves,
|
| 26 |
_normalize_raster_pattern,
|
|
|
|
| 27 |
align_stack_to,
|
| 28 |
build_contour_layers,
|
| 29 |
plan_layer_moves,
|
|
@@ -107,6 +109,7 @@ def write_gcode_file(
|
|
| 107 |
increase_pressure_per_layer: float,
|
| 108 |
pressure_ramp_enabled: bool,
|
| 109 |
all_g1: bool,
|
|
|
|
| 110 |
) -> None:
|
| 111 |
off_color = 0
|
| 112 |
com_port = f"serialPort{port}"
|
|
@@ -120,6 +123,13 @@ def write_gcode_file(
|
|
| 120 |
|
| 121 |
with open(gcode_path, "w") as f:
|
| 122 |
f.write("G91\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
f.write(_valve_cmd(valve, 0))
|
| 124 |
for line in setpress_lines:
|
| 125 |
f.write(f"{line}\n")
|
|
@@ -186,6 +196,7 @@ def generate_vector_gcode(
|
|
| 186 |
motion: LayerStack | None = None,
|
| 187 |
contour_sources: list[ContourSource] | None = None,
|
| 188 |
active_contour_owner: int | None = None,
|
|
|
|
| 189 |
increase_pressure_per_layer: float = 0.1,
|
| 190 |
pressure_ramp_enabled: bool = True,
|
| 191 |
all_g1: bool = False,
|
|
@@ -229,7 +240,20 @@ def generate_vector_gcode(
|
|
| 229 |
reference=contour_reference,
|
| 230 |
)
|
| 231 |
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
motion_layers,
|
| 234 |
valve_layers,
|
| 235 |
fil_width,
|
|
@@ -237,8 +261,20 @@ def generate_vector_gcode(
|
|
| 237 |
raster_pattern,
|
| 238 |
contour_layers,
|
| 239 |
active_contour_owner,
|
|
|
|
|
|
|
|
|
|
| 240 |
)
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
lead_in = _lead_in_moves(
|
| 243 |
lead_in_enabled,
|
| 244 |
lead_in_length,
|
|
@@ -267,5 +303,6 @@ def generate_vector_gcode(
|
|
| 267 |
increase_pressure_per_layer=float(increase_pressure_per_layer),
|
| 268 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 269 |
all_g1=bool(all_g1),
|
|
|
|
| 270 |
)
|
| 271 |
return gcode_path
|
|
|
|
| 22 |
RASTER_PATTERN_WOODPILE,
|
| 23 |
RASTER_PATTERN_Y_DIRECTION,
|
| 24 |
ContourSource,
|
| 25 |
+
_centering_delta,
|
| 26 |
_lead_in_moves,
|
| 27 |
_normalize_raster_pattern,
|
| 28 |
+
_scan_anchor,
|
| 29 |
align_stack_to,
|
| 30 |
build_contour_layers,
|
| 31 |
plan_layer_moves,
|
|
|
|
| 109 |
increase_pressure_per_layer: float,
|
| 110 |
pressure_ramp_enabled: bool,
|
| 111 |
all_g1: bool,
|
| 112 |
+
path_origin: tuple[float, float] | None = None,
|
| 113 |
) -> None:
|
| 114 |
off_color = 0
|
| 115 |
com_port = f"serialPort{port}"
|
|
|
|
| 123 |
|
| 124 |
with open(gcode_path, "w") as f:
|
| 125 |
f.write("G91\n")
|
| 126 |
+
if path_origin is not None:
|
| 127 |
+
# World anchor of the relative toolpath: absolute position (in the
|
| 128 |
+
# shape's own coordinate frame) of the point the moves start from.
|
| 129 |
+
# Lets tools place parallel parts so split pieces reassemble.
|
| 130 |
+
f.write(
|
| 131 |
+
f"; PathOrigin X{_coord(path_origin[0])} Y{_coord(path_origin[1])}\n"
|
| 132 |
+
)
|
| 133 |
f.write(_valve_cmd(valve, 0))
|
| 134 |
for line in setpress_lines:
|
| 135 |
f.write(f"{line}\n")
|
|
|
|
| 196 |
motion: LayerStack | None = None,
|
| 197 |
contour_sources: list[ContourSource] | None = None,
|
| 198 |
active_contour_owner: int | None = None,
|
| 199 |
+
infill: float = 1.0,
|
| 200 |
increase_pressure_per_layer: float = 0.1,
|
| 201 |
pressure_ramp_enabled: bool = True,
|
| 202 |
all_g1: bool = False,
|
|
|
|
| 240 |
reference=contour_reference,
|
| 241 |
)
|
| 242 |
|
| 243 |
+
# Anchor the raster scan grid to the motion stack's frame (a split
|
| 244 |
+
# piece's frame is its parent shape's bounds) so lines stack across
|
| 245 |
+
# layers and stay on one continuous grid across split pieces.
|
| 246 |
+
frame_stack = motion if motion is not None else shape
|
| 247 |
+
if frame_stack.scan_frame is not None:
|
| 248 |
+
frame_x_min, frame_y_min, frame_x_max, frame_y_max = frame_stack.scan_frame
|
| 249 |
+
else:
|
| 250 |
+
(frame_x_min, frame_y_min, _fz), (frame_x_max, frame_y_max, _fz2) = frame_stack.bounds
|
| 251 |
+
scan_anchors = (
|
| 252 |
+
_scan_anchor(frame_x_min, frame_x_max, fil_width),
|
| 253 |
+
_scan_anchor(frame_y_min, frame_y_max, fil_width),
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
gcode_list, toolpath_origin = plan_layer_moves(
|
| 257 |
motion_layers,
|
| 258 |
valve_layers,
|
| 259 |
fil_width,
|
|
|
|
| 261 |
raster_pattern,
|
| 262 |
contour_layers,
|
| 263 |
active_contour_owner,
|
| 264 |
+
shared_motion=motion is not None,
|
| 265 |
+
scan_anchors=scan_anchors,
|
| 266 |
+
infill_fraction=max(0.0, min(1.0, float(infill))),
|
| 267 |
)
|
| 268 |
|
| 269 |
+
# World anchor: the toolpath origin expressed in the shape's own frame.
|
| 270 |
+
# With reference motion the geometry was translated by the centering
|
| 271 |
+
# delta, so subtract it to get back to the shape's coordinates.
|
| 272 |
+
if motion is not None:
|
| 273 |
+
delta_x, delta_y = _centering_delta(shape, motion)
|
| 274 |
+
else:
|
| 275 |
+
delta_x = delta_y = 0.0
|
| 276 |
+
path_origin = (toolpath_origin[0] - delta_x, toolpath_origin[1] - delta_y)
|
| 277 |
+
|
| 278 |
lead_in = _lead_in_moves(
|
| 279 |
lead_in_enabled,
|
| 280 |
lead_in_length,
|
|
|
|
| 303 |
increase_pressure_per_layer=float(increase_pressure_per_layer),
|
| 304 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 305 |
all_g1=bool(all_g1),
|
| 306 |
+
path_origin=path_origin,
|
| 307 |
)
|
| 308 |
return gcode_path
|
|
@@ -227,12 +227,15 @@ def _classify_polyline(
|
|
| 227 |
points: list[tuple[float, float]],
|
| 228 |
valve: MultiPolygon,
|
| 229 |
eps: float = EPS,
|
|
|
|
| 230 |
) -> list[Seg]:
|
| 231 |
"""Split a motion polyline at valve-boundary crossings and color the pieces.
|
| 232 |
|
| 233 |
Preserves path order (a whole-polyline shapely intersection would not).
|
| 234 |
Pieces on the boundary count as printing (`covers`), matching the raster
|
| 235 |
-
convention that boundary-grazing sweeps dispense.
|
|
|
|
|
|
|
| 236 |
"""
|
| 237 |
segments: list[Seg] = []
|
| 238 |
has_valve = valve is not None and not valve.is_empty
|
|
@@ -270,7 +273,10 @@ def _classify_polyline(
|
|
| 270 |
if (t1 - t0) * seg_len <= eps:
|
| 271 |
continue
|
| 272 |
mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
start = _point_along(t0, x0, y0, x1, y1)
|
| 275 |
end = _point_along(t1, x0, y0, x1, y1)
|
| 276 |
_append_colored_segment(segments, *start, *end, color)
|
|
@@ -278,18 +284,68 @@ def _classify_polyline(
|
|
| 278 |
return segments
|
| 279 |
|
| 280 |
|
| 281 |
-
def
|
| 282 |
-
"""
|
| 283 |
-
if hi - lo < fil_width:
|
| 284 |
-
return [(lo + hi) / 2.0]
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
|
| 295 |
def _axis_raster_segments(
|
|
@@ -299,13 +355,18 @@ def _axis_raster_segments(
|
|
| 299 |
axis: str,
|
| 300 |
reverse_order: bool = False,
|
| 301 |
start_forward: bool = True,
|
|
|
|
|
|
|
| 302 |
) -> list[Seg]:
|
| 303 |
"""Snake raster of `motion`, dispensing only inside `valve`.
|
| 304 |
|
| 305 |
Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
|
| 306 |
with columns stacked in X. Each sweep spans from the first to the last
|
| 307 |
motion chord (crossing interior gaps valve-off) and gets a fil_width
|
| 308 |
-
valve-settle travel buffer before and after.
|
|
|
|
|
|
|
|
|
|
| 309 |
"""
|
| 310 |
if motion is None or motion.is_empty:
|
| 311 |
return []
|
|
@@ -316,10 +377,16 @@ def _axis_raster_segments(
|
|
| 316 |
else:
|
| 317 |
scan_lo, scan_hi = min_y, max_y
|
| 318 |
|
| 319 |
-
coords = _scan_coords(scan_lo, scan_hi, fil_width)
|
| 320 |
if reverse_order:
|
| 321 |
coords = coords[::-1]
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
segments: list[Seg] = []
|
| 324 |
sweep_number = 0
|
| 325 |
for coord in coords:
|
|
@@ -329,12 +396,17 @@ def _axis_raster_segments(
|
|
| 329 |
|
| 330 |
sweep_lo = motion_runs[0][0]
|
| 331 |
sweep_hi = motion_runs[-1][1]
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
|
| 339 |
def emit(a: float, b: float, color: int) -> None:
|
| 340 |
if axis == "Y":
|
|
@@ -387,9 +459,13 @@ def _oriented_axis_raster_segments(
|
|
| 387 |
current_x: float,
|
| 388 |
current_y: float,
|
| 389 |
prefer_default: bool = False,
|
|
|
|
|
|
|
| 390 |
) -> list[Seg]:
|
| 391 |
"""Pick the raster orientation whose start is nearest the current position."""
|
| 392 |
-
default_segments = _axis_raster_segments(
|
|
|
|
|
|
|
| 393 |
if prefer_default or not default_segments:
|
| 394 |
return default_segments
|
| 395 |
|
|
@@ -403,6 +479,8 @@ def _oriented_axis_raster_segments(
|
|
| 403 |
axis,
|
| 404 |
reverse_order=reverse_order,
|
| 405 |
start_forward=start_forward,
|
|
|
|
|
|
|
| 406 |
)
|
| 407 |
if segments and segments not in candidates:
|
| 408 |
candidates.append(segments)
|
|
@@ -691,6 +769,29 @@ def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float,
|
|
| 691 |
return last_x, last_y, last_dx, last_dy
|
| 692 |
|
| 693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
def _rewind_trailing_travel(
|
| 695 |
output_list: list[dict],
|
| 696 |
current_x: float,
|
|
@@ -763,39 +864,55 @@ def _append_layer_contours(
|
|
| 763 |
active_owner_idx: int | None,
|
| 764 |
origin_x: float = 0.0,
|
| 765 |
origin_y: float = 0.0,
|
|
|
|
| 766 |
) -> tuple[float, float]:
|
| 767 |
# `origin_x/origin_y` is the world position of the move list's start:
|
| 768 |
# _last_print_reference sums relative moves from zero, so its result must
|
| 769 |
# be shifted back into the world frame the contours live in.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
if layer_number >= len(contour_layers):
|
| 771 |
return current_x, current_y
|
| 772 |
|
| 773 |
active_sources = [
|
| 774 |
source
|
| 775 |
for source in contour_layers[layer_number]
|
| 776 |
-
if source.get("owner_idx") == active_owner_idx
|
| 777 |
and any(len(contour) >= 2 for contour in source.get("contours", []))
|
| 778 |
]
|
| 779 |
if not active_sources:
|
| 780 |
return current_x, current_y
|
| 781 |
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
|
|
|
| 787 |
|
| 788 |
use_infill_reference = True
|
| 789 |
|
| 790 |
for source in active_sources:
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
| 792 |
for contour in source.get("contours", []):
|
| 793 |
if len(contour) < 2:
|
| 794 |
continue
|
| 795 |
if use_infill_reference:
|
| 796 |
-
|
| 797 |
-
output_list
|
|
|
|
|
|
|
| 798 |
)
|
|
|
|
| 799 |
nearest_x += origin_x
|
| 800 |
nearest_y += origin_y
|
| 801 |
else:
|
|
@@ -882,9 +999,25 @@ def plan_layer_moves(
|
|
| 882 |
raster_pattern: str,
|
| 883 |
contour_layers: list[list[dict]] | None = None,
|
| 884 |
active_contour_owner: int | None = None,
|
| 885 |
-
|
| 886 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
|
|
|
| 888 |
gcode_list: list[dict] = []
|
| 889 |
current_x = 0.0
|
| 890 |
current_y = 0.0
|
|
@@ -902,16 +1035,58 @@ def plan_layer_moves(
|
|
| 902 |
fil_width,
|
| 903 |
reverse=layer_number % 2 == 1,
|
| 904 |
)
|
| 905 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
|
| 907 |
points = _rectangular_spiral_polyline(
|
| 908 |
motion.bounds,
|
| 909 |
fil_width,
|
| 910 |
reverse=layer_number % 2 == 1,
|
| 911 |
)
|
| 912 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
else:
|
| 914 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
segments = _oriented_axis_raster_segments(
|
| 916 |
motion,
|
| 917 |
valve,
|
|
@@ -920,6 +1095,8 @@ def plan_layer_moves(
|
|
| 920 |
current_x,
|
| 921 |
current_y,
|
| 922 |
prefer_default=not raster_origin_initialized,
|
|
|
|
|
|
|
| 923 |
)
|
| 924 |
|
| 925 |
if not segments:
|
|
@@ -935,6 +1112,7 @@ def plan_layer_moves(
|
|
| 935 |
active_contour_owner,
|
| 936 |
origin_x,
|
| 937 |
origin_y,
|
|
|
|
| 938 |
)
|
| 939 |
current_x, current_y = _append_relative_move(
|
| 940 |
gcode_list,
|
|
@@ -1009,6 +1187,7 @@ def plan_layer_moves(
|
|
| 1009 |
active_contour_owner,
|
| 1010 |
origin_x,
|
| 1011 |
origin_y,
|
|
|
|
| 1012 |
)
|
| 1013 |
current_x, current_y = _append_relative_move(
|
| 1014 |
gcode_list,
|
|
@@ -1019,7 +1198,7 @@ def plan_layer_moves(
|
|
| 1019 |
0,
|
| 1020 |
)
|
| 1021 |
|
| 1022 |
-
return gcode_list
|
| 1023 |
|
| 1024 |
|
| 1025 |
def _stack_center(stack: LayerStack) -> tuple[float, float]:
|
|
@@ -1027,10 +1206,58 @@ def _stack_center(stack: LayerStack) -> tuple[float, float]:
|
|
| 1027 |
return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
|
| 1028 |
|
| 1029 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
|
| 1031 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
center_x, center_y = _stack_center(stack)
|
| 1033 |
-
return
|
|
|
|
|
|
|
|
|
|
| 1034 |
|
| 1035 |
|
| 1036 |
def align_stack_to(
|
|
@@ -1053,12 +1280,21 @@ def align_stack_to(
|
|
| 1053 |
return layers
|
| 1054 |
|
| 1055 |
|
| 1056 |
-
def build_reference_stack(
|
|
|
|
|
|
|
|
|
|
| 1057 |
"""Union all shapes into one shared motion stack, bbox-centres aligned.
|
| 1058 |
|
| 1059 |
Vector analog of the old centered "black wins" TIFF merge: every stack is
|
| 1060 |
translated so its XY bbox centre lands on the first stack's centre, then
|
| 1061 |
each layer is the union of the translated layers.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
"""
|
| 1063 |
valid = [stack for stack in stacks if stack is not None and stack.layers]
|
| 1064 |
if not valid:
|
|
@@ -1066,15 +1302,22 @@ def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
|
|
| 1066 |
|
| 1067 |
n_layers = max(len(stack.layers) for stack in valid)
|
| 1068 |
reference_x, reference_y = _stack_center(valid[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
|
| 1070 |
layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
|
| 1071 |
x_min = y_min = z_min = math.inf
|
| 1072 |
x_max = y_max = z_max = -math.inf
|
| 1073 |
|
| 1074 |
for stack in valid:
|
| 1075 |
-
|
| 1076 |
-
delta_x = reference_x - center_x
|
| 1077 |
-
delta_y = reference_y - center_y
|
| 1078 |
|
| 1079 |
(sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
|
| 1080 |
x_min = min(x_min, sx_min + delta_x)
|
|
@@ -1103,26 +1346,54 @@ def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
|
|
| 1103 |
break
|
| 1104 |
z_values.append(z_value)
|
| 1105 |
|
|
|
|
|
|
|
| 1106 |
return LayerStack(
|
| 1107 |
layers=layers,
|
| 1108 |
z_values=z_values,
|
| 1109 |
bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
|
| 1110 |
layer_height=valid[0].layer_height,
|
| 1111 |
name="reference",
|
|
|
|
|
|
|
|
|
|
| 1112 |
)
|
| 1113 |
|
| 1114 |
|
| 1115 |
-
def
|
| 1116 |
lo: float,
|
| 1117 |
hi: float,
|
| 1118 |
count: int,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1119 |
layer_index: int,
|
| 1120 |
overlap: float,
|
| 1121 |
) -> list[float]:
|
| 1122 |
-
"""
|
| 1123 |
-
|
| 1124 |
if overlap <= 0.0 or count <= 1:
|
| 1125 |
-
return edges
|
| 1126 |
|
| 1127 |
adjusted = list(edges)
|
| 1128 |
for boundary_index in range(1, count):
|
|
@@ -1144,13 +1415,16 @@ def split_layer_stack_grid(
|
|
| 1144 |
rows: int,
|
| 1145 |
overlapping_layers: bool = False,
|
| 1146 |
overlap: float = 0.0,
|
|
|
|
| 1147 |
) -> list[LayerStack]:
|
| 1148 |
"""Split a sliced shape into a rows x columns grid of piece stacks.
|
| 1149 |
|
| 1150 |
Pieces are returned row-major with row 1 the top strip (max-Y side),
|
| 1151 |
matching the legacy image-grid ordering. With `overlapping_layers`, the
|
| 1152 |
interior cut lines alternate by ±overlap between layers so neighbouring
|
| 1153 |
-
pieces interlock.
|
|
|
|
|
|
|
| 1154 |
"""
|
| 1155 |
columns = max(1, int(columns))
|
| 1156 |
rows = max(1, int(rows))
|
|
@@ -1159,17 +1433,22 @@ def split_layer_stack_grid(
|
|
| 1159 |
overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
|
| 1160 |
overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
|
| 1161 |
|
| 1162 |
-
base_x_edges =
|
| 1163 |
-
base_y_edges =
|
| 1164 |
layer_x_edges = [
|
| 1165 |
-
|
| 1166 |
for index in range(len(stack.layers))
|
| 1167 |
]
|
| 1168 |
layer_y_edges = [
|
| 1169 |
-
|
| 1170 |
for index in range(len(stack.layers))
|
| 1171 |
]
|
| 1172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
base_name = stack.name or "shape"
|
| 1174 |
pieces: list[LayerStack] = []
|
| 1175 |
for row_index in range(1, rows + 1):
|
|
@@ -1203,6 +1482,7 @@ def split_layer_stack_grid(
|
|
| 1203 |
),
|
| 1204 |
layer_height=stack.layer_height,
|
| 1205 |
name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
|
|
|
|
| 1206 |
)
|
| 1207 |
)
|
| 1208 |
|
|
|
|
| 227 |
points: list[tuple[float, float]],
|
| 228 |
valve: MultiPolygon,
|
| 229 |
eps: float = EPS,
|
| 230 |
+
keep_point=None,
|
| 231 |
) -> list[Seg]:
|
| 232 |
"""Split a motion polyline at valve-boundary crossings and color the pieces.
|
| 233 |
|
| 234 |
Preserves path order (a whole-polyline shapely intersection would not).
|
| 235 |
Pieces on the boundary count as printing (`covers`), matching the raster
|
| 236 |
+
convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can
|
| 237 |
+
additionally veto dispensing for a piece (evaluated at its midpoint) —
|
| 238 |
+
used for partial infill; the motion is unaffected.
|
| 239 |
"""
|
| 240 |
segments: list[Seg] = []
|
| 241 |
has_valve = valve is not None and not valve.is_empty
|
|
|
|
| 273 |
if (t1 - t0) * seg_len <= eps:
|
| 274 |
continue
|
| 275 |
mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
|
| 276 |
+
dispensing = valve.covers(Point(mid_x, mid_y))
|
| 277 |
+
if dispensing and keep_point is not None:
|
| 278 |
+
dispensing = keep_point(mid_x, mid_y)
|
| 279 |
+
color = 255 if dispensing else 0
|
| 280 |
start = _point_along(t0, x0, y0, x1, y1)
|
| 281 |
end = _point_along(t1, x0, y0, x1, y1)
|
| 282 |
_append_colored_segment(segments, *start, *end, color)
|
|
|
|
| 284 |
return segments
|
| 285 |
|
| 286 |
|
| 287 |
+
def _infill_line_keep(fraction: float):
|
| 288 |
+
"""Line-selection gate for partial infill; None means print every line.
|
|
|
|
|
|
|
| 289 |
|
| 290 |
+
Uses a Bresenham-style distribution so kept lines spread evenly: line k
|
| 291 |
+
dispenses iff floor((k+1)*f) > floor(k*f). At f=0.5 every other line
|
| 292 |
+
prints; at f=1 all do; at f=0 none. `k` is a global grid index, so the
|
| 293 |
+
same lines are selected across layers, across split pieces sharing a
|
| 294 |
+
parent frame, and across shapes sharing reference motion — the motion
|
| 295 |
+
path itself is never affected.
|
| 296 |
+
"""
|
| 297 |
+
if fraction >= 1.0:
|
| 298 |
+
return None
|
| 299 |
+
fraction = max(0.0, float(fraction))
|
| 300 |
+
|
| 301 |
+
def keep(line_index: int) -> bool:
|
| 302 |
+
return math.floor((line_index + 1) * fraction) - math.floor(line_index * fraction) >= 1
|
| 303 |
+
|
| 304 |
+
return keep
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _scan_anchor(lo: float, hi: float, fil_width: float) -> float:
|
| 308 |
+
"""First scanline position of the centred grid spanning [lo, hi].
|
| 309 |
+
|
| 310 |
+
The grid is centred: when the extent is not an exact multiple of
|
| 311 |
+
fil_width, the leftover slack is split evenly between both edges.
|
| 312 |
+
"""
|
| 313 |
+
extent = hi - lo
|
| 314 |
+
if extent < fil_width:
|
| 315 |
+
return (lo + hi) / 2.0
|
| 316 |
+
count = int(math.floor(extent / fil_width + 1e-9))
|
| 317 |
+
slack = max(0.0, extent - count * fil_width)
|
| 318 |
+
return lo + slack / 2.0 + fil_width / 2.0
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _scan_coords(
|
| 322 |
+
lo: float,
|
| 323 |
+
hi: float,
|
| 324 |
+
fil_width: float,
|
| 325 |
+
anchor: float | None = None,
|
| 326 |
+
) -> list[float]:
|
| 327 |
+
"""Scanline positions in [lo, hi], fil_width apart; at least one line.
|
| 328 |
+
|
| 329 |
+
Without `anchor` the set is centred in [lo, hi] (always at least one
|
| 330 |
+
line). With `anchor` the lines lie on the global grid
|
| 331 |
+
{anchor + k*fil_width}: grid-split pieces anchored to their parent's
|
| 332 |
+
frame then raster on one continuous line grid, so the assembled seams
|
| 333 |
+
keep an exact one-fil-width pitch instead of drifting by each piece's own
|
| 334 |
+
quantization slack. The interval is half-open at `hi` so a line exactly
|
| 335 |
+
on a cut belongs to exactly one piece, and a sliver too thin to contain a
|
| 336 |
+
grid line gets none — the neighbouring grid line (at most one fil away)
|
| 337 |
+
covers it with filament width.
|
| 338 |
+
"""
|
| 339 |
+
if anchor is None:
|
| 340 |
+
anchor = _scan_anchor(lo, hi, fil_width)
|
| 341 |
+
if hi - lo < fil_width:
|
| 342 |
+
return [anchor]
|
| 343 |
+
count = int(math.floor((hi - lo) / fil_width + 1e-9))
|
| 344 |
+
return [anchor + index * fil_width for index in range(count)]
|
| 345 |
+
|
| 346 |
+
k_lo = math.ceil((lo - anchor) / fil_width - 1e-9)
|
| 347 |
+
k_hi = math.floor((hi - anchor) / fil_width - 1e-9)
|
| 348 |
+
return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)]
|
| 349 |
|
| 350 |
|
| 351 |
def _axis_raster_segments(
|
|
|
|
| 355 |
axis: str,
|
| 356 |
reverse_order: bool = False,
|
| 357 |
start_forward: bool = True,
|
| 358 |
+
scan_anchor: float | None = None,
|
| 359 |
+
infill_keep=None,
|
| 360 |
) -> list[Seg]:
|
| 361 |
"""Snake raster of `motion`, dispensing only inside `valve`.
|
| 362 |
|
| 363 |
Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
|
| 364 |
with columns stacked in X. Each sweep spans from the first to the last
|
| 365 |
motion chord (crossing interior gaps valve-off) and gets a fil_width
|
| 366 |
+
valve-settle travel buffer before and after. `scan_anchor` pins the
|
| 367 |
+
scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates
|
| 368 |
+
dispensing per grid line for partial infill: skipped lines are still
|
| 369 |
+
swept, just with the valve closed, so the motion never changes.
|
| 370 |
"""
|
| 371 |
if motion is None or motion.is_empty:
|
| 372 |
return []
|
|
|
|
| 377 |
else:
|
| 378 |
scan_lo, scan_hi = min_y, max_y
|
| 379 |
|
| 380 |
+
coords = _scan_coords(scan_lo, scan_hi, fil_width, anchor=scan_anchor)
|
| 381 |
if reverse_order:
|
| 382 |
coords = coords[::-1]
|
| 383 |
|
| 384 |
+
# Grid index base: orientation-independent, and shared across pieces when
|
| 385 |
+
# anchored to a common frame, so infill line selection lines up at seams.
|
| 386 |
+
index_base = scan_anchor if scan_anchor is not None else _scan_anchor(
|
| 387 |
+
scan_lo, scan_hi, fil_width
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
segments: list[Seg] = []
|
| 391 |
sweep_number = 0
|
| 392 |
for coord in coords:
|
|
|
|
| 396 |
|
| 397 |
sweep_lo = motion_runs[0][0]
|
| 398 |
sweep_hi = motion_runs[-1][1]
|
| 399 |
+
if infill_keep is not None and not infill_keep(
|
| 400 |
+
int(round((coord - index_base) / fil_width))
|
| 401 |
+
):
|
| 402 |
+
valve_runs: list[tuple[float, float]] = []
|
| 403 |
+
else:
|
| 404 |
+
valve_runs = []
|
| 405 |
+
for lo, hi in _chord_runs(valve, axis, coord):
|
| 406 |
+
lo = max(lo, sweep_lo)
|
| 407 |
+
hi = min(hi, sweep_hi)
|
| 408 |
+
if hi - lo > EPS:
|
| 409 |
+
valve_runs.append((lo, hi))
|
| 410 |
|
| 411 |
def emit(a: float, b: float, color: int) -> None:
|
| 412 |
if axis == "Y":
|
|
|
|
| 459 |
current_x: float,
|
| 460 |
current_y: float,
|
| 461 |
prefer_default: bool = False,
|
| 462 |
+
scan_anchor: float | None = None,
|
| 463 |
+
infill_keep=None,
|
| 464 |
) -> list[Seg]:
|
| 465 |
"""Pick the raster orientation whose start is nearest the current position."""
|
| 466 |
+
default_segments = _axis_raster_segments(
|
| 467 |
+
motion, valve, fil_width, axis, scan_anchor=scan_anchor, infill_keep=infill_keep
|
| 468 |
+
)
|
| 469 |
if prefer_default or not default_segments:
|
| 470 |
return default_segments
|
| 471 |
|
|
|
|
| 479 |
axis,
|
| 480 |
reverse_order=reverse_order,
|
| 481 |
start_forward=start_forward,
|
| 482 |
+
scan_anchor=scan_anchor,
|
| 483 |
+
infill_keep=infill_keep,
|
| 484 |
)
|
| 485 |
if segments and segments not in candidates:
|
| 486 |
candidates.append(segments)
|
|
|
|
| 769 |
return last_x, last_y, last_dx, last_dy
|
| 770 |
|
| 771 |
|
| 772 |
+
def _last_motion_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
|
| 773 |
+
"""Endpoint and direction of the last XY move, regardless of valve state.
|
| 774 |
+
|
| 775 |
+
Used instead of `_last_print_reference` when shapes share a reference
|
| 776 |
+
motion path: anchoring contours off the valve state would give every
|
| 777 |
+
shape a different contour tour, breaking the shared path.
|
| 778 |
+
"""
|
| 779 |
+
x = y = 0.0
|
| 780 |
+
last_x = last_y = 0.0
|
| 781 |
+
last_dx = last_dy = 0.0
|
| 782 |
+
for move in output_list:
|
| 783 |
+
dx = float(move.get("X", 0.0))
|
| 784 |
+
dy = float(move.get("Y", 0.0))
|
| 785 |
+
x += dx
|
| 786 |
+
y += dy
|
| 787 |
+
if "Z" not in move and (dx != 0.0 or dy != 0.0):
|
| 788 |
+
last_x = x
|
| 789 |
+
last_y = y
|
| 790 |
+
last_dx = dx
|
| 791 |
+
last_dy = dy
|
| 792 |
+
return last_x, last_y, last_dx, last_dy
|
| 793 |
+
|
| 794 |
+
|
| 795 |
def _rewind_trailing_travel(
|
| 796 |
output_list: list[dict],
|
| 797 |
current_x: float,
|
|
|
|
| 864 |
active_owner_idx: int | None,
|
| 865 |
origin_x: float = 0.0,
|
| 866 |
origin_y: float = 0.0,
|
| 867 |
+
shared_motion: bool = False,
|
| 868 |
) -> tuple[float, float]:
|
| 869 |
# `origin_x/origin_y` is the world position of the move list's start:
|
| 870 |
# _last_print_reference sums relative moves from zero, so its result must
|
| 871 |
# be shifted back into the world frame the contours live in.
|
| 872 |
+
#
|
| 873 |
+
# With `shared_motion` (reference-stack printing) every shape must trace
|
| 874 |
+
# EVERY source's contours in the same order so all parallel heads follow
|
| 875 |
+
# one identical path; the valve opens only on the active owner's contours.
|
| 876 |
+
# Anchoring then uses the last motion move instead of the last print, and
|
| 877 |
+
# the trailing-travel rewind is skipped — both depend on how the raster
|
| 878 |
+
# moves were split at valve boundaries, which differs per shape and would
|
| 879 |
+
# desynchronise the shared path.
|
| 880 |
if layer_number >= len(contour_layers):
|
| 881 |
return current_x, current_y
|
| 882 |
|
| 883 |
active_sources = [
|
| 884 |
source
|
| 885 |
for source in contour_layers[layer_number]
|
| 886 |
+
if (shared_motion or source.get("owner_idx") == active_owner_idx)
|
| 887 |
and any(len(contour) >= 2 for contour in source.get("contours", []))
|
| 888 |
]
|
| 889 |
if not active_sources:
|
| 890 |
return current_x, current_y
|
| 891 |
|
| 892 |
+
if not shared_motion:
|
| 893 |
+
current_x, current_y = _rewind_trailing_travel(
|
| 894 |
+
output_list,
|
| 895 |
+
current_x,
|
| 896 |
+
current_y,
|
| 897 |
+
)
|
| 898 |
|
| 899 |
use_infill_reference = True
|
| 900 |
|
| 901 |
for source in active_sources:
|
| 902 |
+
if shared_motion and source.get("owner_idx") != active_owner_idx:
|
| 903 |
+
color = 0
|
| 904 |
+
else:
|
| 905 |
+
color = 255
|
| 906 |
for contour in source.get("contours", []):
|
| 907 |
if len(contour) < 2:
|
| 908 |
continue
|
| 909 |
if use_infill_reference:
|
| 910 |
+
reference = (
|
| 911 |
+
_last_motion_reference(output_list)
|
| 912 |
+
if shared_motion
|
| 913 |
+
else _last_print_reference(output_list)
|
| 914 |
)
|
| 915 |
+
nearest_x, nearest_y, approach_dx, approach_dy = reference
|
| 916 |
nearest_x += origin_x
|
| 917 |
nearest_y += origin_y
|
| 918 |
else:
|
|
|
|
| 999 |
raster_pattern: str,
|
| 1000 |
contour_layers: list[list[dict]] | None = None,
|
| 1001 |
active_contour_owner: int | None = None,
|
| 1002 |
+
shared_motion: bool = False,
|
| 1003 |
+
scan_anchors: tuple[float, float] | None = None,
|
| 1004 |
+
infill_fraction: float = 1.0,
|
| 1005 |
+
) -> tuple[list[dict], tuple[float, float]]:
|
| 1006 |
+
"""Assemble per-layer segments into a relative move list for all patterns.
|
| 1007 |
+
|
| 1008 |
+
`scan_anchors` = (x_anchor, y_anchor) pins the axis rasters' scanlines to
|
| 1009 |
+
a global grid so lines stack across layers and across split pieces.
|
| 1010 |
+
|
| 1011 |
+
`infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid
|
| 1012 |
+
lines for axis rasters, rings/revolutions for spirals) while the motion
|
| 1013 |
+
path stays exactly the same as at 100% infill.
|
| 1014 |
+
|
| 1015 |
+
Returns the move list and the toolpath origin — the world position the
|
| 1016 |
+
relative moves start from (the first segment start of the first non-empty
|
| 1017 |
+
layer). Callers need it to map relative G-code back into world space.
|
| 1018 |
+
"""
|
| 1019 |
raster_pattern = _normalize_raster_pattern(raster_pattern)
|
| 1020 |
+
infill_keep = _infill_line_keep(infill_fraction)
|
| 1021 |
gcode_list: list[dict] = []
|
| 1022 |
current_x = 0.0
|
| 1023 |
current_y = 0.0
|
|
|
|
| 1035 |
fil_width,
|
| 1036 |
reverse=layer_number % 2 == 1,
|
| 1037 |
)
|
| 1038 |
+
keep_point = None
|
| 1039 |
+
if infill_keep is not None:
|
| 1040 |
+
# A spiral "line" is one revolution: revolution k lies at
|
| 1041 |
+
# radius outer - k*fil from the bbox-circumscribed circle.
|
| 1042 |
+
min_x, min_y, max_x, max_y = motion.bounds
|
| 1043 |
+
center_x = (min_x + max_x) / 2.0
|
| 1044 |
+
center_y = (min_y + max_y) / 2.0
|
| 1045 |
+
outer_radius = max(
|
| 1046 |
+
math.hypot(cx - center_x, cy - center_y)
|
| 1047 |
+
for cx, cy in (
|
| 1048 |
+
(min_x, min_y),
|
| 1049 |
+
(max_x, min_y),
|
| 1050 |
+
(max_x, max_y),
|
| 1051 |
+
(min_x, max_y),
|
| 1052 |
+
)
|
| 1053 |
+
)
|
| 1054 |
+
|
| 1055 |
+
def keep_point(x: float, y: float) -> bool:
|
| 1056 |
+
radius = math.hypot(x - center_x, y - center_y)
|
| 1057 |
+
return infill_keep(
|
| 1058 |
+
max(0, int(round((outer_radius - radius) / fil_width)))
|
| 1059 |
+
)
|
| 1060 |
+
|
| 1061 |
+
segments = _classify_polyline(points, valve, keep_point=keep_point)
|
| 1062 |
elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
|
| 1063 |
points = _rectangular_spiral_polyline(
|
| 1064 |
motion.bounds,
|
| 1065 |
fil_width,
|
| 1066 |
reverse=layer_number % 2 == 1,
|
| 1067 |
)
|
| 1068 |
+
keep_point = None
|
| 1069 |
+
if infill_keep is not None:
|
| 1070 |
+
# A spiral "line" is one ring: ring k sits k*fil inside the
|
| 1071 |
+
# half-fil-inset bounding box the spiral walks.
|
| 1072 |
+
min_x, min_y, max_x, max_y = motion.bounds
|
| 1073 |
+
half = fil_width / 2.0
|
| 1074 |
+
left, right = min_x + half, max_x - half
|
| 1075 |
+
bottom, top = min_y + half, max_y - half
|
| 1076 |
+
|
| 1077 |
+
def keep_point(x: float, y: float) -> bool:
|
| 1078 |
+
inset = min(x - left, right - x, y - bottom, top - y)
|
| 1079 |
+
return infill_keep(max(0, int(round(inset / fil_width))))
|
| 1080 |
+
|
| 1081 |
+
segments = _classify_polyline(points, valve, keep_point=keep_point)
|
| 1082 |
else:
|
| 1083 |
raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
|
| 1084 |
+
if scan_anchors is None:
|
| 1085 |
+
axis_anchor = None
|
| 1086 |
+
else:
|
| 1087 |
+
# Axis "X" sweeps along X with rows stacked in Y, so its
|
| 1088 |
+
# scanlines use the Y anchor (and vice versa).
|
| 1089 |
+
axis_anchor = scan_anchors[0] if raster_axis == "Y" else scan_anchors[1]
|
| 1090 |
segments = _oriented_axis_raster_segments(
|
| 1091 |
motion,
|
| 1092 |
valve,
|
|
|
|
| 1095 |
current_x,
|
| 1096 |
current_y,
|
| 1097 |
prefer_default=not raster_origin_initialized,
|
| 1098 |
+
scan_anchor=axis_anchor,
|
| 1099 |
+
infill_keep=infill_keep,
|
| 1100 |
)
|
| 1101 |
|
| 1102 |
if not segments:
|
|
|
|
| 1112 |
active_contour_owner,
|
| 1113 |
origin_x,
|
| 1114 |
origin_y,
|
| 1115 |
+
shared_motion,
|
| 1116 |
)
|
| 1117 |
current_x, current_y = _append_relative_move(
|
| 1118 |
gcode_list,
|
|
|
|
| 1187 |
active_contour_owner,
|
| 1188 |
origin_x,
|
| 1189 |
origin_y,
|
| 1190 |
+
shared_motion,
|
| 1191 |
)
|
| 1192 |
current_x, current_y = _append_relative_move(
|
| 1193 |
gcode_list,
|
|
|
|
| 1198 |
0,
|
| 1199 |
)
|
| 1200 |
|
| 1201 |
+
return gcode_list, (origin_x, origin_y)
|
| 1202 |
|
| 1203 |
|
| 1204 |
def _stack_center(stack: LayerStack) -> tuple[float, float]:
|
|
|
|
| 1206 |
return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
|
| 1207 |
|
| 1208 |
|
| 1209 |
+
def _snap_to_grid(value: float, grid: float | None) -> float:
|
| 1210 |
+
if not grid or grid <= 0.0:
|
| 1211 |
+
return value
|
| 1212 |
+
# Half-up, not Python's banker's rounding: values that differ by exact
|
| 1213 |
+
# grid multiples must snap to results that differ by the same multiples
|
| 1214 |
+
# (banker's rounds exact halves toward even, breaking that translation
|
| 1215 |
+
# invariance and with it uniform split-piece spacing).
|
| 1216 |
+
return math.floor(value / grid + 0.5 + 1e-9) * grid
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
|
| 1220 |
+
"""Translation that aligns `stack` into `reference`'s shared frame.
|
| 1221 |
+
|
| 1222 |
+
Uses the alignment target and snap grid stamped on the reference by
|
| 1223 |
+
`build_reference_stack`, so re-deriving the delta later reproduces the
|
| 1224 |
+
exact translation the reference was built with. Snapping the delta to the
|
| 1225 |
+
fil grid keeps every shape's world scan-grid phase intact, so split
|
| 1226 |
+
pieces printed with shared reference motion still tile at an exact
|
| 1227 |
+
one-fil-width line pitch.
|
| 1228 |
+
|
| 1229 |
+
Split siblings (stacks sharing the reference's scan frame) are aligned by
|
| 1230 |
+
their cell corner within that frame instead of their centre: with cells
|
| 1231 |
+
sized in whole grid multiples the deltas — and with them the required
|
| 1232 |
+
nozzle spacing — come out uniform across all pieces, whereas snapping the
|
| 1233 |
+
centres would wobble by up to one fil where the last cell's width (and so
|
| 1234 |
+
its centre phase) differs.
|
| 1235 |
+
"""
|
| 1236 |
+
grid = reference.align_grid
|
| 1237 |
+
if (
|
| 1238 |
+
grid
|
| 1239 |
+
and stack.scan_frame is not None
|
| 1240 |
+
and stack.scan_frame == reference.scan_frame
|
| 1241 |
+
):
|
| 1242 |
+
(stack_min_x, stack_min_y, _sz), _stack_max = (
|
| 1243 |
+
stack.bounds[0],
|
| 1244 |
+
stack.bounds[1],
|
| 1245 |
+
)
|
| 1246 |
+
frame_min_x, frame_min_y = stack.scan_frame[0], stack.scan_frame[1]
|
| 1247 |
+
return (
|
| 1248 |
+
_snap_to_grid(frame_min_x - stack_min_x, grid),
|
| 1249 |
+
_snap_to_grid(frame_min_y - stack_min_y, grid),
|
| 1250 |
+
)
|
| 1251 |
+
|
| 1252 |
+
if reference.align_center is not None:
|
| 1253 |
+
reference_x, reference_y = reference.align_center
|
| 1254 |
+
else:
|
| 1255 |
+
reference_x, reference_y = _stack_center(reference)
|
| 1256 |
center_x, center_y = _stack_center(stack)
|
| 1257 |
+
return (
|
| 1258 |
+
_snap_to_grid(reference_x - center_x, grid),
|
| 1259 |
+
_snap_to_grid(reference_y - center_y, grid),
|
| 1260 |
+
)
|
| 1261 |
|
| 1262 |
|
| 1263 |
def align_stack_to(
|
|
|
|
| 1280 |
return layers
|
| 1281 |
|
| 1282 |
|
| 1283 |
+
def build_reference_stack(
|
| 1284 |
+
stacks: list[LayerStack | None],
|
| 1285 |
+
grid: float | None = None,
|
| 1286 |
+
) -> LayerStack | None:
|
| 1287 |
"""Union all shapes into one shared motion stack, bbox-centres aligned.
|
| 1288 |
|
| 1289 |
Vector analog of the old centered "black wins" TIFF merge: every stack is
|
| 1290 |
translated so its XY bbox centre lands on the first stack's centre, then
|
| 1291 |
each layer is the union of the translated layers.
|
| 1292 |
+
|
| 1293 |
+
`grid` (the fil width) snaps each translation to grid multiples so every
|
| 1294 |
+
shape's world scan-grid phase survives the alignment — required for split
|
| 1295 |
+
pieces to keep an exact one-fil line pitch under shared reference motion.
|
| 1296 |
+
The alignment target and grid are stamped on the result so later
|
| 1297 |
+
`_centering_delta` calls reproduce the exact same translations.
|
| 1298 |
"""
|
| 1299 |
valid = [stack for stack in stacks if stack is not None and stack.layers]
|
| 1300 |
if not valid:
|
|
|
|
| 1302 |
|
| 1303 |
n_layers = max(len(stack.layers) for stack in valid)
|
| 1304 |
reference_x, reference_y = _stack_center(valid[0])
|
| 1305 |
+
target = LayerStack(
|
| 1306 |
+
layers=[],
|
| 1307 |
+
z_values=[],
|
| 1308 |
+
bounds=((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)),
|
| 1309 |
+
layer_height=valid[0].layer_height,
|
| 1310 |
+
scan_frame=valid[0].scan_frame,
|
| 1311 |
+
align_center=(reference_x, reference_y),
|
| 1312 |
+
align_grid=grid,
|
| 1313 |
+
)
|
| 1314 |
|
| 1315 |
layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
|
| 1316 |
x_min = y_min = z_min = math.inf
|
| 1317 |
x_max = y_max = z_max = -math.inf
|
| 1318 |
|
| 1319 |
for stack in valid:
|
| 1320 |
+
delta_x, delta_y = _centering_delta(stack, target)
|
|
|
|
|
|
|
| 1321 |
|
| 1322 |
(sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
|
| 1323 |
x_min = min(x_min, sx_min + delta_x)
|
|
|
|
| 1346 |
break
|
| 1347 |
z_values.append(z_value)
|
| 1348 |
|
| 1349 |
+
# The first stack's delta is zero (snap(0) == 0), so its scan frame is
|
| 1350 |
+
# already in the shared frame and anchors the reference's scan grid.
|
| 1351 |
return LayerStack(
|
| 1352 |
layers=layers,
|
| 1353 |
z_values=z_values,
|
| 1354 |
bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
|
| 1355 |
layer_height=valid[0].layer_height,
|
| 1356 |
name="reference",
|
| 1357 |
+
scan_frame=valid[0].scan_frame,
|
| 1358 |
+
align_center=(reference_x, reference_y),
|
| 1359 |
+
align_grid=grid,
|
| 1360 |
)
|
| 1361 |
|
| 1362 |
|
| 1363 |
+
def _base_split_edges(
|
| 1364 |
lo: float,
|
| 1365 |
hi: float,
|
| 1366 |
count: int,
|
| 1367 |
+
grid: float | None = None,
|
| 1368 |
+
) -> list[float]:
|
| 1369 |
+
"""Cell edges for one axis: equal cells, padded to whole grid multiples.
|
| 1370 |
+
|
| 1371 |
+
With `grid` (the fil width), every cell gets the SAME width — the extent
|
| 1372 |
+
divided by `count`, rounded UP to a whole number of grid units — and the
|
| 1373 |
+
leftover becomes blank margin split evenly outside the shape's outer
|
| 1374 |
+
edges. This is the vector analog of the old pixel splitter's padded
|
| 1375 |
+
canvas: identical piece sizes, so the required nozzle spacing is one cell
|
| 1376 |
+
everywhere, including under shared reference motion.
|
| 1377 |
+
"""
|
| 1378 |
+
extent = hi - lo
|
| 1379 |
+
if grid and grid > 0.0 and count > 1 and extent > 0.0:
|
| 1380 |
+
units = max(1, math.ceil(extent / (count * grid) - 1e-9))
|
| 1381 |
+
cell = units * grid
|
| 1382 |
+
pad = cell * count - extent
|
| 1383 |
+
start = lo - pad / 2.0
|
| 1384 |
+
return [start + index * cell for index in range(count + 1)]
|
| 1385 |
+
return [lo + index * extent / count for index in range(count + 1)]
|
| 1386 |
+
|
| 1387 |
+
|
| 1388 |
+
def _shifted_split_edges(
|
| 1389 |
+
edges: list[float],
|
| 1390 |
layer_index: int,
|
| 1391 |
overlap: float,
|
| 1392 |
) -> list[float]:
|
| 1393 |
+
"""Per-layer cell edges; interior ones alternate by ±overlap per layer."""
|
| 1394 |
+
count = len(edges) - 1
|
| 1395 |
if overlap <= 0.0 or count <= 1:
|
| 1396 |
+
return list(edges)
|
| 1397 |
|
| 1398 |
adjusted = list(edges)
|
| 1399 |
for boundary_index in range(1, count):
|
|
|
|
| 1415 |
rows: int,
|
| 1416 |
overlapping_layers: bool = False,
|
| 1417 |
overlap: float = 0.0,
|
| 1418 |
+
grid: float | None = None,
|
| 1419 |
) -> list[LayerStack]:
|
| 1420 |
"""Split a sliced shape into a rows x columns grid of piece stacks.
|
| 1421 |
|
| 1422 |
Pieces are returned row-major with row 1 the top strip (max-Y side),
|
| 1423 |
matching the legacy image-grid ordering. With `overlapping_layers`, the
|
| 1424 |
interior cut lines alternate by ±overlap between layers so neighbouring
|
| 1425 |
+
pieces interlock. `grid` (the fil width) sizes the cells in whole grid
|
| 1426 |
+
multiples (see `_base_split_edges`). Piece `bounds` are the nominal
|
| 1427 |
+
(un-shifted) cell boxes.
|
| 1428 |
"""
|
| 1429 |
columns = max(1, int(columns))
|
| 1430 |
rows = max(1, int(rows))
|
|
|
|
| 1433 |
overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
|
| 1434 |
overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
|
| 1435 |
|
| 1436 |
+
base_x_edges = _base_split_edges(x_min, x_max, columns, grid)
|
| 1437 |
+
base_y_edges = _base_split_edges(y_min, y_max, rows, grid)
|
| 1438 |
layer_x_edges = [
|
| 1439 |
+
_shifted_split_edges(base_x_edges, index, overlap_x)
|
| 1440 |
for index in range(len(stack.layers))
|
| 1441 |
]
|
| 1442 |
layer_y_edges = [
|
| 1443 |
+
_shifted_split_edges(base_y_edges, index, overlap_y)
|
| 1444 |
for index in range(len(stack.layers))
|
| 1445 |
]
|
| 1446 |
|
| 1447 |
+
# Pieces inherit the parent's scan frame so they all raster on ONE
|
| 1448 |
+
# continuous line grid: the assembled seams then keep an exact
|
| 1449 |
+
# one-fil-width pitch instead of each piece re-centring its own lines.
|
| 1450 |
+
scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max)
|
| 1451 |
+
|
| 1452 |
base_name = stack.name or "shape"
|
| 1453 |
pieces: list[LayerStack] = []
|
| 1454 |
for row_index in range(1, rows + 1):
|
|
|
|
| 1482 |
),
|
| 1483 |
layer_height=stack.layer_height,
|
| 1484 |
name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
|
| 1485 |
+
scan_frame=scan_frame,
|
| 1486 |
)
|
| 1487 |
)
|
| 1488 |
|