Spaces:
Sleeping
Slim the Shape Settings table: tenths dimensions, short headers, no Flip Z
Browse files- X/Y/Z display AND store to the tenths place. Stored values must round
exactly like the display: the table's .change echo re-applies what is
shown, so unrounded storage made every echo look like a fresh edit.
Originals round too (a pristine row's ratios must be exactly 1), and
edit detection compares against the displayed value - otherwise legacy
noisy extents (32.99557) flagged phantom edits that mis-anchored the
Keep Proportions rescale and reverted the user's real edit
- Headers "Target X (mm)" -> "X (mm)" (Y/Z alike)
- Flip Z column and its slicing plumbing removed (orientation belongs in
the STL); the engine's flip_z/z_flip_mid parameters stay. Slice-params
fingerprint changed, so existing sessions re-slice once
- Narrow fixed column widths + two-line header wrapping via CSS; Color
cell drops a legacy 170px min-width (old swatch strip). The table now
fits a 1280px viewport with no horizontal scroll (1650px -> 1188px)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +1 -2
- app.py +99 -59
- tests/test_nozzle_spacing.py +42 -12
|
@@ -47,7 +47,7 @@ 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 |
- Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
|
| 49 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 50 |
-
- Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing
|
| 51 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 52 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 53 |
- **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
|
|
@@ -85,7 +85,6 @@ For a multi-material object exported as separate STLs (one per material), give e
|
|
| 85 |
- Motion is always the combined reference outline, so all heads share one synchronized path while each dispenses only its own part.
|
| 86 |
- Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
|
| 87 |
- **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
|
| 88 |
-
- **Flip Z** (per-shape checkbox) prints a shape the other way up by mirroring it about its Z midplane. Checking it on any assembly member flips the **whole group** about the group's shared midplane, so the assembly stays together — useful when a multi-material model is authored with its display face down (e.g. the detail layer would otherwise print first instead of last).
|
| 89 |
|
| 90 |
### Multi-Nozzle Split
|
| 91 |
|
|
|
|
| 47 |
- Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
|
| 48 |
- Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
|
| 49 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 50 |
+
- Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing can be checked before generating G-code)
|
| 51 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 52 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 53 |
- **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
|
|
|
|
| 85 |
- Motion is always the combined reference outline, so all heads share one synchronized path while each dispenses only its own part.
|
| 86 |
- Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
|
| 87 |
- **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
|
|
|
|
| 88 |
|
| 89 |
### Multi-Nozzle Split
|
| 90 |
|
|
@@ -92,7 +92,11 @@ APP_CSS = """
|
|
| 92 |
display: flex;
|
| 93 |
align-items: center;
|
| 94 |
gap: 6px;
|
| 95 |
-
min-width:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
.pp-color-current {
|
| 98 |
display: inline-block;
|
|
@@ -254,6 +258,22 @@ APP_CSS = """
|
|
| 254 |
display: none !important;
|
| 255 |
}
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
#toolpath-anim-controls {
|
| 258 |
display: flex;
|
| 259 |
align-items: center;
|
|
@@ -1586,9 +1606,9 @@ def _format_nozzle_spacing_status(
|
|
| 1586 |
SHAPE_SETTINGS_HEADERS = [
|
| 1587 |
"Shape",
|
| 1588 |
"STL",
|
| 1589 |
-
"
|
| 1590 |
-
"
|
| 1591 |
-
"
|
| 1592 |
"Pressure (psi)",
|
| 1593 |
"Valve",
|
| 1594 |
"Nozzle",
|
|
@@ -1597,7 +1617,6 @@ SHAPE_SETTINGS_HEADERS = [
|
|
| 1597 |
"Infill %",
|
| 1598 |
"Contour Tracing",
|
| 1599 |
"Lead In",
|
| 1600 |
-
"Flip Z",
|
| 1601 |
"Delete",
|
| 1602 |
]
|
| 1603 |
SHAPE_SETTINGS_DATATYPES = [
|
|
@@ -1614,7 +1633,6 @@ SHAPE_SETTINGS_DATATYPES = [
|
|
| 1614 |
"number",
|
| 1615 |
"bool",
|
| 1616 |
"bool",
|
| 1617 |
-
"bool",
|
| 1618 |
"str",
|
| 1619 |
]
|
| 1620 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
|
@@ -1760,12 +1778,14 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 1760 |
"idx": index,
|
| 1761 |
"name": name,
|
| 1762 |
"stl_path": path,
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
"
|
| 1766 |
-
"
|
| 1767 |
-
"
|
| 1768 |
-
"
|
|
|
|
|
|
|
| 1769 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1770 |
"pressure": pressure,
|
| 1771 |
"valve": previous.get("valve", 4),
|
|
@@ -1791,14 +1811,33 @@ def _reindex_shape_records(records: list[dict]) -> list[dict]:
|
|
| 1791 |
return reindexed
|
| 1792 |
|
| 1793 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1794 |
def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
| 1795 |
return [
|
| 1796 |
[
|
| 1797 |
record["idx"],
|
| 1798 |
record["name"],
|
| 1799 |
-
record.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
|
| 1800 |
-
record.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
|
| 1801 |
-
record.get("target_z", DEFAULT_TARGET_EXTENTS[2]),
|
| 1802 |
record.get("pressure", 25.0),
|
| 1803 |
record.get("valve", 4),
|
| 1804 |
_record_nozzle_number(record, int(record["idx"])),
|
|
@@ -1807,7 +1846,6 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
|
| 1807 |
_coerce_float(record.get("infill", 100.0), 100.0),
|
| 1808 |
bool(record.get("contour_tracing", False)),
|
| 1809 |
bool(record.get("lead_in", False)),
|
| 1810 |
-
bool(record.get("flip_z", False)),
|
| 1811 |
"Delete",
|
| 1812 |
]
|
| 1813 |
for record in records
|
|
@@ -1883,13 +1921,6 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
|
|
| 1883 |
copy["lead_in"] = _coerce_bool(row[lead_in_pos], bool(copy.get("lead_in", False)))
|
| 1884 |
except IndexError:
|
| 1885 |
copy["lead_in"] = bool(copy.get("lead_in", False))
|
| 1886 |
-
flip_pos = lead_in_pos + 1
|
| 1887 |
-
# Parse only when the Flip Z column is present (Delete follows it);
|
| 1888 |
-
# an old-format row would otherwise coerce the "Delete" cell.
|
| 1889 |
-
if len(row) > flip_pos + 1:
|
| 1890 |
-
copy["flip_z"] = _coerce_bool(row[flip_pos], bool(copy.get("flip_z", False)))
|
| 1891 |
-
else:
|
| 1892 |
-
copy["flip_z"] = bool(copy.get("flip_z", False))
|
| 1893 |
updated.append(copy)
|
| 1894 |
return updated
|
| 1895 |
|
|
@@ -1916,7 +1947,10 @@ def _last_edited_target_axes(records: list[dict] | None, settings_table: Any) ->
|
|
| 1916 |
for key, pos in zip(TARGET_DIMENSION_KEYS, (2, 3, 4)):
|
| 1917 |
try:
|
| 1918 |
new_value = float(row[pos])
|
| 1919 |
-
|
|
|
|
|
|
|
|
|
|
| 1920 |
except (IndexError, TypeError, ValueError):
|
| 1921 |
continue
|
| 1922 |
if not math.isclose(new_value, old_value, rel_tol=1e-9, abs_tol=1e-9):
|
|
@@ -2432,9 +2466,12 @@ def reset_shape_dimensions(records: list[dict] | None, settings_table: Any | Non
|
|
| 2432 |
original_z = copy.get("original_z")
|
| 2433 |
if original_x is None or original_y is None or original_z is None:
|
| 2434 |
original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
|
| 2435 |
-
|
| 2436 |
-
|
| 2437 |
-
|
|
|
|
|
|
|
|
|
|
| 2438 |
copy["target_x"] = original_x
|
| 2439 |
copy["target_y"] = original_y
|
| 2440 |
copy["target_z"] = original_z
|
|
@@ -2446,7 +2483,6 @@ def reset_shape_dimensions(records: list[dict] | None, settings_table: Any | Non
|
|
| 2446 |
BULK_BOOL_COLUMNS = {
|
| 2447 |
"Contour Tracing": "contour_tracing",
|
| 2448 |
"Lead In": "lead_in",
|
| 2449 |
-
"Flip Z": "flip_z",
|
| 2450 |
}
|
| 2451 |
|
| 2452 |
|
|
@@ -2488,7 +2524,7 @@ def _bool_cells_need_rewrite(settings_table: Any) -> bool:
|
|
| 2488 |
"""
|
| 2489 |
rows = _normalise_rows(settings_table)
|
| 2490 |
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 2491 |
-
bool_positions = (contour_pos, contour_pos + 1
|
| 2492 |
for row in rows:
|
| 2493 |
if len(row) < len(SHAPE_SETTINGS_HEADERS):
|
| 2494 |
continue
|
|
@@ -2721,7 +2757,7 @@ def _propagate_group_scale_factors(
|
|
| 2721 |
continue
|
| 2722 |
for axis, factor in zip(("x", "y", "z"), source_factors):
|
| 2723 |
member[f"target_{axis}"] = round(
|
| 2724 |
-
float(member[f"original_{axis}"]) * factor,
|
| 2725 |
)
|
| 2726 |
member["last_scaled_axis"] = source.get(
|
| 2727 |
"last_scaled_axis", member.get("last_scaled_axis")
|
|
@@ -2759,6 +2795,8 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2759 |
idx = int(record.get("idx", 0))
|
| 2760 |
if idx in edited_axes:
|
| 2761 |
record["last_scaled_axis"] = edited_axes[idx]
|
|
|
|
|
|
|
| 2762 |
normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
|
| 2763 |
normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
|
| 2764 |
changed = any(
|
|
@@ -2786,6 +2824,7 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2786 |
# Piece dimensions are informational — never rescale them.
|
| 2787 |
normalized.append(copy)
|
| 2788 |
continue
|
|
|
|
| 2789 |
originals = np.asarray([
|
| 2790 |
copy.get("original_x"),
|
| 2791 |
copy.get("original_y"),
|
|
@@ -2845,9 +2884,9 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2845 |
scale = float(targets[anchor_index] / originals[anchor_index])
|
| 2846 |
copy["last_scaled_axis"] = TARGET_DIMENSION_KEYS[anchor_index]
|
| 2847 |
scaled = originals * scale
|
| 2848 |
-
copy["target_x"] = round(float(scaled[0]),
|
| 2849 |
-
copy["target_y"] = round(float(scaled[1]),
|
| 2850 |
-
copy["target_z"] = round(float(scaled[2]),
|
| 2851 |
recomputed_idx.add(idx)
|
| 2852 |
normalized.append(copy)
|
| 2853 |
|
|
@@ -2932,8 +2971,8 @@ def update_layer_preview(
|
|
| 2932 |
|
| 2933 |
Draws the selected shape's layer polygons in its print color. Shapes on
|
| 2934 |
the same nozzle are parts of one assembly, so their polygons at the same
|
| 2935 |
-
Z are drawn too (dimmer) — this is where multi-material slicing
|
| 2936 |
-
|
| 2937 |
"""
|
| 2938 |
from matplotlib.figure import Figure
|
| 2939 |
from matplotlib.patches import Patch
|
|
@@ -3024,11 +3063,10 @@ def _slice_params_snapshot(
|
|
| 3024 |
record: dict,
|
| 3025 |
layer_height: float,
|
| 3026 |
scale_mode: str | None,
|
| 3027 |
-
slice_plan: tuple[list[float], tuple[float, float, float]
|
| 3028 |
) -> dict:
|
| 3029 |
z_levels = slice_plan[0] if slice_plan else None
|
| 3030 |
anchor = slice_plan[1] if slice_plan else None
|
| 3031 |
-
z_flip_mid = slice_plan[2] if slice_plan else None
|
| 3032 |
return {
|
| 3033 |
"layer_height": float(layer_height),
|
| 3034 |
"scale_mode": _normalize_scale_mode(scale_mode),
|
|
@@ -3040,8 +3078,6 @@ def _slice_params_snapshot(
|
|
| 3040 |
# correctly marks every part's slices stale.
|
| 3041 |
"z_grid": (round(z_levels[0], 6), len(z_levels)) if z_levels else None,
|
| 3042 |
"scale_anchor": tuple(round(v, 6) for v in anchor) if anchor else None,
|
| 3043 |
-
"flip_z": bool(record.get("flip_z", False)),
|
| 3044 |
-
"z_flip_mid": round(z_flip_mid, 6) if z_flip_mid is not None else None,
|
| 3045 |
}
|
| 3046 |
|
| 3047 |
|
|
@@ -3110,17 +3146,14 @@ def _multi_material_slice_plan(
|
|
| 3110 |
records: list[dict],
|
| 3111 |
layer_height: float,
|
| 3112 |
scale_mode: str | None,
|
| 3113 |
-
) -> tuple[list[float], tuple[float, float, float]
|
| 3114 |
-
"""(shared Z grid, shared scale anchor
|
| 3115 |
|
| 3116 |
Group members must slice on the SAME planes so a part that starts
|
| 3117 |
higher gets empty lower layers instead of having its first material
|
| 3118 |
layer treated as layer 0 — and any target-dimension scaling must happen
|
| 3119 |
about ONE shared point (the group's combined un-scaled corner), or
|
| 3120 |
same-factor scaling would still shift the parts relative to each other.
|
| 3121 |
-
When any member has Flip Z checked, the WHOLE assembly mirrors about
|
| 3122 |
-
the group's combined Z midplane (so it stays assembled, just printed
|
| 3123 |
-
the other way up); the midplane is returned, else None.
|
| 3124 |
"""
|
| 3125 |
loaded: list[tuple[Any, tuple[float, float, float]]] = []
|
| 3126 |
corner = [math.inf, math.inf, math.inf]
|
|
@@ -3158,10 +3191,7 @@ def _multi_material_slice_plan(
|
|
| 3158 |
z_hi = max(z_hi, float(scaled.bounds[1][2]))
|
| 3159 |
if not math.isfinite(z_lo) or not math.isfinite(z_hi):
|
| 3160 |
return None
|
| 3161 |
-
|
| 3162 |
-
# the shared grid stays valid for the flipped assembly.
|
| 3163 |
-
z_flip_mid = (z_lo + z_hi) / 2.0 if any(r.get("flip_z") for r in records) else None
|
| 3164 |
-
return calculate_z_levels(z_lo, z_hi, float(layer_height)), anchor, z_flip_mid
|
| 3165 |
|
| 3166 |
|
| 3167 |
def _slice_record(
|
|
@@ -3169,7 +3199,7 @@ def _slice_record(
|
|
| 3169 |
layer_height: float,
|
| 3170 |
scale_mode: str | None,
|
| 3171 |
progress_callback=None,
|
| 3172 |
-
slice_plan: tuple[list[float], tuple[float, float, float]
|
| 3173 |
) -> LayerStack:
|
| 3174 |
stl_path = record["stl_path"]
|
| 3175 |
mesh = load_mesh(stl_path)
|
|
@@ -3181,10 +3211,6 @@ def _slice_record(
|
|
| 3181 |
record.get("target_y"),
|
| 3182 |
record.get("target_z"),
|
| 3183 |
)
|
| 3184 |
-
# Group members flip together about the group midplane (any member's
|
| 3185 |
-
# Flip Z flips the whole assembly); solo shapes flip about their own.
|
| 3186 |
-
z_flip_mid = slice_plan[2] if slice_plan else None
|
| 3187 |
-
flip_z = (z_flip_mid is not None) if slice_plan else bool(record.get("flip_z", False))
|
| 3188 |
stack = slice_stl_to_layers(
|
| 3189 |
stl_path,
|
| 3190 |
layer_height=float(layer_height),
|
|
@@ -3193,8 +3219,6 @@ def _slice_record(
|
|
| 3193 |
name=str(record.get("name") or Path(stl_path).stem),
|
| 3194 |
z_levels=slice_plan[0] if slice_plan else None,
|
| 3195 |
scale_anchor=slice_plan[1] if slice_plan else None,
|
| 3196 |
-
flip_z=flip_z,
|
| 3197 |
-
z_flip_mid=z_flip_mid,
|
| 3198 |
)
|
| 3199 |
record["layer_stack"] = stack
|
| 3200 |
record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
|
|
@@ -3223,8 +3247,6 @@ def _group_z_levels_by_record(
|
|
| 3223 |
f"Multi-material group (nozzle {nozzle}): {names} — sliced on one "
|
| 3224 |
f"shared Z grid ({len(z_levels)} layers), positions locked together."
|
| 3225 |
)
|
| 3226 |
-
if plan[2] is not None:
|
| 3227 |
-
note += " Flip Z is set: the whole assembly prints mirrored top-to-bottom."
|
| 3228 |
messages.append(note)
|
| 3229 |
return plan_by_record
|
| 3230 |
|
|
@@ -3414,8 +3436,8 @@ def _split_group_records(
|
|
| 3414 |
piece_record.update({
|
| 3415 |
"name": f"{member_name} - R{row_index}C{col_index}",
|
| 3416 |
"stl_path": None,
|
| 3417 |
-
"target_x": (piece_x_max - piece_x_min) or member.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
|
| 3418 |
-
"target_y": (piece_y_max - piece_y_min) or member.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
|
| 3419 |
"nozzle": first_nozzle + cell,
|
| 3420 |
"valve": valve_cursor,
|
| 3421 |
"split_group_id": split_group_id,
|
|
@@ -4075,6 +4097,24 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4075 |
static_columns=[SHAPE_SETTINGS_HEADERS.index("Color")],
|
| 4076 |
label="Shape Settings",
|
| 4077 |
elem_id="shape-settings-table",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4078 |
)
|
| 4079 |
with gr.Row():
|
| 4080 |
layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
|
|
|
|
| 92 |
display: flex;
|
| 93 |
align-items: center;
|
| 94 |
gap: 6px;
|
| 95 |
+
min-width: 92px;
|
| 96 |
+
}
|
| 97 |
+
.pp-color-select {
|
| 98 |
+
width: 100%;
|
| 99 |
+
min-width: 0;
|
| 100 |
}
|
| 101 |
.pp-color-current {
|
| 102 |
display: inline-block;
|
|
|
|
| 258 |
display: none !important;
|
| 259 |
}
|
| 260 |
|
| 261 |
+
/* Narrower columns: header labels ("Pressure (psi)", "Contour Tracing", ...)
|
| 262 |
+
wrap to two lines instead of forcing single-line column widths. */
|
| 263 |
+
#shape-settings-table thead th,
|
| 264 |
+
#shape-settings-table thead th button,
|
| 265 |
+
#shape-settings-table thead th span {
|
| 266 |
+
white-space: normal !important;
|
| 267 |
+
overflow-wrap: break-word;
|
| 268 |
+
line-height: 1.15;
|
| 269 |
+
}
|
| 270 |
+
#shape-settings-table thead th {
|
| 271 |
+
text-align: center;
|
| 272 |
+
vertical-align: middle;
|
| 273 |
+
padding-left: 0.2rem !important;
|
| 274 |
+
padding-right: 0.2rem !important;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
#toolpath-anim-controls {
|
| 278 |
display: flex;
|
| 279 |
align-items: center;
|
|
|
|
| 1606 |
SHAPE_SETTINGS_HEADERS = [
|
| 1607 |
"Shape",
|
| 1608 |
"STL",
|
| 1609 |
+
"X (mm)",
|
| 1610 |
+
"Y (mm)",
|
| 1611 |
+
"Z (mm)",
|
| 1612 |
"Pressure (psi)",
|
| 1613 |
"Valve",
|
| 1614 |
"Nozzle",
|
|
|
|
| 1617 |
"Infill %",
|
| 1618 |
"Contour Tracing",
|
| 1619 |
"Lead In",
|
|
|
|
| 1620 |
"Delete",
|
| 1621 |
]
|
| 1622 |
SHAPE_SETTINGS_DATATYPES = [
|
|
|
|
| 1633 |
"number",
|
| 1634 |
"bool",
|
| 1635 |
"bool",
|
|
|
|
| 1636 |
"str",
|
| 1637 |
]
|
| 1638 |
ADVANCED_NOZZLE_SPACING_HEADERS = [
|
|
|
|
| 1778 |
"idx": index,
|
| 1779 |
"name": name,
|
| 1780 |
"stl_path": path,
|
| 1781 |
+
# Dimensions live on a 0.1 mm grid (originals included, so a
|
| 1782 |
+
# pristine row's target/original ratios are exactly 1).
|
| 1783 |
+
"original_x": round(_coerce_float(previous.get("original_x"), default_x), 1),
|
| 1784 |
+
"original_y": round(_coerce_float(previous.get("original_y"), default_y), 1),
|
| 1785 |
+
"original_z": round(_coerce_float(previous.get("original_z"), default_z), 1),
|
| 1786 |
+
"target_x": round(_coerce_float(previous.get("target_x"), default_x), 1),
|
| 1787 |
+
"target_y": round(_coerce_float(previous.get("target_y"), default_y), 1),
|
| 1788 |
+
"target_z": round(_coerce_float(previous.get("target_z"), default_z), 1),
|
| 1789 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1790 |
"pressure": pressure,
|
| 1791 |
"valve": previous.get("valve", 4),
|
|
|
|
| 1811 |
return reindexed
|
| 1812 |
|
| 1813 |
|
| 1814 |
+
def _round_targets_to_tenths(record: dict) -> dict:
|
| 1815 |
+
"""Snap a record's dimensions (targets AND originals) to the 0.1 mm grid.
|
| 1816 |
+
|
| 1817 |
+
Table dimensions are DISPLAYED to the tenths place, and the table's
|
| 1818 |
+
.change echo re-applies whatever is displayed — so the stored values
|
| 1819 |
+
must round identically or every echo would look like a fresh edit and
|
| 1820 |
+
the convergence guards would ping-pong. Originals round too: a pristine
|
| 1821 |
+
row's target/original ratios must be exactly 1, or legacy noisy extents
|
| 1822 |
+
(e.g. 32.99557) would break the odd-one-out edit anchoring."""
|
| 1823 |
+
for key in (*TARGET_DIMENSION_KEYS, "original_x", "original_y", "original_z"):
|
| 1824 |
+
try:
|
| 1825 |
+
value = float(record.get(key))
|
| 1826 |
+
except (TypeError, ValueError):
|
| 1827 |
+
continue
|
| 1828 |
+
if math.isfinite(value):
|
| 1829 |
+
record[key] = round(value, 1)
|
| 1830 |
+
return record
|
| 1831 |
+
|
| 1832 |
+
|
| 1833 |
def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
| 1834 |
return [
|
| 1835 |
[
|
| 1836 |
record["idx"],
|
| 1837 |
record["name"],
|
| 1838 |
+
round(_coerce_float(record.get("target_x"), DEFAULT_TARGET_EXTENTS[0]), 1),
|
| 1839 |
+
round(_coerce_float(record.get("target_y"), DEFAULT_TARGET_EXTENTS[1]), 1),
|
| 1840 |
+
round(_coerce_float(record.get("target_z"), DEFAULT_TARGET_EXTENTS[2]), 1),
|
| 1841 |
record.get("pressure", 25.0),
|
| 1842 |
record.get("valve", 4),
|
| 1843 |
_record_nozzle_number(record, int(record["idx"])),
|
|
|
|
| 1846 |
_coerce_float(record.get("infill", 100.0), 100.0),
|
| 1847 |
bool(record.get("contour_tracing", False)),
|
| 1848 |
bool(record.get("lead_in", False)),
|
|
|
|
| 1849 |
"Delete",
|
| 1850 |
]
|
| 1851 |
for record in records
|
|
|
|
| 1921 |
copy["lead_in"] = _coerce_bool(row[lead_in_pos], bool(copy.get("lead_in", False)))
|
| 1922 |
except IndexError:
|
| 1923 |
copy["lead_in"] = bool(copy.get("lead_in", False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1924 |
updated.append(copy)
|
| 1925 |
return updated
|
| 1926 |
|
|
|
|
| 1947 |
for key, pos in zip(TARGET_DIMENSION_KEYS, (2, 3, 4)):
|
| 1948 |
try:
|
| 1949 |
new_value = float(row[pos])
|
| 1950 |
+
# The table displays tenths, so an edit is a deviation from
|
| 1951 |
+
# what was DISPLAYED — comparing against an unrounded stored
|
| 1952 |
+
# value would flag phantom edits on legacy noisy records.
|
| 1953 |
+
old_value = round(float(previous.get(key)), 1)
|
| 1954 |
except (IndexError, TypeError, ValueError):
|
| 1955 |
continue
|
| 1956 |
if not math.isclose(new_value, old_value, rel_tol=1e-9, abs_tol=1e-9):
|
|
|
|
| 2466 |
original_z = copy.get("original_z")
|
| 2467 |
if original_x is None or original_y is None or original_z is None:
|
| 2468 |
original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
|
| 2469 |
+
original_x = round(float(original_x), 1)
|
| 2470 |
+
original_y = round(float(original_y), 1)
|
| 2471 |
+
original_z = round(float(original_z), 1)
|
| 2472 |
+
copy["original_x"] = original_x
|
| 2473 |
+
copy["original_y"] = original_y
|
| 2474 |
+
copy["original_z"] = original_z
|
| 2475 |
copy["target_x"] = original_x
|
| 2476 |
copy["target_y"] = original_y
|
| 2477 |
copy["target_z"] = original_z
|
|
|
|
| 2483 |
BULK_BOOL_COLUMNS = {
|
| 2484 |
"Contour Tracing": "contour_tracing",
|
| 2485 |
"Lead In": "lead_in",
|
|
|
|
| 2486 |
}
|
| 2487 |
|
| 2488 |
|
|
|
|
| 2524 |
"""
|
| 2525 |
rows = _normalise_rows(settings_table)
|
| 2526 |
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 2527 |
+
bool_positions = (contour_pos, contour_pos + 1)
|
| 2528 |
for row in rows:
|
| 2529 |
if len(row) < len(SHAPE_SETTINGS_HEADERS):
|
| 2530 |
continue
|
|
|
|
| 2757 |
continue
|
| 2758 |
for axis, factor in zip(("x", "y", "z"), source_factors):
|
| 2759 |
member[f"target_{axis}"] = round(
|
| 2760 |
+
float(member[f"original_{axis}"]) * factor, 1
|
| 2761 |
)
|
| 2762 |
member["last_scaled_axis"] = source.get(
|
| 2763 |
"last_scaled_axis", member.get("last_scaled_axis")
|
|
|
|
| 2795 |
idx = int(record.get("idx", 0))
|
| 2796 |
if idx in edited_axes:
|
| 2797 |
record["last_scaled_axis"] = edited_axes[idx]
|
| 2798 |
+
if record.get("stl_path"):
|
| 2799 |
+
_round_targets_to_tenths(record)
|
| 2800 |
normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
|
| 2801 |
normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
|
| 2802 |
changed = any(
|
|
|
|
| 2824 |
# Piece dimensions are informational — never rescale them.
|
| 2825 |
normalized.append(copy)
|
| 2826 |
continue
|
| 2827 |
+
_round_targets_to_tenths(copy)
|
| 2828 |
originals = np.asarray([
|
| 2829 |
copy.get("original_x"),
|
| 2830 |
copy.get("original_y"),
|
|
|
|
| 2884 |
scale = float(targets[anchor_index] / originals[anchor_index])
|
| 2885 |
copy["last_scaled_axis"] = TARGET_DIMENSION_KEYS[anchor_index]
|
| 2886 |
scaled = originals * scale
|
| 2887 |
+
copy["target_x"] = round(float(scaled[0]), 1)
|
| 2888 |
+
copy["target_y"] = round(float(scaled[1]), 1)
|
| 2889 |
+
copy["target_z"] = round(float(scaled[2]), 1)
|
| 2890 |
recomputed_idx.add(idx)
|
| 2891 |
normalized.append(copy)
|
| 2892 |
|
|
|
|
| 2971 |
|
| 2972 |
Draws the selected shape's layer polygons in its print color. Shapes on
|
| 2973 |
the same nozzle are parts of one assembly, so their polygons at the same
|
| 2974 |
+
Z are drawn too (dimmer) — this is where multi-material slicing can be
|
| 2975 |
+
checked before generating any G-code.
|
| 2976 |
"""
|
| 2977 |
from matplotlib.figure import Figure
|
| 2978 |
from matplotlib.patches import Patch
|
|
|
|
| 3063 |
record: dict,
|
| 3064 |
layer_height: float,
|
| 3065 |
scale_mode: str | None,
|
| 3066 |
+
slice_plan: tuple[list[float], tuple[float, float, float]] | None = None,
|
| 3067 |
) -> dict:
|
| 3068 |
z_levels = slice_plan[0] if slice_plan else None
|
| 3069 |
anchor = slice_plan[1] if slice_plan else None
|
|
|
|
| 3070 |
return {
|
| 3071 |
"layer_height": float(layer_height),
|
| 3072 |
"scale_mode": _normalize_scale_mode(scale_mode),
|
|
|
|
| 3078 |
# correctly marks every part's slices stale.
|
| 3079 |
"z_grid": (round(z_levels[0], 6), len(z_levels)) if z_levels else None,
|
| 3080 |
"scale_anchor": tuple(round(v, 6) for v in anchor) if anchor else None,
|
|
|
|
|
|
|
| 3081 |
}
|
| 3082 |
|
| 3083 |
|
|
|
|
| 3146 |
records: list[dict],
|
| 3147 |
layer_height: float,
|
| 3148 |
scale_mode: str | None,
|
| 3149 |
+
) -> tuple[list[float], tuple[float, float, float]] | None:
|
| 3150 |
+
"""(shared Z grid, shared scale anchor) for one multi-material group.
|
| 3151 |
|
| 3152 |
Group members must slice on the SAME planes so a part that starts
|
| 3153 |
higher gets empty lower layers instead of having its first material
|
| 3154 |
layer treated as layer 0 — and any target-dimension scaling must happen
|
| 3155 |
about ONE shared point (the group's combined un-scaled corner), or
|
| 3156 |
same-factor scaling would still shift the parts relative to each other.
|
|
|
|
|
|
|
|
|
|
| 3157 |
"""
|
| 3158 |
loaded: list[tuple[Any, tuple[float, float, float]]] = []
|
| 3159 |
corner = [math.inf, math.inf, math.inf]
|
|
|
|
| 3191 |
z_hi = max(z_hi, float(scaled.bounds[1][2]))
|
| 3192 |
if not math.isfinite(z_lo) or not math.isfinite(z_hi):
|
| 3193 |
return None
|
| 3194 |
+
return calculate_z_levels(z_lo, z_hi, float(layer_height)), anchor
|
|
|
|
|
|
|
|
|
|
| 3195 |
|
| 3196 |
|
| 3197 |
def _slice_record(
|
|
|
|
| 3199 |
layer_height: float,
|
| 3200 |
scale_mode: str | None,
|
| 3201 |
progress_callback=None,
|
| 3202 |
+
slice_plan: tuple[list[float], tuple[float, float, float]] | None = None,
|
| 3203 |
) -> LayerStack:
|
| 3204 |
stl_path = record["stl_path"]
|
| 3205 |
mesh = load_mesh(stl_path)
|
|
|
|
| 3211 |
record.get("target_y"),
|
| 3212 |
record.get("target_z"),
|
| 3213 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3214 |
stack = slice_stl_to_layers(
|
| 3215 |
stl_path,
|
| 3216 |
layer_height=float(layer_height),
|
|
|
|
| 3219 |
name=str(record.get("name") or Path(stl_path).stem),
|
| 3220 |
z_levels=slice_plan[0] if slice_plan else None,
|
| 3221 |
scale_anchor=slice_plan[1] if slice_plan else None,
|
|
|
|
|
|
|
| 3222 |
)
|
| 3223 |
record["layer_stack"] = stack
|
| 3224 |
record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
|
|
|
|
| 3247 |
f"Multi-material group (nozzle {nozzle}): {names} — sliced on one "
|
| 3248 |
f"shared Z grid ({len(z_levels)} layers), positions locked together."
|
| 3249 |
)
|
|
|
|
|
|
|
| 3250 |
messages.append(note)
|
| 3251 |
return plan_by_record
|
| 3252 |
|
|
|
|
| 3436 |
piece_record.update({
|
| 3437 |
"name": f"{member_name} - R{row_index}C{col_index}",
|
| 3438 |
"stl_path": None,
|
| 3439 |
+
"target_x": round((piece_x_max - piece_x_min) or member.get("target_x", DEFAULT_TARGET_EXTENTS[0]), 1),
|
| 3440 |
+
"target_y": round((piece_y_max - piece_y_min) or member.get("target_y", DEFAULT_TARGET_EXTENTS[1]), 1),
|
| 3441 |
"nozzle": first_nozzle + cell,
|
| 3442 |
"valve": valve_cursor,
|
| 3443 |
"split_group_id": split_group_id,
|
|
|
|
| 4097 |
static_columns=[SHAPE_SETTINGS_HEADERS.index("Color")],
|
| 4098 |
label="Shape Settings",
|
| 4099 |
elem_id="shape-settings-table",
|
| 4100 |
+
# Narrow fixed widths; two-word headers ("Pressure (psi)",
|
| 4101 |
+
# "Contour Tracing", ...) wrap to two lines via CSS.
|
| 4102 |
+
column_widths=[
|
| 4103 |
+
"52px", # Shape
|
| 4104 |
+
"170px", # STL
|
| 4105 |
+
"62px", # X (mm)
|
| 4106 |
+
"62px", # Y (mm)
|
| 4107 |
+
"62px", # Z (mm)
|
| 4108 |
+
"76px", # Pressure (psi)
|
| 4109 |
+
"56px", # Valve
|
| 4110 |
+
"60px", # Nozzle
|
| 4111 |
+
"52px", # Port
|
| 4112 |
+
"104px", # Color
|
| 4113 |
+
"58px", # Infill %
|
| 4114 |
+
"78px", # Contour Tracing
|
| 4115 |
+
"58px", # Lead In
|
| 4116 |
+
"60px", # Delete
|
| 4117 |
+
],
|
| 4118 |
)
|
| 4119 |
with gr.Row():
|
| 4120 |
layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
|
|
@@ -111,7 +111,7 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
|
|
| 111 |
|
| 112 |
rows = _shape_settings_rows(records)
|
| 113 |
assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
|
| 114 |
-
assert SHAPE_SETTINGS_HEADERS[-
|
| 115 |
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 116 |
lead_in_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
|
| 117 |
assert rows[0][6:9] == [4, 1, 1]
|
|
@@ -875,7 +875,7 @@ def test_keep_proportions_is_stale_echo_proof() -> None:
|
|
| 875 |
rows[0][3] = 50.0
|
| 876 |
updated, table_out = normalize_shape_dimensions_for_mode(records, rows, SCALE_MODE_UNIFORM_FACTOR)
|
| 877 |
assert updated[0]["target_x"] == 50.0
|
| 878 |
-
assert updated[0]["target_z"] == 43.
|
| 879 |
assert isinstance(table_out, list) # table written
|
| 880 |
|
| 881 |
# 2) A stale PRE-EDIT echo arrives while records already hold the scaled
|
|
@@ -889,7 +889,7 @@ def test_keep_proportions_is_stale_echo_proof() -> None:
|
|
| 889 |
updated3, table_out3 = normalize_shape_dimensions_for_mode(updated, scaled_rows, SCALE_MODE_UNIFORM_FACTOR)
|
| 890 |
assert not isinstance(table_out3, list)
|
| 891 |
assert updated3[0]["target_x"] == 50.0
|
| 892 |
-
assert updated3[0]["target_z"] == 43.
|
| 893 |
|
| 894 |
|
| 895 |
def _mm_member(idx: int, nozzle: int, ox: float, oy: float, oz: float) -> dict:
|
|
@@ -1012,6 +1012,36 @@ def test_joining_a_nozzle_group_adopts_the_group_scale() -> None:
|
|
| 1012 |
assert updated2[2]["target_x"] == 45.0
|
| 1013 |
|
| 1014 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
|
| 1016 |
from app import SCALE_MODE_TARGET_DIMENSIONS
|
| 1017 |
|
|
@@ -1159,20 +1189,20 @@ def test_apply_bulk_bool_selection_sets_a_whole_column() -> None:
|
|
| 1159 |
_mm_member(2, 2, 10.0, 10.0, 10.0),
|
| 1160 |
_mm_member(3, 3, 10.0, 10.0, 10.0),
|
| 1161 |
]
|
| 1162 |
-
flip_pos = SHAPE_SETTINGS_HEADERS.index("Flip Z")
|
| 1163 |
lead_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
|
|
|
|
| 1164 |
|
| 1165 |
-
updated, rows = apply_bulk_bool_selection(records, None, f"{
|
| 1166 |
-
assert all(record["
|
| 1167 |
-
assert all(record.get("
|
| 1168 |
-
assert all(row[
|
| 1169 |
-
assert all(row[
|
| 1170 |
|
| 1171 |
# Unchecking clears the whole column; junk payloads change nothing.
|
| 1172 |
-
cleared, rows2 = apply_bulk_bool_selection(updated, rows, f"{
|
| 1173 |
-
assert all(record["
|
| 1174 |
same, _rows3 = apply_bulk_bool_selection(cleared, rows2, "garbage")
|
| 1175 |
-
assert all(record["
|
| 1176 |
# Non-bool columns are refused.
|
| 1177 |
color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
|
| 1178 |
refused, _rows4 = apply_bulk_bool_selection(cleared, rows2, f"{color_pos}|1")
|
|
|
|
| 111 |
|
| 112 |
rows = _shape_settings_rows(records)
|
| 113 |
assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
|
| 114 |
+
assert SHAPE_SETTINGS_HEADERS[-3:] == ["Contour Tracing", "Lead In", "Delete"]
|
| 115 |
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 116 |
lead_in_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
|
| 117 |
assert rows[0][6:9] == [4, 1, 1]
|
|
|
|
| 875 |
rows[0][3] = 50.0
|
| 876 |
updated, table_out = normalize_shape_dimensions_for_mode(records, rows, SCALE_MODE_UNIFORM_FACTOR)
|
| 877 |
assert updated[0]["target_x"] == 50.0
|
| 878 |
+
assert updated[0]["target_z"] == 43.3 # tenths grid (originals round too)
|
| 879 |
assert isinstance(table_out, list) # table written
|
| 880 |
|
| 881 |
# 2) A stale PRE-EDIT echo arrives while records already hold the scaled
|
|
|
|
| 889 |
updated3, table_out3 = normalize_shape_dimensions_for_mode(updated, scaled_rows, SCALE_MODE_UNIFORM_FACTOR)
|
| 890 |
assert not isinstance(table_out3, list)
|
| 891 |
assert updated3[0]["target_x"] == 50.0
|
| 892 |
+
assert updated3[0]["target_z"] == 43.3
|
| 893 |
|
| 894 |
|
| 895 |
def _mm_member(idx: int, nozzle: int, ox: float, oy: float, oz: float) -> dict:
|
|
|
|
| 1012 |
assert updated2[2]["target_x"] == 45.0
|
| 1013 |
|
| 1014 |
|
| 1015 |
+
def test_table_dimensions_display_and_store_to_the_tenths_place() -> None:
|
| 1016 |
+
from app import SCALE_MODE_TARGET_DIMENSIONS
|
| 1017 |
+
|
| 1018 |
+
# Headers shrank: "Target X (mm)" -> "X (mm)".
|
| 1019 |
+
assert SHAPE_SETTINGS_HEADERS[2:5] == ["X (mm)", "Y (mm)", "Z (mm)"]
|
| 1020 |
+
|
| 1021 |
+
record = _mm_member(1, 1, 38.1, 38.1, 33.0)
|
| 1022 |
+
record["target_z"] = 32.99557 # legacy noisy value from a mesh extent
|
| 1023 |
+
rows = _shape_settings_rows([record])
|
| 1024 |
+
assert rows[0][2:5] == [38.1, 38.1, 33.0]
|
| 1025 |
+
|
| 1026 |
+
# The echo of the rounded display converges: the stored value snaps to
|
| 1027 |
+
# the displayed tenths and no further table rewrite happens.
|
| 1028 |
+
updated, table_out = normalize_shape_dimensions_for_mode(
|
| 1029 |
+
[record], rows, SCALE_MODE_TARGET_DIMENSIONS
|
| 1030 |
+
)
|
| 1031 |
+
assert updated[0]["target_z"] == 33.0
|
| 1032 |
+
assert not isinstance(table_out, list)
|
| 1033 |
+
|
| 1034 |
+
# Keep Proportions recomputes land on the tenths grid too.
|
| 1035 |
+
updated[0]["last_scaled_axis"] = "target_x"
|
| 1036 |
+
rows2 = _shape_settings_rows(updated)
|
| 1037 |
+
rows2[0][2] = 50.0
|
| 1038 |
+
updated2, _out = normalize_shape_dimensions_for_mode(
|
| 1039 |
+
updated, rows2, SCALE_MODE_UNIFORM_FACTOR
|
| 1040 |
+
)
|
| 1041 |
+
for key in ("target_x", "target_y", "target_z"):
|
| 1042 |
+
assert updated2[0][key] == round(updated2[0][key], 1)
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
|
| 1046 |
from app import SCALE_MODE_TARGET_DIMENSIONS
|
| 1047 |
|
|
|
|
| 1189 |
_mm_member(2, 2, 10.0, 10.0, 10.0),
|
| 1190 |
_mm_member(3, 3, 10.0, 10.0, 10.0),
|
| 1191 |
]
|
|
|
|
| 1192 |
lead_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
|
| 1193 |
+
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 1194 |
|
| 1195 |
+
updated, rows = apply_bulk_bool_selection(records, None, f"{lead_pos}|1")
|
| 1196 |
+
assert all(record["lead_in"] is True for record in updated)
|
| 1197 |
+
assert all(record.get("contour_tracing") is not True for record in updated) # no bleed
|
| 1198 |
+
assert all(row[lead_pos] is True for row in rows)
|
| 1199 |
+
assert all(row[contour_pos] is False for row in rows)
|
| 1200 |
|
| 1201 |
# Unchecking clears the whole column; junk payloads change nothing.
|
| 1202 |
+
cleared, rows2 = apply_bulk_bool_selection(updated, rows, f"{lead_pos}|0")
|
| 1203 |
+
assert all(record["lead_in"] is False for record in cleared)
|
| 1204 |
same, _rows3 = apply_bulk_bool_selection(cleared, rows2, "garbage")
|
| 1205 |
+
assert all(record["lead_in"] is False for record in same)
|
| 1206 |
# Non-bool columns are refused.
|
| 1207 |
color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
|
| 1208 |
refused, _rows4 = apply_bulk_bool_selection(cleared, rows2, f"{color_pos}|1")
|