CyGuy8 Claude Fable 5 commited on
Commit
09c5dff
·
1 Parent(s): c1a6699

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>

Files changed (3) hide show
  1. README.md +1 -2
  2. app.py +99 -59
  3. tests/test_nozzle_spacing.py +42 -12
README.md CHANGED
@@ -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 and Flip Z 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,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
 
app.py CHANGED
@@ -92,7 +92,11 @@ APP_CSS = """
92
  display: flex;
93
  align-items: center;
94
  gap: 6px;
95
- min-width: 170px;
 
 
 
 
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
- "Target X (mm)",
1590
- "Target Y (mm)",
1591
- "Target Z (mm)",
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
- "original_x": previous.get("original_x", default_x),
1764
- "original_y": previous.get("original_y", default_y),
1765
- "original_z": previous.get("original_z", default_z),
1766
- "target_x": previous.get("target_x", default_x),
1767
- "target_y": previous.get("target_y", default_y),
1768
- "target_z": previous.get("target_z", default_z),
 
 
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
- old_value = float(previous.get(key))
 
 
 
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
- copy["original_x"] = original_x
2436
- copy["original_y"] = original_y
2437
- copy["original_z"] = original_z
 
 
 
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, contour_pos + 2)
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, 6
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]), 6)
2849
- copy["target_y"] = round(float(scaled[1]), 6)
2850
- copy["target_z"] = round(float(scaled[2]), 6)
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 and Flip
2936
- Z can be checked before generating any G-code.
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], float | None] | None = None,
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], float | None] | None:
3114
- """(shared Z grid, shared scale anchor, Z-flip midplane) for one group.
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
- # Mirroring about the group midplane preserves the group's Z range, so
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], float | None] | None = None,
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)
tests/test_nozzle_spacing.py CHANGED
@@ -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[-4:] == ["Contour Tracing", "Lead In", "Flip Z", "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,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.301273
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.301273
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"{flip_pos}|1")
1166
- assert all(record["flip_z"] is True for record in updated)
1167
- assert all(record.get("lead_in") is not True for record in updated) # no bleed
1168
- assert all(row[flip_pos] is True for row in rows)
1169
- assert all(row[lead_pos] is False for row in rows)
1170
 
1171
  # Unchecking clears the whole column; junk payloads change nothing.
1172
- cleared, rows2 = apply_bulk_bool_selection(updated, rows, f"{flip_pos}|0")
1173
- assert all(record["flip_z"] is False for record in cleared)
1174
  same, _rows3 = apply_bulk_bool_selection(cleared, rows2, "garbage")
1175
- assert all(record["flip_z"] is False for record in same)
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")