CyGuy8 Claude Fable 5 commited on
Commit
8478b27
·
1 Parent(s): c7f0b44

Sync multi-material assemblies in the table, add Flip Z, fix split seam gaps

Browse files

Shape Settings table:
- Dimension edits on a multi-material group member (shapes sharing a
nozzle) propagate the per-axis scale factor (target/original) to every
member in both scaling modes, so assemblies scale as one unit; members
are scaled about the group's shared corner so parts stay assembled
- Moving a shape onto another nozzle makes it adopt that group's scale
- New Flip Z checkbox column prints a shape the other way up (mirrored
about its Z midplane); any assembly member's Flip Z flips the whole
group about the shared midplane - for models authored face-down, like
the Maryland flag whose arms otherwise print first instead of last
- Keep Proportions fixed: Dataframe emits no .input for cell edits, so
the normalizer is back on .change with a server-side idempotence guard
(converges in two rounds instead of a minute-long re-anchoring storm);
anchoring now comes from the row's own ratios (odd factor out), which
is immune to stale event echoes that used to revert the first edit
- Split pieces are exempt from proportion normalization (their targets
are cell sizes; a table echo used to reset them to parent dimensions)
- Cell clicks outside the Delete column no longer echo the table (the
queued echo raced normalize and re-rendered the table mid-typing)
- Header select-all checkboxes are handled by the backend via a hidden
relay: Gradio's own implementation sometimes toggled nothing or bled a
stray value into the neighbouring column's first row

Split seams:
- A split cut landing exactly on a scan-grid line lost its boundary
scanline to float noise (epsilon too small in the grid-line selection,
and the chord probe grazing the material edge from outside by ulps),
printing a one-fil gap at every assembled seam - the flag's 2x2 split
Y seam. Both are fixed; seams reassemble at exact one-fil bead pitch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

README.md CHANGED
@@ -83,7 +83,8 @@ For a multi-material object exported as separate STLs (one per material), give e
83
  - **Contour Tracing** on assembly parts outlines only the assembly's true outer surface: edges where one material meets (or nearly meets, within half a bead — fit tolerances included) another material are internal interfaces and are skipped, exactly like the cut seams of grid-split pieces.
84
  - Combine with **Use combined reference outline for motion** so all heads share one synchronized path while each dispenses only its own part.
85
  - Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
86
- - Leave assembly parts' target dimensions at their defaults (or scale every part identically); parts are scaled about their own corners, so unequal scaling would misalign an assembly.
 
87
 
88
  ### Multi-Nozzle Split
89
 
 
83
  - **Contour Tracing** on assembly parts outlines only the assembly's true outer surface: edges where one material meets (or nearly meets, within half a bead — fit tolerances included) another material are internal interfaces and are skipped, exactly like the cut seams of grid-split pieces.
84
  - Combine with **Use combined reference outline for motion** so all heads share one synchronized path while each dispenses only its own part.
85
  - Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
86
+ - **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.
87
+ - **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).
88
 
89
  ### Multi-Nozzle Split
90
 
app.py CHANGED
@@ -404,6 +404,39 @@ APP_HEAD = """
404
  event.stopPropagation();
405
  relayColorChoice(idxMatch[1], '#' + hexMatch[1].toLowerCase());
406
  }, true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  function enableUndoButtons(root) {
408
  (root || document).querySelectorAll('.model3D button[aria-label="Undo"]').forEach(function (btn) {
409
  if (btn.disabled) {
@@ -1553,6 +1586,7 @@ SHAPE_SETTINGS_HEADERS = [
1553
  "Infill %",
1554
  "Contour Tracing",
1555
  "Lead In",
 
1556
  "Delete",
1557
  ]
1558
  SHAPE_SETTINGS_DATATYPES = [
@@ -1569,6 +1603,7 @@ SHAPE_SETTINGS_DATATYPES = [
1569
  "number",
1570
  "bool",
1571
  "bool",
 
1572
  "str",
1573
  ]
1574
  ADVANCED_NOZZLE_SPACING_HEADERS = [
@@ -1750,6 +1785,7 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
1750
  _coerce_float(record.get("infill", 100.0), 100.0),
1751
  bool(record.get("contour_tracing", False)),
1752
  bool(record.get("lead_in", False)),
 
1753
  "Delete",
1754
  ]
1755
  for record in records
@@ -1825,6 +1861,13 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
1825
  copy["lead_in"] = _coerce_bool(row[lead_in_pos], bool(copy.get("lead_in", False)))
1826
  except IndexError:
1827
  copy["lead_in"] = bool(copy.get("lead_in", False))
 
 
 
 
 
 
 
1828
  updated.append(copy)
1829
  return updated
1830
 
@@ -2275,6 +2318,15 @@ def _shape_delete_outputs(
2275
  )
2276
 
2277
 
 
 
 
 
 
 
 
 
 
2278
  def delete_shape_from_settings(
2279
  records: list[dict] | None,
2280
  settings_table: Any | None,
@@ -2284,20 +2336,20 @@ def delete_shape_from_settings(
2284
  now = time.monotonic()
2285
  rows = _normalise_rows(settings_table)
2286
  selected = getattr(evt, "index", None)
2287
- current_records = _apply_shape_settings(records or [], settings_table)
2288
  if not isinstance(selected, (list, tuple)) or len(selected) < 2:
2289
- return _shape_delete_outputs(current_records, last_delete_at)
2290
 
2291
  try:
2292
  row_index, column_index = int(selected[0]), int(selected[1])
2293
  except (TypeError, ValueError):
2294
- return _shape_delete_outputs(current_records, last_delete_at)
2295
  delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
2296
  if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
2297
- return _shape_delete_outputs(current_records, last_delete_at)
2298
  if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
2299
- return _shape_delete_outputs(current_records, last_delete_at)
2300
 
 
2301
  try:
2302
  delete_idx = int(float(rows[row_index][0]))
2303
  except (IndexError, TypeError, ValueError):
@@ -2335,23 +2387,228 @@ def reset_shape_dimensions(records: list[dict] | None, settings_table: Any | Non
2335
  return reset_records, _shape_settings_rows(reset_records)
2336
 
2337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2338
  def normalize_shape_dimensions_for_mode(
2339
  records: list[dict] | None,
2340
  settings_table: Any | None,
2341
  scale_mode: str | None,
2342
  ) -> tuple:
 
 
 
 
 
 
 
 
 
 
 
 
 
2343
  edited_axes = _last_edited_target_axes(records, settings_table)
 
2344
  records = _apply_shape_settings(records or [], settings_table)
2345
  if _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR:
2346
- for record in records:
 
2347
  idx = int(record.get("idx", 0))
2348
  if idx in edited_axes:
2349
  record["last_scaled_axis"] = edited_axes[idx]
2350
- return records, _shape_settings_rows(records)
 
 
 
 
 
 
 
 
 
 
 
 
 
2351
 
 
2352
  normalized: list[dict] = []
2353
  for record in records:
2354
  copy = dict(record)
 
 
 
 
 
 
 
2355
  originals = np.asarray([
2356
  copy.get("original_x"),
2357
  copy.get("original_y"),
@@ -2373,18 +2630,69 @@ def normalize_shape_dimensions_for_mode(
2373
  normalized.append(copy)
2374
  continue
2375
  idx = int(copy.get("idx", 0))
2376
- anchor_key = edited_axes.get(idx) or copy.get("last_scaled_axis") or "target_x"
2377
- try:
2378
- anchor_index = TARGET_DIMENSION_KEYS.index(str(anchor_key))
2379
- except ValueError:
2380
- anchor_index = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2381
  scale = float(targets[anchor_index] / originals[anchor_index])
2382
  copy["last_scaled_axis"] = TARGET_DIMENSION_KEYS[anchor_index]
2383
  scaled = originals * scale
2384
  copy["target_x"] = round(float(scaled[0]), 6)
2385
  copy["target_y"] = round(float(scaled[1]), 6)
2386
  copy["target_z"] = round(float(scaled[2]), 6)
 
2387
  normalized.append(copy)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2388
  return normalized, _shape_settings_rows(normalized)
2389
 
2390
 
@@ -2415,18 +2723,24 @@ def _slice_params_snapshot(
2415
  record: dict,
2416
  layer_height: float,
2417
  scale_mode: str | None,
2418
- z_levels: list[float] | None = None,
2419
  ) -> dict:
 
 
 
2420
  return {
2421
  "layer_height": float(layer_height),
2422
  "scale_mode": _normalize_scale_mode(scale_mode),
2423
  "target_x": record.get("target_x"),
2424
  "target_y": record.get("target_y"),
2425
  "target_z": record.get("target_z"),
2426
- # Multi-material mode: the shared Z grid fingerprint. Adding/removing
2427
- # an assembly part changes the grid, which correctly marks every
2428
- # part's slices stale.
2429
  "z_grid": (round(z_levels[0], 6), len(z_levels)) if z_levels else None,
 
 
 
2430
  }
2431
 
2432
 
@@ -2491,19 +2805,24 @@ def _stamp_multi_material_frames(records: list[dict], fil_width: float = 0.8) ->
2491
  stack.contour_paths = None
2492
 
2493
 
2494
- def _multi_material_z_levels(
2495
  records: list[dict],
2496
  layer_height: float,
2497
  scale_mode: str | None,
2498
- ) -> list[float] | None:
2499
- """One shared Z grid spanning the given parts' scaled extents.
2500
-
2501
- Multi-material group members must slice on the SAME planes so a part
2502
- that starts higher gets empty lower layers instead of having its first
2503
- material layer treated as layer 0.
 
 
 
 
 
2504
  """
2505
- z_lo = math.inf
2506
- z_hi = -math.inf
2507
  for record in records:
2508
  stl_path = record.get("stl_path")
2509
  if not stl_path:
@@ -2518,14 +2837,30 @@ def _multi_material_z_levels(
2518
  record.get("target_y"),
2519
  record.get("target_z"),
2520
  )
2521
- scaled = scale_mesh(mesh, scale_factors)
 
 
 
 
 
 
 
 
 
 
 
 
 
2522
  except Exception:
2523
  continue
2524
  z_lo = min(z_lo, float(scaled.bounds[0][2]))
2525
  z_hi = max(z_hi, float(scaled.bounds[1][2]))
2526
  if not math.isfinite(z_lo) or not math.isfinite(z_hi):
2527
  return None
2528
- return calculate_z_levels(z_lo, z_hi, float(layer_height))
 
 
 
2529
 
2530
 
2531
  def _slice_record(
@@ -2533,7 +2868,7 @@ def _slice_record(
2533
  layer_height: float,
2534
  scale_mode: str | None,
2535
  progress_callback=None,
2536
- z_levels: list[float] | None = None,
2537
  ) -> LayerStack:
2538
  stl_path = record["stl_path"]
2539
  mesh = load_mesh(stl_path)
@@ -2545,16 +2880,23 @@ def _slice_record(
2545
  record.get("target_y"),
2546
  record.get("target_z"),
2547
  )
 
 
 
 
2548
  stack = slice_stl_to_layers(
2549
  stl_path,
2550
  layer_height=float(layer_height),
2551
  progress_callback=progress_callback,
2552
  scale_factors=scale_factors,
2553
  name=str(record.get("name") or Path(stl_path).stem),
2554
- z_levels=z_levels,
 
 
 
2555
  )
2556
  record["layer_stack"] = stack
2557
- record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, z_levels)
2558
  return stack
2559
 
2560
 
@@ -2563,22 +2905,27 @@ def _group_z_levels_by_record(
2563
  layer_height: float,
2564
  scale_mode: str | None,
2565
  messages: list[str] | None = None,
2566
- ) -> dict[int, list[float]]:
2567
- """Shared Z grid per multi-material group, keyed by member record id."""
2568
- z_by_record: dict[int, list[float]] = {}
 
2569
  for nozzle, members in sorted(_multi_material_groups(records).items()):
2570
- z_levels = _multi_material_z_levels(members, layer_height, scale_mode)
2571
- if z_levels is None:
2572
  continue
 
2573
  for member in members:
2574
- z_by_record[id(member)] = z_levels
2575
  if messages is not None:
2576
  names = ", ".join(str(m.get("name") or f"Shape {m['idx']}") for m in members)
2577
- messages.append(
2578
  f"Multi-material group (nozzle {nozzle}): {names} — sliced on one "
2579
  f"shared Z grid ({len(z_levels)} layers), positions locked together."
2580
  )
2581
- return z_by_record
 
 
 
2582
 
2583
 
2584
  def generate_dynamic_layer_stacks(
@@ -2594,7 +2941,7 @@ def generate_dynamic_layer_stacks(
2594
  return records, "Upload at least one STL first.", None
2595
  total = len(records)
2596
  messages: list[str] = []
2597
- z_by_record = _group_z_levels_by_record(records, layer_height, scale_mode, messages)
2598
  for pos, record in enumerate(records):
2599
  stl_path = record.get("stl_path")
2600
  if not stl_path:
@@ -2609,7 +2956,7 @@ def generate_dynamic_layer_stacks(
2609
 
2610
  try:
2611
  stack = _slice_record(
2612
- record, layer_height, scale_mode, report_progress, z_by_record.get(id(record))
2613
  )
2614
  (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
2615
  messages.append(
@@ -2960,18 +3307,18 @@ def _ensure_records_sliced(
2960
  messages: list[str],
2961
  ) -> bool:
2962
  """Re-slice records whose layers are missing or stale for the current settings."""
2963
- z_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
2964
  resliced = False
2965
  for record in records:
2966
  stl_path = record.get("stl_path")
2967
  if not stl_path:
2968
  continue # Split pieces carry their clipped layers; nothing to re-slice.
2969
- z_levels = z_by_record.get(id(record))
2970
- current = _slice_params_snapshot(record, layer_height, scale_mode, z_levels)
2971
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
2972
  continue
2973
  try:
2974
- stack = _slice_record(record, layer_height, scale_mode, None, z_levels)
2975
  messages.append(
2976
  f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
2977
  )
@@ -3369,6 +3716,17 @@ def build_dynamic_demo() -> gr.Blocks:
3369
  elem_id="pp-color-apply",
3370
  elem_classes=["pp-visually-hidden"],
3371
  )
 
 
 
 
 
 
 
 
 
 
 
3372
  shape_settings = gr.Dataframe(
3373
  headers=SHAPE_SETTINGS_HEADERS,
3374
  value=[],
@@ -3619,10 +3977,17 @@ def build_dynamic_demo() -> gr.Blocks:
3619
  outputs=[shape_records, shape_settings],
3620
  queue=False,
3621
  )
 
 
 
 
 
 
3622
  shape_settings.select(
3623
  fn=delete_shape_from_settings,
3624
  inputs=[shape_records, shape_settings, last_shape_delete_at],
3625
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
 
3626
  ).then(
3627
  fn=lambda records: _dropdown_update(records),
3628
  inputs=[shape_records],
@@ -3636,6 +4001,11 @@ def build_dynamic_demo() -> gr.Blocks:
3636
  )
3637
 
3638
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
 
 
 
 
 
3639
  shape_settings.change(
3640
  fn=normalize_shape_dimensions_for_mode,
3641
  inputs=[shape_records, shape_settings, scale_mode],
 
404
  event.stopPropagation();
405
  relayColorChoice(idxMatch[1], '#' + hexMatch[1].toLowerCase());
406
  }, true);
407
+ // Header "select all" checkboxes: Gradio's own implementation is buggy
408
+ // (sometimes toggles nothing server-side, sometimes bleeds a stray value
409
+ // into the neighbouring column's first row). Hijack the click and set
410
+ // the whole column through the backend instead.
411
+ function relayBulkBool(columnIndex, value) {
412
+ var sink = document.querySelector('#pp-bulk-sink textarea, #pp-bulk-sink input');
413
+ var apply = document.querySelector('#pp-bulk-apply button, button#pp-bulk-apply, #pp-bulk-apply');
414
+ if (!sink || !apply) return;
415
+ sink.value = columnIndex + '|' + value;
416
+ sink.dispatchEvent(new Event('input', { bubbles: true }));
417
+ if (apply.tagName !== 'BUTTON') { apply = apply.querySelector('button') || apply; }
418
+ apply.click();
419
+ }
420
+ document.addEventListener('click', function (event) {
421
+ var el = event.target;
422
+ if (!el || !el.closest) return;
423
+ if (!el.closest('#shape-settings-table thead')) return;
424
+ var wrap = el.closest('label') || el;
425
+ var cb = (wrap.matches && wrap.matches('input[type=checkbox]'))
426
+ ? wrap
427
+ : (wrap.querySelector ? wrap.querySelector('input[type=checkbox]') : null);
428
+ if (!cb) return;
429
+ event.preventDefault();
430
+ event.stopPropagation();
431
+ var row = cb.closest('tr');
432
+ var cell = cb.closest('th, td');
433
+ if (!row || !cell) return;
434
+ var col = Array.prototype.indexOf.call(row.children, cell);
435
+ // Checkbox pre-click activation: by the time click handlers run the
436
+ // box has ALREADY toggled (preventDefault rolls it back visually),
437
+ // so cb.checked is the state the user is asking for.
438
+ relayBulkBool(col, cb.checked ? 1 : 0);
439
+ }, true);
440
  function enableUndoButtons(root) {
441
  (root || document).querySelectorAll('.model3D button[aria-label="Undo"]').forEach(function (btn) {
442
  if (btn.disabled) {
 
1586
  "Infill %",
1587
  "Contour Tracing",
1588
  "Lead In",
1589
+ "Flip Z",
1590
  "Delete",
1591
  ]
1592
  SHAPE_SETTINGS_DATATYPES = [
 
1603
  "number",
1604
  "bool",
1605
  "bool",
1606
+ "bool",
1607
  "str",
1608
  ]
1609
  ADVANCED_NOZZLE_SPACING_HEADERS = [
 
1785
  _coerce_float(record.get("infill", 100.0), 100.0),
1786
  bool(record.get("contour_tracing", False)),
1787
  bool(record.get("lead_in", False)),
1788
+ bool(record.get("flip_z", False)),
1789
  "Delete",
1790
  ]
1791
  for record in records
 
1861
  copy["lead_in"] = _coerce_bool(row[lead_in_pos], bool(copy.get("lead_in", False)))
1862
  except IndexError:
1863
  copy["lead_in"] = bool(copy.get("lead_in", False))
1864
+ flip_pos = lead_in_pos + 1
1865
+ # Parse only when the Flip Z column is present (Delete follows it);
1866
+ # an old-format row would otherwise coerce the "Delete" cell.
1867
+ if len(row) > flip_pos + 1:
1868
+ copy["flip_z"] = _coerce_bool(row[flip_pos], bool(copy.get("flip_z", False)))
1869
+ else:
1870
+ copy["flip_z"] = bool(copy.get("flip_z", False))
1871
  updated.append(copy)
1872
  return updated
1873
 
 
2318
  )
2319
 
2320
 
2321
+ def _shape_select_noop() -> tuple:
2322
+ """Skip every output: a cell click that is not a Delete click must not
2323
+ touch anything. This handler fires on EVERY cell selection (queued, so it
2324
+ lands late); echoing records/rows here raced the dimension normalizer's
2325
+ write-back — the stale echo clobbered the recomputed proportions on the
2326
+ first Keep Proportions edit and re-rendered the table mid-typing."""
2327
+ return tuple(gr.skip() for _ in range(8))
2328
+
2329
+
2330
  def delete_shape_from_settings(
2331
  records: list[dict] | None,
2332
  settings_table: Any | None,
 
2336
  now = time.monotonic()
2337
  rows = _normalise_rows(settings_table)
2338
  selected = getattr(evt, "index", None)
 
2339
  if not isinstance(selected, (list, tuple)) or len(selected) < 2:
2340
+ return _shape_select_noop()
2341
 
2342
  try:
2343
  row_index, column_index = int(selected[0]), int(selected[1])
2344
  except (TypeError, ValueError):
2345
+ return _shape_select_noop()
2346
  delete_column_index = len(SHAPE_SETTINGS_HEADERS) - 1
2347
  if column_index != delete_column_index or row_index < 0 or row_index >= len(rows):
2348
+ return _shape_select_noop()
2349
  if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
2350
+ return _shape_select_noop()
2351
 
2352
+ current_records = _apply_shape_settings(records or [], settings_table)
2353
  try:
2354
  delete_idx = int(float(rows[row_index][0]))
2355
  except (IndexError, TypeError, ValueError):
 
2387
  return reset_records, _shape_settings_rows(reset_records)
2388
 
2389
 
2390
+ BULK_BOOL_COLUMNS = {
2391
+ "Contour Tracing": "contour_tracing",
2392
+ "Lead In": "lead_in",
2393
+ "Flip Z": "flip_z",
2394
+ }
2395
+
2396
+
2397
+ def apply_bulk_bool_selection(
2398
+ records: list[dict] | None,
2399
+ settings_table: Any,
2400
+ payload: str | None,
2401
+ ) -> tuple[list[dict], list[list[Any]]]:
2402
+ """Set a checkbox column for ALL shapes ("columnIndex|0/1" from the sink).
2403
+
2404
+ Backs the header select-all checkboxes: Gradio's own implementation is
2405
+ unreliable (see the head-script hijack), so the whole column is set here
2406
+ and the table re-rendered canonically.
2407
+ """
2408
+ records = _apply_shape_settings(records or [], settings_table)
2409
+ parts = str(payload or "").split("|")
2410
+ if len(parts) >= 2:
2411
+ try:
2412
+ column_index = int(parts[0])
2413
+ value = bool(int(parts[1]))
2414
+ except (TypeError, ValueError):
2415
+ column_index = -1
2416
+ value = False
2417
+ if 0 <= column_index < len(SHAPE_SETTINGS_HEADERS):
2418
+ key = BULK_BOOL_COLUMNS.get(SHAPE_SETTINGS_HEADERS[column_index])
2419
+ if key:
2420
+ records = [dict(record, **{key: value}) for record in records]
2421
+ return records, _shape_settings_rows(records)
2422
+
2423
+
2424
+ def _bool_cells_need_rewrite(settings_table: Any) -> bool:
2425
+ """True when the checkbox columns carry non-boolean cell values.
2426
+
2427
+ Gradio's header "select all" writes the STRING "true"/"false" into the
2428
+ column's cells (and leaves some rendered as plain text or stale
2429
+ checkboxes in neighbouring columns) instead of booleans. Detecting that
2430
+ lets the normalizer answer with a canonical re-render, which restores
2431
+ real checkboxes and stomps any visual strays within one round-trip.
2432
+ """
2433
+ rows = _normalise_rows(settings_table)
2434
+ contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
2435
+ bool_positions = (contour_pos, contour_pos + 1, contour_pos + 2)
2436
+ for row in rows:
2437
+ if len(row) < len(SHAPE_SETTINGS_HEADERS):
2438
+ continue
2439
+ for pos in bool_positions:
2440
+ if not isinstance(row[pos], bool):
2441
+ return True
2442
+ return False
2443
+
2444
+
2445
+ def _last_edited_nozzles(records: list[dict] | None, settings_table: Any) -> set[int]:
2446
+ """Record idx whose Nozzle cell differs from the record — i.e. shapes the
2447
+ user just moved onto a (possibly new) nozzle via the table."""
2448
+ rows = _normalise_rows(settings_table)
2449
+ previous_by_idx: dict[int, dict] = {}
2450
+ for record in records or []:
2451
+ try:
2452
+ previous_by_idx[int(record.get("idx", 0))] = record
2453
+ except (TypeError, ValueError):
2454
+ continue
2455
+
2456
+ nozzle_pos = SHAPE_SETTINGS_HEADERS.index("Nozzle")
2457
+ changed: set[int] = set()
2458
+ for row in rows:
2459
+ try:
2460
+ idx = int(float(row[0]))
2461
+ except (IndexError, TypeError, ValueError):
2462
+ continue
2463
+ previous = previous_by_idx.get(idx)
2464
+ if not previous or len(row) <= nozzle_pos:
2465
+ continue
2466
+ old_nozzle = _record_nozzle_number(previous, idx)
2467
+ new_nozzle = _coerce_int(row[nozzle_pos], old_nozzle)
2468
+ if new_nozzle > 0 and new_nozzle != old_nozzle:
2469
+ changed.add(idx)
2470
+ return changed
2471
+
2472
+
2473
+ def _record_scale_factors(record: dict) -> tuple[float, float, float] | None:
2474
+ """Per-axis target/original factors, or None when they can't be computed."""
2475
+ try:
2476
+ originals = [float(record.get(f"original_{axis}")) for axis in ("x", "y", "z")]
2477
+ targets = [float(record.get(f"target_{axis}")) for axis in ("x", "y", "z")]
2478
+ except (TypeError, ValueError):
2479
+ return None
2480
+ if any(not math.isfinite(v) or v <= 0 for v in originals + targets):
2481
+ return None
2482
+ return tuple(target / original for target, original in zip(targets, originals))
2483
+
2484
+
2485
+ def _propagate_group_scale_factors(
2486
+ records: list[dict],
2487
+ edited_axes: dict[int, str],
2488
+ recomputed_idx: set[int],
2489
+ joined_idx: set[int] | None = None,
2490
+ ) -> list[dict]:
2491
+ """Sync each multi-material group's scale factors from the edited member.
2492
+
2493
+ Shapes sharing a nozzle are one assembly, so a dimension edit on one
2494
+ part scales EVERY part by the same per-axis factor (target/original) —
2495
+ absolute values would distort assemblies whose parts differ in size.
2496
+ The source member must be unambiguous: the one whose row was actually
2497
+ recomputed this round (Keep Proportions), the single member the user
2498
+ edited, or — when a shape just JOINED the group by a nozzle edit — the
2499
+ incumbent members' shared factors, which the newcomer adopts. Groups
2500
+ already in sync, or with no clear source (e.g. stale .change echoes that
2501
+ flag several members at once), are left untouched, so the event storm
2502
+ converges instead of ping-ponging.
2503
+ """
2504
+ joined_idx = joined_idx or set()
2505
+
2506
+ def _triples_close(a: tuple, b: tuple) -> bool:
2507
+ return all(
2508
+ math.isclose(fa, fb, rel_tol=1e-6, abs_tol=1e-9) for fa, fb in zip(a, b)
2509
+ )
2510
+
2511
+ for members in _multi_material_groups(records).values():
2512
+ factors = {id(member): _record_scale_factors(member) for member in members}
2513
+ if any(value is None for value in factors.values()):
2514
+ continue
2515
+ triples = list(factors.values())
2516
+ if all(_triples_close(triples[0], triple) for triple in triples[1:]):
2517
+ continue # group already in sync
2518
+
2519
+ recomputed_members = [
2520
+ member for member in members if int(member.get("idx", 0)) in recomputed_idx
2521
+ ]
2522
+ edited_members = [
2523
+ member for member in members if int(member.get("idx", 0)) in edited_axes
2524
+ ]
2525
+ newcomers = [
2526
+ member for member in members if int(member.get("idx", 0)) in joined_idx
2527
+ ]
2528
+ incumbents = [
2529
+ member for member in members if int(member.get("idx", 0)) not in joined_idx
2530
+ ]
2531
+ if len(recomputed_members) == 1:
2532
+ source = recomputed_members[0]
2533
+ elif len(edited_members) == 1:
2534
+ source = edited_members[0]
2535
+ elif newcomers and incumbents and all(
2536
+ _triples_close(factors[id(incumbents[0])], factors[id(member)])
2537
+ for member in incumbents[1:]
2538
+ ):
2539
+ # Nozzle edit pulled newcomers into the group: they adopt the
2540
+ # incumbents' shared scale factors.
2541
+ source = incumbents[0]
2542
+ else:
2543
+ continue # ambiguous (stale echo): do not guess
2544
+
2545
+ source_factors = factors[id(source)]
2546
+ for member in members:
2547
+ if member is source:
2548
+ continue
2549
+ for axis, factor in zip(("x", "y", "z"), source_factors):
2550
+ member[f"target_{axis}"] = round(
2551
+ float(member[f"original_{axis}"]) * factor, 6
2552
+ )
2553
+ member["last_scaled_axis"] = source.get(
2554
+ "last_scaled_axis", member.get("last_scaled_axis")
2555
+ )
2556
+ return records
2557
+
2558
+
2559
  def normalize_shape_dimensions_for_mode(
2560
  records: list[dict] | None,
2561
  settings_table: Any | None,
2562
  scale_mode: str | None,
2563
  ) -> tuple:
2564
+ """Apply table edits to the records; in Keep Proportions, rescale each
2565
+ shape's other dimensions from the edited axis. In BOTH modes, a
2566
+ dimension edit on a multi-material group member (shapes sharing a
2567
+ nozzle) propagates its scale factors to the whole group, so assemblies
2568
+ stay proportional as one unit.
2569
+
2570
+ Wired to the table's .change event, WHICH ALSO FIRES FOR OUR OWN
2571
+ WRITE-BACK (Gradio's Dataframe does not emit .input for cell edits at
2572
+ all). The cascade is broken server-side: the table output is skipped
2573
+ whenever normalization did not change any dimension — so a user edit
2574
+ costs exactly two rounds (recompute + converged no-op), and programmatic
2575
+ table updates cost one no-op round instead of looping.
2576
+ """
2577
  edited_axes = _last_edited_target_axes(records, settings_table)
2578
+ joined_idx = _last_edited_nozzles(records, settings_table)
2579
  records = _apply_shape_settings(records or [], settings_table)
2580
  if _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR:
2581
+ normalized = [dict(record) for record in records]
2582
+ for record in normalized:
2583
  idx = int(record.get("idx", 0))
2584
  if idx in edited_axes:
2585
  record["last_scaled_axis"] = edited_axes[idx]
2586
+ normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
2587
+ changed = any(
2588
+ not math.isclose(
2589
+ float(before.get(key) or 0.0),
2590
+ float(after.get(key) or 0.0),
2591
+ rel_tol=0.0,
2592
+ abs_tol=1e-9,
2593
+ )
2594
+ for before, after in zip(records, normalized)
2595
+ for key in TARGET_DIMENSION_KEYS
2596
+ )
2597
+ if not changed and not _bool_cells_need_rewrite(settings_table):
2598
+ return normalized, gr.skip()
2599
+ return normalized, _shape_settings_rows(normalized)
2600
 
2601
+ recomputed_idx: set[int] = set()
2602
  normalized: list[dict] = []
2603
  for record in records:
2604
  copy = dict(record)
2605
+ if not copy.get("stl_path"):
2606
+ # Split pieces: their targets are CELL sizes while original_* is
2607
+ # inherited from the parent shape, so the ratio logic would
2608
+ # misread them as mid-edit and "restore" the parent dimensions.
2609
+ # Piece dimensions are informational — never rescale them.
2610
+ normalized.append(copy)
2611
+ continue
2612
  originals = np.asarray([
2613
  copy.get("original_x"),
2614
  copy.get("original_y"),
 
2630
  normalized.append(copy)
2631
  continue
2632
  idx = int(copy.get("idx", 0))
2633
+
2634
+ # Anchor on the row's own evidence: in a proportional row all three
2635
+ # target/original ratios agree; a user edit makes exactly one ratio
2636
+ # the odd one out. This is ORDER-INDEPENDENT — the .change event
2637
+ # storm delivers stale/echoed tables whose rows are self-consistent,
2638
+ # and those must recompute to themselves (skip) instead of being
2639
+ # diffed against fresher records, mis-anchored, and reverted.
2640
+ ratios = targets / originals
2641
+
2642
+ def _close(a: float, b: float) -> bool:
2643
+ return math.isclose(float(a), float(b), rel_tol=1e-6, abs_tol=1e-9)
2644
+
2645
+ if _close(ratios[0], ratios[1]) and _close(ratios[1], ratios[2]):
2646
+ # Already proportional (a pristine row or an echo of our own
2647
+ # write-back): nothing to recompute.
2648
+ if idx in edited_axes:
2649
+ copy["last_scaled_axis"] = edited_axes[idx]
2650
+ normalized.append(copy)
2651
+ continue
2652
+ # others_agree[i] means the OTHER two ratios agree -> axis i was edited.
2653
+ others_agree = [
2654
+ _close(ratios[1], ratios[2]),
2655
+ _close(ratios[0], ratios[2]),
2656
+ _close(ratios[0], ratios[1]),
2657
+ ]
2658
+ if sum(others_agree) == 1:
2659
+ anchor_index = others_agree.index(True)
2660
+ else:
2661
+ # All three differ (e.g. custom dims from Independent mode):
2662
+ # fall back to the detected edit, then the last anchor.
2663
+ anchor_key = edited_axes.get(idx) or copy.get("last_scaled_axis") or "target_x"
2664
+ try:
2665
+ anchor_index = TARGET_DIMENSION_KEYS.index(str(anchor_key))
2666
+ except ValueError:
2667
+ anchor_index = 0
2668
  scale = float(targets[anchor_index] / originals[anchor_index])
2669
  copy["last_scaled_axis"] = TARGET_DIMENSION_KEYS[anchor_index]
2670
  scaled = originals * scale
2671
  copy["target_x"] = round(float(scaled[0]), 6)
2672
  copy["target_y"] = round(float(scaled[1]), 6)
2673
  copy["target_z"] = round(float(scaled[2]), 6)
2674
+ recomputed_idx.add(idx)
2675
  normalized.append(copy)
2676
+
2677
+ normalized = _propagate_group_scale_factors(
2678
+ normalized, edited_axes, recomputed_idx, joined_idx
2679
+ )
2680
+
2681
+ # Idempotence guard (breaks the .change write-back cascade): only write
2682
+ # the table when a dimension actually changed — or when the checkbox
2683
+ # columns need a canonical re-render after a header "select all".
2684
+ changed = any(
2685
+ not math.isclose(
2686
+ float(before.get(key) or 0.0),
2687
+ float(after.get(key) or 0.0),
2688
+ rel_tol=0.0,
2689
+ abs_tol=1e-9,
2690
+ )
2691
+ for before, after in zip(records, normalized)
2692
+ for key in TARGET_DIMENSION_KEYS
2693
+ )
2694
+ if not changed and not _bool_cells_need_rewrite(settings_table):
2695
+ return normalized, gr.skip()
2696
  return normalized, _shape_settings_rows(normalized)
2697
 
2698
 
 
2723
  record: dict,
2724
  layer_height: float,
2725
  scale_mode: str | None,
2726
+ slice_plan: tuple[list[float], tuple[float, float, float], float | None] | None = None,
2727
  ) -> dict:
2728
+ z_levels = slice_plan[0] if slice_plan else None
2729
+ anchor = slice_plan[1] if slice_plan else None
2730
+ z_flip_mid = slice_plan[2] if slice_plan else None
2731
  return {
2732
  "layer_height": float(layer_height),
2733
  "scale_mode": _normalize_scale_mode(scale_mode),
2734
  "target_x": record.get("target_x"),
2735
  "target_y": record.get("target_y"),
2736
  "target_z": record.get("target_z"),
2737
+ # Multi-material groups: the shared Z grid + scale anchor
2738
+ # fingerprint. Adding/removing an assembly part changes them, which
2739
+ # correctly marks every part's slices stale.
2740
  "z_grid": (round(z_levels[0], 6), len(z_levels)) if z_levels else None,
2741
+ "scale_anchor": tuple(round(v, 6) for v in anchor) if anchor else None,
2742
+ "flip_z": bool(record.get("flip_z", False)),
2743
+ "z_flip_mid": round(z_flip_mid, 6) if z_flip_mid is not None else None,
2744
  }
2745
 
2746
 
 
2805
  stack.contour_paths = None
2806
 
2807
 
2808
+ def _multi_material_slice_plan(
2809
  records: list[dict],
2810
  layer_height: float,
2811
  scale_mode: str | None,
2812
+ ) -> tuple[list[float], tuple[float, float, float], float | None] | None:
2813
+ """(shared Z grid, shared scale anchor, Z-flip midplane) for one group.
2814
+
2815
+ Group members must slice on the SAME planes so a part that starts
2816
+ higher gets empty lower layers instead of having its first material
2817
+ layer treated as layer 0 — and any target-dimension scaling must happen
2818
+ about ONE shared point (the group's combined un-scaled corner), or
2819
+ same-factor scaling would still shift the parts relative to each other.
2820
+ When any member has Flip Z checked, the WHOLE assembly mirrors about
2821
+ the group's combined Z midplane (so it stays assembled, just printed
2822
+ the other way up); the midplane is returned, else None.
2823
  """
2824
+ loaded: list[tuple[Any, tuple[float, float, float]]] = []
2825
+ corner = [math.inf, math.inf, math.inf]
2826
  for record in records:
2827
  stl_path = record.get("stl_path")
2828
  if not stl_path:
 
2837
  record.get("target_y"),
2838
  record.get("target_z"),
2839
  )
2840
+ except Exception:
2841
+ continue
2842
+ loaded.append((mesh, scale_factors))
2843
+ for axis in range(3):
2844
+ corner[axis] = min(corner[axis], float(mesh.bounds[0][axis]))
2845
+ if not loaded or not all(math.isfinite(value) for value in corner):
2846
+ return None
2847
+
2848
+ anchor = (corner[0], corner[1], corner[2])
2849
+ z_lo = math.inf
2850
+ z_hi = -math.inf
2851
+ for mesh, scale_factors in loaded:
2852
+ try:
2853
+ scaled = scale_mesh(mesh, scale_factors, anchor=anchor)
2854
  except Exception:
2855
  continue
2856
  z_lo = min(z_lo, float(scaled.bounds[0][2]))
2857
  z_hi = max(z_hi, float(scaled.bounds[1][2]))
2858
  if not math.isfinite(z_lo) or not math.isfinite(z_hi):
2859
  return None
2860
+ # Mirroring about the group midplane preserves the group's Z range, so
2861
+ # the shared grid stays valid for the flipped assembly.
2862
+ z_flip_mid = (z_lo + z_hi) / 2.0 if any(r.get("flip_z") for r in records) else None
2863
+ return calculate_z_levels(z_lo, z_hi, float(layer_height)), anchor, z_flip_mid
2864
 
2865
 
2866
  def _slice_record(
 
2868
  layer_height: float,
2869
  scale_mode: str | None,
2870
  progress_callback=None,
2871
+ slice_plan: tuple[list[float], tuple[float, float, float], float | None] | None = None,
2872
  ) -> LayerStack:
2873
  stl_path = record["stl_path"]
2874
  mesh = load_mesh(stl_path)
 
2880
  record.get("target_y"),
2881
  record.get("target_z"),
2882
  )
2883
+ # Group members flip together about the group midplane (any member's
2884
+ # Flip Z flips the whole assembly); solo shapes flip about their own.
2885
+ z_flip_mid = slice_plan[2] if slice_plan else None
2886
+ flip_z = (z_flip_mid is not None) if slice_plan else bool(record.get("flip_z", False))
2887
  stack = slice_stl_to_layers(
2888
  stl_path,
2889
  layer_height=float(layer_height),
2890
  progress_callback=progress_callback,
2891
  scale_factors=scale_factors,
2892
  name=str(record.get("name") or Path(stl_path).stem),
2893
+ z_levels=slice_plan[0] if slice_plan else None,
2894
+ scale_anchor=slice_plan[1] if slice_plan else None,
2895
+ flip_z=flip_z,
2896
+ z_flip_mid=z_flip_mid,
2897
  )
2898
  record["layer_stack"] = stack
2899
+ record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
2900
  return stack
2901
 
2902
 
 
2905
  layer_height: float,
2906
  scale_mode: str | None,
2907
  messages: list[str] | None = None,
2908
+ ) -> dict[int, tuple[list[float], tuple[float, float, float]]]:
2909
+ """Per multi-material group member: (shared Z grid, shared scale anchor),
2910
+ keyed by record id."""
2911
+ plan_by_record: dict[int, tuple[list[float], tuple[float, float, float]]] = {}
2912
  for nozzle, members in sorted(_multi_material_groups(records).items()):
2913
+ plan = _multi_material_slice_plan(members, layer_height, scale_mode)
2914
+ if plan is None:
2915
  continue
2916
+ z_levels = plan[0]
2917
  for member in members:
2918
+ plan_by_record[id(member)] = plan
2919
  if messages is not None:
2920
  names = ", ".join(str(m.get("name") or f"Shape {m['idx']}") for m in members)
2921
+ note = (
2922
  f"Multi-material group (nozzle {nozzle}): {names} — sliced on one "
2923
  f"shared Z grid ({len(z_levels)} layers), positions locked together."
2924
  )
2925
+ if plan[2] is not None:
2926
+ note += " Flip Z is set: the whole assembly prints mirrored top-to-bottom."
2927
+ messages.append(note)
2928
+ return plan_by_record
2929
 
2930
 
2931
  def generate_dynamic_layer_stacks(
 
2941
  return records, "Upload at least one STL first.", None
2942
  total = len(records)
2943
  messages: list[str] = []
2944
+ plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode, messages)
2945
  for pos, record in enumerate(records):
2946
  stl_path = record.get("stl_path")
2947
  if not stl_path:
 
2956
 
2957
  try:
2958
  stack = _slice_record(
2959
+ record, layer_height, scale_mode, report_progress, plan_by_record.get(id(record))
2960
  )
2961
  (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
2962
  messages.append(
 
3307
  messages: list[str],
3308
  ) -> bool:
3309
  """Re-slice records whose layers are missing or stale for the current settings."""
3310
+ plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
3311
  resliced = False
3312
  for record in records:
3313
  stl_path = record.get("stl_path")
3314
  if not stl_path:
3315
  continue # Split pieces carry their clipped layers; nothing to re-slice.
3316
+ slice_plan = plan_by_record.get(id(record))
3317
+ current = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
3318
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
3319
  continue
3320
  try:
3321
+ stack = _slice_record(record, layer_height, scale_mode, None, slice_plan)
3322
  messages.append(
3323
  f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
3324
  )
 
3716
  elem_id="pp-color-apply",
3717
  elem_classes=["pp-visually-hidden"],
3718
  )
3719
+ bulk_sink = gr.Textbox(
3720
+ label="bulk sink",
3721
+ container=False,
3722
+ elem_id="pp-bulk-sink",
3723
+ elem_classes=["pp-visually-hidden"],
3724
+ )
3725
+ bulk_apply = gr.Button(
3726
+ "apply bulk",
3727
+ elem_id="pp-bulk-apply",
3728
+ elem_classes=["pp-visually-hidden"],
3729
+ )
3730
  shape_settings = gr.Dataframe(
3731
  headers=SHAPE_SETTINGS_HEADERS,
3732
  value=[],
 
3977
  outputs=[shape_records, shape_settings],
3978
  queue=False,
3979
  )
3980
+ bulk_apply.click(
3981
+ fn=apply_bulk_bool_selection,
3982
+ inputs=[shape_records, shape_settings, bulk_sink],
3983
+ outputs=[shape_records, shape_settings],
3984
+ queue=False,
3985
+ )
3986
  shape_settings.select(
3987
  fn=delete_shape_from_settings,
3988
  inputs=[shape_records, shape_settings, last_shape_delete_at],
3989
  outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
3990
+ queue=False,
3991
  ).then(
3992
  fn=lambda records: _dropdown_update(records),
3993
  inputs=[shape_records],
 
4001
  )
4002
 
4003
  preview_inputs = [shape_records, selected_shape, shape_settings, model_opacity, scale_mode]
4004
+ # .change is the ONLY event the Dataframe emits for cell edits (it has
4005
+ # no working .input) — and it also fires for the normalizer's own
4006
+ # write-back. The infinite/minute-long cascade is prevented inside
4007
+ # normalize_shape_dimensions_for_mode: it skips the table output
4008
+ # whenever no dimension actually changed.
4009
  shape_settings.change(
4010
  fn=normalize_shape_dimensions_for_mode,
4011
  inputs=[shape_records, shape_settings, scale_mode],
stl_slicer.py CHANGED
@@ -88,15 +88,28 @@ def _normalize_scale_factors(scale_factors: Sequence[float] | None) -> ScaleFact
88
  return (values[0], values[1], values[2])
89
 
90
 
91
- def scale_mesh(mesh: trimesh.Trimesh, scale_factors: Sequence[float] | None = None) -> trimesh.Trimesh:
92
- """Return a copy of `mesh` scaled around its minimum XYZ corner."""
 
 
 
 
 
 
 
 
 
 
 
93
  sx, sy, sz = _normalize_scale_factors(scale_factors)
94
  scaled = mesh.copy()
95
 
96
  if math.isclose(sx, 1.0) and math.isclose(sy, 1.0) and math.isclose(sz, 1.0):
97
  return scaled
98
 
99
- anchor = np.asarray(mesh.bounds[0], dtype=float)
 
 
100
  transform = np.eye(4)
101
  transform[0, 0] = sx
102
  transform[1, 1] = sy
@@ -285,15 +298,37 @@ def slice_stl_to_layers(
285
  scale_factors: Sequence[float] | None = None,
286
  name: str | None = None,
287
  z_levels: Sequence[float] | None = None,
 
 
 
288
  ) -> LayerStack:
289
  """Slice an STL into per-layer vector outlines (world-XY millimetres).
290
 
291
  `z_levels` overrides the per-mesh Z planes with an explicit (world) grid —
292
  used by multi-material assemblies so every part slices on ONE shared grid
293
- and parts that start higher simply get empty lower layers.
 
 
 
 
 
 
 
294
  """
295
  stl_path = Path(stl_path)
296
- mesh = scale_mesh(load_mesh(stl_path), scale_factors)
 
 
 
 
 
 
 
 
 
 
 
 
297
  (x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
298
 
299
  if z_levels is not None:
 
88
  return (values[0], values[1], values[2])
89
 
90
 
91
+ def scale_mesh(
92
+ mesh: trimesh.Trimesh,
93
+ scale_factors: Sequence[float] | None = None,
94
+ anchor: Sequence[float] | None = None,
95
+ ) -> trimesh.Trimesh:
96
+ """Return a copy of `mesh` scaled around `anchor` (its own minimum XYZ
97
+ corner by default).
98
+
99
+ Multi-material assembly parts pass their GROUP's combined corner: parts
100
+ scaled by the same factors about one shared point stay assembled, while
101
+ each scaling about its own corner would shift them relative to each
102
+ other.
103
+ """
104
  sx, sy, sz = _normalize_scale_factors(scale_factors)
105
  scaled = mesh.copy()
106
 
107
  if math.isclose(sx, 1.0) and math.isclose(sy, 1.0) and math.isclose(sz, 1.0):
108
  return scaled
109
 
110
+ anchor = np.asarray(
111
+ mesh.bounds[0] if anchor is None else anchor, dtype=float
112
+ )
113
  transform = np.eye(4)
114
  transform[0, 0] = sx
115
  transform[1, 1] = sy
 
298
  scale_factors: Sequence[float] | None = None,
299
  name: str | None = None,
300
  z_levels: Sequence[float] | None = None,
301
+ scale_anchor: Sequence[float] | None = None,
302
+ flip_z: bool = False,
303
+ z_flip_mid: float | None = None,
304
  ) -> LayerStack:
305
  """Slice an STL into per-layer vector outlines (world-XY millimetres).
306
 
307
  `z_levels` overrides the per-mesh Z planes with an explicit (world) grid —
308
  used by multi-material assemblies so every part slices on ONE shared grid
309
+ and parts that start higher simply get empty lower layers. `scale_anchor`
310
+ is the point target-dimension scaling happens about (assembly parts share
311
+ their group's corner so they stay assembled when rescaled).
312
+
313
+ `flip_z` mirrors the scaled mesh about the horizontal plane at
314
+ `z_flip_mid` (its own Z midpoint by default) — printing the shape the
315
+ other way up. Assembly parts pass their GROUP's midplane so the whole
316
+ assembly flips as one unit.
317
  """
318
  stl_path = Path(stl_path)
319
+ mesh = scale_mesh(load_mesh(stl_path), scale_factors, anchor=scale_anchor)
320
+ if flip_z:
321
+ mid = (
322
+ float(z_flip_mid)
323
+ if z_flip_mid is not None
324
+ else (float(mesh.bounds[0][2]) + float(mesh.bounds[1][2])) / 2.0
325
+ )
326
+ mirror = np.eye(4)
327
+ mirror[2, 2] = -1.0
328
+ mirror[2, 3] = 2.0 * mid
329
+ # apply_transform reverses face winding for negative determinants,
330
+ # so normals stay outward and cavity detection keeps working.
331
+ mesh.apply_transform(mirror)
332
  (x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
333
 
334
  if z_levels is not None:
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[-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]
@@ -691,6 +691,8 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
691
  ]
692
 
693
  first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
 
 
694
  second_outputs = delete_shape_from_settings(
695
  first_outputs[1],
696
  first_outputs[2],
@@ -698,7 +700,26 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
698
  Event(),
699
  )
700
 
701
- assert [record["name"] for record in second_outputs[1]] == ["first", "last"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
 
703
 
704
  def test_group_split_splits_all_materials_on_one_shared_grid() -> None:
@@ -816,3 +837,259 @@ def test_describe_split_source_warns_about_group_splits() -> None:
816
  # No selection defaults to the first shape - which is grouped here.
817
  assert "whole group as one shape" in describe_split_source(records, None)
818
  assert describe_split_source([], None) == SPLIT_STATUS_DEFAULT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]
 
691
  ]
692
 
693
  first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
694
+ assert [record["name"] for record in first_outputs[1]] == ["first", "last"]
695
+
696
  second_outputs = delete_shape_from_settings(
697
  first_outputs[1],
698
  first_outputs[2],
 
700
  Event(),
701
  )
702
 
703
+ # Blocked by the cooldown: every output is skipped (nothing rewritten).
704
+ assert not isinstance(second_outputs[1], list)
705
+
706
+
707
+ def test_non_delete_cell_selection_touches_nothing() -> None:
708
+ # The select handler fires on EVERY cell click; unless the click is on
709
+ # the Delete column it must skip all outputs — echoing the table here
710
+ # raced (and clobbered) the Keep Proportions dimension recompute.
711
+ class Event:
712
+ index = (0, 3) # a Target Y cell
713
+
714
+ records = [
715
+ {"idx": 1, "name": "first", "stl_path": "first.stl", "target_x": 10.0, "target_y": 11.0, "target_z": 12.0, "pressure": 25, "valve": 4, "port": 1, "color": "#111111"},
716
+ ]
717
+
718
+ outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
719
+
720
+ assert len(outputs) == 8
721
+ assert all(not isinstance(value, list) for value in outputs)
722
+ assert not isinstance(outputs[1], list) # records State untouched
723
 
724
 
725
  def test_group_split_splits_all_materials_on_one_shared_grid() -> None:
 
837
  # No selection defaults to the first shape - which is grouped here.
838
  assert "whole group as one shape" in describe_split_source(records, None)
839
  assert describe_split_source([], None) == SPLIT_STATUS_DEFAULT
840
+
841
+
842
+ def test_keep_proportions_is_stale_echo_proof() -> None:
843
+ # The table's .change event delivers stale/echoed tables out of order.
844
+ # Anchoring must come from the ROW's own ratios (odd one out), never from
845
+ # diffing against fresher records - that mis-anchored and reverted the
846
+ # first edit. Self-consistent rows must skip the table write entirely.
847
+ import gradio as gr
848
+
849
+ def _record(**overrides):
850
+ record = {
851
+ "idx": 1, "name": "cube", "stl_path": "cube.stl",
852
+ "original_x": 38.1, "original_y": 38.1, "original_z": 32.99557,
853
+ "target_x": 38.1, "target_y": 38.1, "target_z": 32.99557,
854
+ "pressure": 25.0, "valve": 4, "nozzle": 1, "port": 1,
855
+ "color": "#111111", "last_scaled_axis": "target_x",
856
+ }
857
+ record.update(overrides)
858
+ return record
859
+
860
+ # 1) A user edit (odd ratio on Y) rescales everything and writes the table.
861
+ records = [_record()]
862
+ rows = _shape_settings_rows(records)
863
+ rows[0][3] = 50.0
864
+ updated, table_out = normalize_shape_dimensions_for_mode(records, rows, SCALE_MODE_UNIFORM_FACTOR)
865
+ assert updated[0]["target_x"] == 50.0
866
+ assert updated[0]["target_z"] == 43.301273
867
+ assert isinstance(table_out, list) # table written
868
+
869
+ # 2) A stale PRE-EDIT echo arrives while records already hold the scaled
870
+ # dims: the row is self-consistent, so no re-anchor, no revert write.
871
+ stale_rows = _shape_settings_rows([_record()])
872
+ updated2, table_out2 = normalize_shape_dimensions_for_mode(updated, stale_rows, SCALE_MODE_UNIFORM_FACTOR)
873
+ assert not isinstance(table_out2, list) # table output skipped
874
+
875
+ # 3) The echo of our own scaled write-back is also a no-op.
876
+ scaled_rows = _shape_settings_rows(updated)
877
+ updated3, table_out3 = normalize_shape_dimensions_for_mode(updated, scaled_rows, SCALE_MODE_UNIFORM_FACTOR)
878
+ assert not isinstance(table_out3, list)
879
+ assert updated3[0]["target_x"] == 50.0
880
+ assert updated3[0]["target_z"] == 43.301273
881
+
882
+
883
+ def _mm_member(idx: int, nozzle: int, ox: float, oy: float, oz: float) -> dict:
884
+ return {
885
+ "idx": idx, "name": f"part{idx}", "stl_path": f"part{idx}.stl",
886
+ "original_x": ox, "original_y": oy, "original_z": oz,
887
+ "target_x": ox, "target_y": oy, "target_z": oz,
888
+ "pressure": 25.0, "valve": 4, "nozzle": nozzle, "port": 1,
889
+ "color": "#111111", "last_scaled_axis": "target_x",
890
+ }
891
+
892
+
893
+ def test_group_members_share_scale_factors_in_independent_mode() -> None:
894
+ from app import SCALE_MODE_TARGET_DIMENSIONS
895
+
896
+ # Two assembly parts of DIFFERENT sizes on nozzle 1, a solo on nozzle 2.
897
+ records = [
898
+ _mm_member(1, 1, 40.0, 20.0, 10.0),
899
+ _mm_member(2, 1, 20.0, 20.0, 10.0),
900
+ _mm_member(3, 2, 30.0, 30.0, 30.0),
901
+ ]
902
+ rows = _shape_settings_rows(records)
903
+ rows[0][2] = 60.0 # X of part 1: factor 1.5
904
+
905
+ updated, table_out = normalize_shape_dimensions_for_mode(
906
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
907
+ )
908
+
909
+ # Part 2 gets the same FACTOR (x 20 -> 30), not the same absolute value.
910
+ assert updated[0]["target_x"] == 60.0
911
+ assert updated[1]["target_x"] == 30.0
912
+ # Unedited axes keep factor 1.
913
+ assert updated[0]["target_y"] == 20.0 and updated[1]["target_y"] == 20.0
914
+ # Solo shape on its own nozzle is untouched.
915
+ assert updated[2]["target_x"] == 30.0
916
+ assert isinstance(table_out, list) # propagation must be written back
917
+
918
+
919
+ def test_group_members_share_scale_factors_in_keep_proportions() -> None:
920
+ records = [
921
+ _mm_member(1, 1, 40.0, 20.0, 10.0),
922
+ _mm_member(2, 1, 20.0, 20.0, 10.0),
923
+ ]
924
+ rows = _shape_settings_rows(records)
925
+ rows[0][3] = 30.0 # Y of part 1: factor 1.5
926
+
927
+ updated, table_out = normalize_shape_dimensions_for_mode(
928
+ records, rows, SCALE_MODE_UNIFORM_FACTOR
929
+ )
930
+
931
+ # Part 1 rescales proportionally; part 2 follows with the same factor.
932
+ assert [updated[0][k] for k in ("target_x", "target_y", "target_z")] == [60.0, 30.0, 15.0]
933
+ assert [updated[1][k] for k in ("target_x", "target_y", "target_z")] == [30.0, 30.0, 15.0]
934
+ assert isinstance(table_out, list)
935
+
936
+ # The echo of that write-back is a converged no-op.
937
+ echoed_rows = _shape_settings_rows(updated)
938
+ updated2, table_out2 = normalize_shape_dimensions_for_mode(
939
+ updated, echoed_rows, SCALE_MODE_UNIFORM_FACTOR
940
+ )
941
+ assert not isinstance(table_out2, list)
942
+ assert updated2[1]["target_x"] == 30.0
943
+
944
+
945
+ def test_group_propagation_skips_when_the_source_is_ambiguous() -> None:
946
+ from app import SCALE_MODE_TARGET_DIMENSIONS
947
+
948
+ # A stale echo can make SEVERAL members look edited at once: the
949
+ # propagation must not guess a source (guessing reverted edits).
950
+ records = [
951
+ _mm_member(1, 1, 40.0, 20.0, 10.0),
952
+ _mm_member(2, 1, 20.0, 20.0, 10.0),
953
+ ]
954
+ records[0]["target_x"] = 60.0 # records already hold part 1 scaled...
955
+ rows = _shape_settings_rows(records)
956
+ rows[0][2] = 44.0 # ...while the table flags edits on BOTH members
957
+ rows[1][2] = 24.0
958
+
959
+ updated, _table_out = normalize_shape_dimensions_for_mode(
960
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
961
+ )
962
+
963
+ # Both edits applied as-is; no propagation happened (factors differ).
964
+ assert updated[0]["target_x"] == 44.0
965
+ assert updated[1]["target_x"] == 24.0
966
+
967
+
968
+ def test_joining_a_nozzle_group_adopts_the_group_scale() -> None:
969
+ from app import SCALE_MODE_TARGET_DIMENSIONS
970
+
971
+ # Nozzle-1 group already scaled x1.5; a solo shape moves onto nozzle 1
972
+ # via the Nozzle column and must adopt the group's factors.
973
+ member_a = _mm_member(1, 1, 40.0, 20.0, 10.0)
974
+ member_b = _mm_member(2, 1, 20.0, 20.0, 10.0)
975
+ for member in (member_a, member_b):
976
+ for axis in ("x", "y", "z"):
977
+ member[f"target_{axis}"] = member[f"original_{axis}"] * 1.5
978
+ solo = _mm_member(3, 2, 30.0, 10.0, 10.0)
979
+
980
+ records = [member_a, member_b, solo]
981
+ rows = _shape_settings_rows(records)
982
+ nozzle_pos = SHAPE_SETTINGS_HEADERS.index("Nozzle")
983
+ rows[2][nozzle_pos] = 1 # solo joins the assembly
984
+
985
+ updated, table_out = normalize_shape_dimensions_for_mode(
986
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
987
+ )
988
+
989
+ assert [updated[2][k] for k in ("target_x", "target_y", "target_z")] == [45.0, 15.0, 15.0]
990
+ # Incumbents unchanged.
991
+ assert updated[0]["target_x"] == 60.0
992
+ assert isinstance(table_out, list)
993
+
994
+ # The echo of that write-back converges (nozzle now matches the record).
995
+ echoed = _shape_settings_rows(updated)
996
+ updated2, table_out2 = normalize_shape_dimensions_for_mode(
997
+ updated, echoed, SCALE_MODE_TARGET_DIMENSIONS
998
+ )
999
+ assert not isinstance(table_out2, list)
1000
+ assert updated2[2]["target_x"] == 45.0
1001
+
1002
+
1003
+ def test_split_pieces_are_never_rescaled_by_keep_proportions() -> None:
1004
+ # Regression: split pieces inherit the parent's original_* dims while
1005
+ # their targets are the CELL sizes; a table echo in Keep Proportions
1006
+ # used to "restore" the parent dimensions (shapes visibly reset after
1007
+ # e.g. a color change).
1008
+ piece = {
1009
+ "idx": 1, "name": "cube - R1C1", "stl_path": None,
1010
+ "original_x": 30.0, "original_y": 30.0, "original_z": 30.0,
1011
+ "target_x": 15.2, "target_y": 15.2, "target_z": 30.0,
1012
+ "pressure": 25.0, "valve": 4, "nozzle": 1, "port": 1,
1013
+ "color": "#111111", "split_group_id": "split-1", "split_columns": 2,
1014
+ "split_rows": 2, "last_scaled_axis": "target_x",
1015
+ }
1016
+ records = [piece, dict(piece, idx=2, name="cube - R1C2", nozzle=1, valve=5)]
1017
+ rows = _shape_settings_rows(records)
1018
+
1019
+ updated, table_out = normalize_shape_dimensions_for_mode(
1020
+ records, rows, SCALE_MODE_UNIFORM_FACTOR
1021
+ )
1022
+
1023
+ assert updated[0]["target_x"] == 15.2 # NOT reset to 30
1024
+ assert updated[0]["target_z"] == 30.0
1025
+ assert updated[1]["target_x"] == 15.2
1026
+ assert not isinstance(table_out, list) # nothing changed, no write-back
1027
+
1028
+
1029
+ def test_select_all_string_bools_trigger_a_canonical_rewrite() -> None:
1030
+ # Gradio's header "select all" writes STRING "true"/"false" into the
1031
+ # checkbox columns (rendered as text / stray stale checkboxes). The
1032
+ # normalizer must answer with real rows so the table re-renders with
1033
+ # proper booleans; a clean payload must stay a no-op.
1034
+ from app import SCALE_MODE_TARGET_DIMENSIONS
1035
+
1036
+ records = [
1037
+ _mm_member(1, 1, 10.0, 10.0, 10.0),
1038
+ _mm_member(2, 2, 10.0, 10.0, 10.0),
1039
+ ]
1040
+ rows = _shape_settings_rows(records)
1041
+ lead_in_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
1042
+ for row in rows:
1043
+ row[lead_in_pos] = "true" # select-all artifact
1044
+
1045
+ updated, table_out = normalize_shape_dimensions_for_mode(
1046
+ records, rows, SCALE_MODE_TARGET_DIMENSIONS
1047
+ )
1048
+
1049
+ assert all(record["lead_in"] is True for record in updated)
1050
+ assert isinstance(table_out, list) # canonical rewrite issued
1051
+ assert all(row[lead_in_pos] is True for row in table_out) # real booleans
1052
+
1053
+ # The rewrite's echo is clean -> converges to a no-op.
1054
+ updated2, table_out2 = normalize_shape_dimensions_for_mode(
1055
+ updated, table_out, SCALE_MODE_TARGET_DIMENSIONS
1056
+ )
1057
+ assert not isinstance(table_out2, list)
1058
+
1059
+ # Unchecking via select-all ("false" strings) round-trips too.
1060
+ rows_off = _shape_settings_rows(updated2)
1061
+ for row in rows_off:
1062
+ row[lead_in_pos] = "false"
1063
+ updated3, table_out3 = normalize_shape_dimensions_for_mode(
1064
+ updated2, rows_off, SCALE_MODE_TARGET_DIMENSIONS
1065
+ )
1066
+ assert all(record["lead_in"] is False for record in updated3)
1067
+ assert isinstance(table_out3, list)
1068
+
1069
+
1070
+ def test_apply_bulk_bool_selection_sets_a_whole_column() -> None:
1071
+ from app import apply_bulk_bool_selection
1072
+
1073
+ records = [
1074
+ _mm_member(1, 1, 10.0, 10.0, 10.0),
1075
+ _mm_member(2, 2, 10.0, 10.0, 10.0),
1076
+ _mm_member(3, 3, 10.0, 10.0, 10.0),
1077
+ ]
1078
+ flip_pos = SHAPE_SETTINGS_HEADERS.index("Flip Z")
1079
+ lead_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
1080
+
1081
+ updated, rows = apply_bulk_bool_selection(records, None, f"{flip_pos}|1")
1082
+ assert all(record["flip_z"] is True for record in updated)
1083
+ assert all(record.get("lead_in") is not True for record in updated) # no bleed
1084
+ assert all(row[flip_pos] is True for row in rows)
1085
+ assert all(row[lead_pos] is False for row in rows)
1086
+
1087
+ # Unchecking clears the whole column; junk payloads change nothing.
1088
+ cleared, rows2 = apply_bulk_bool_selection(updated, rows, f"{flip_pos}|0")
1089
+ assert all(record["flip_z"] is False for record in cleared)
1090
+ same, _rows3 = apply_bulk_bool_selection(cleared, rows2, "garbage")
1091
+ assert all(record["flip_z"] is False for record in same)
1092
+ # Non-bool columns are refused.
1093
+ color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
1094
+ refused, _rows4 = apply_bulk_bool_selection(cleared, rows2, f"{color_pos}|1")
1095
+ assert refused[0].get("color") == cleared[0].get("color")
tests/test_stl_slicer.py CHANGED
@@ -140,3 +140,79 @@ def test_slice_stl_handles_abutting_cells_and_stray_open_quads(tmp_path) -> None
140
  for layer in stack.layers:
141
  assert layer.area == pytest.approx(500.0)
142
  assert layer.bounds == pytest.approx((0.0, 0.0, 30.0, 30.0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  for layer in stack.layers:
141
  assert layer.area == pytest.approx(500.0)
142
  assert layer.bounds == pytest.approx((0.0, 0.0, 30.0, 30.0))
143
+
144
+
145
+ def test_scale_mesh_about_an_explicit_anchor() -> None:
146
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
147
+ mesh.apply_translation((6.0, 6.0, 6.0)) # spans 5..7 on every axis
148
+
149
+ scaled = scale_mesh(mesh, (2.0, 2.0, 2.0), anchor=(0.0, 0.0, 0.0))
150
+
151
+ # Scaling about the shared origin: 5..7 becomes 10..14 (own-corner
152
+ # scaling would give 5..9 and shift the part within an assembly).
153
+ np.testing.assert_allclose(scaled.bounds[0], (10.0, 10.0, 10.0))
154
+ np.testing.assert_allclose(scaled.bounds[1], (14.0, 14.0, 14.0))
155
+
156
+
157
+ def test_slice_stl_scale_anchor_keeps_assembly_parts_together(tmp_path) -> None:
158
+ # Two assembly parts side by side; both scaled x2 about the ASSEMBLY
159
+ # corner must stay adjacent (B's min corner moves from 4 to 8).
160
+ a = trimesh.creation.box(extents=(4.0, 4.0, 4.0))
161
+ a.apply_translation((2.0, 2.0, 2.0)) # spans 0..4
162
+ b = trimesh.creation.box(extents=(4.0, 4.0, 4.0))
163
+ b.apply_translation((6.0, 2.0, 2.0)) # spans 4..8 in x
164
+ path_a = tmp_path / "a.stl"
165
+ path_b = tmp_path / "b.stl"
166
+ a.export(path_a)
167
+ b.export(path_b)
168
+
169
+ anchor = (0.0, 0.0, 0.0)
170
+ stack_a = slice_stl_to_layers(path_a, 1.0, scale_factors=(2.0, 2.0, 2.0), scale_anchor=anchor)
171
+ stack_b = slice_stl_to_layers(path_b, 1.0, scale_factors=(2.0, 2.0, 2.0), scale_anchor=anchor)
172
+
173
+ assert stack_a.bounds[0][0] == pytest.approx(0.0)
174
+ assert stack_a.bounds[1][0] == pytest.approx(8.0)
175
+ assert stack_b.bounds[0][0] == pytest.approx(8.0) # still flush against A
176
+ assert stack_b.bounds[1][0] == pytest.approx(16.0)
177
+
178
+
179
+ def test_flip_z_mirrors_the_shape_top_to_bottom(tmp_path) -> None:
180
+ # Wide slab with a narrow tower on top; flipped, the tower prints first.
181
+ slab = trimesh.creation.box(extents=(10.0, 10.0, 1.0))
182
+ slab.apply_translation((5.0, 5.0, 0.5)) # z 0..1
183
+ tower = trimesh.creation.box(extents=(2.0, 2.0, 1.0))
184
+ tower.apply_translation((5.0, 5.0, 1.5)) # z 1..2
185
+ stl_path = tmp_path / "tower.stl"
186
+ trimesh.util.concatenate([slab, tower]).export(stl_path)
187
+
188
+ normal = slice_stl_to_layers(stl_path, layer_height=1.0)
189
+ flipped = slice_stl_to_layers(stl_path, layer_height=1.0, flip_z=True)
190
+
191
+ assert [round(layer.area) for layer in normal.layers] == [100, 4]
192
+ assert [round(layer.area) for layer in flipped.layers] == [4, 100]
193
+ # Flip about the own midplane preserves the Z range.
194
+ assert flipped.bounds[0][2] == pytest.approx(normal.bounds[0][2])
195
+ assert flipped.bounds[1][2] == pytest.approx(normal.bounds[1][2])
196
+
197
+
198
+ def test_flip_z_about_a_group_midplane_flips_the_assembly_as_one(tmp_path) -> None:
199
+ # Two assembly parts at different heights flip about the SHARED midplane:
200
+ # the part that was on top lands on the bottom of the shared Z range.
201
+ low = trimesh.creation.box(extents=(4.0, 4.0, 1.0))
202
+ low.apply_translation((2.0, 2.0, 0.5)) # z 0..1
203
+ high = trimesh.creation.box(extents=(4.0, 4.0, 1.0))
204
+ high.apply_translation((6.0, 2.0, 2.5)) # z 2..3
205
+ path_low = tmp_path / "low.stl"
206
+ path_high = tmp_path / "high.stl"
207
+ low.export(path_low)
208
+ high.export(path_high)
209
+
210
+ group_mid = 1.5 # shared z range 0..3
211
+ z_levels = [0.5, 1.5, 2.5]
212
+ stack_low = slice_stl_to_layers(path_low, 1.0, z_levels=z_levels, flip_z=True, z_flip_mid=group_mid)
213
+ stack_high = slice_stl_to_layers(path_high, 1.0, z_levels=z_levels, flip_z=True, z_flip_mid=group_mid)
214
+
215
+ # `low` (was z 0..1) now occupies z 2..3; `high` now z 0..1.
216
+ assert [layer.is_empty for layer in stack_low.layers] == [True, True, False]
217
+ assert [layer.is_empty for layer in stack_high.layers] == [False, True, True]
218
+ assert stack_high.layers[0].area == pytest.approx(16.0)
tests/test_vector_gcode.py CHANGED
@@ -1619,3 +1619,83 @@ def test_group_contour_paths_exclude_material_interfaces() -> None:
1619
  shell_layer = box(0.0, 0.0, 4.0, 4.0).difference(box(1.0, 1.0, 3.0, 3.0))
1620
  shell = _stack(shell_layer, name="shell")
1621
  assert group_contour_paths(core, [shell], tolerance=0.4) == [[]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1619
  shell_layer = box(0.0, 0.0, 4.0, 4.0).difference(box(1.0, 1.0, 3.0, 3.0))
1620
  shell = _stack(shell_layer, name="shell")
1621
  assert group_contour_paths(core, [shell], tolerance=0.4) == [[]]
1622
+
1623
+
1624
+ def test_scan_coords_keep_a_boundary_line_despite_float_noise() -> None:
1625
+ from vector_toolpath import _scan_coords
1626
+
1627
+ # A split cut can land exactly ON a grid line; the piece above the cut
1628
+ # owns that line (half-open interval), and float noise in the ratio must
1629
+ # not ceil it away. These are the real flag-split numbers.
1630
+ coords = _scan_coords(-15.2, 0.0, 0.8, anchor=-14.4)
1631
+ assert abs(coords[0] - (-15.2)) < 1e-9 # boundary line kept
1632
+ assert abs(coords[-1] - (-0.8)) < 1e-9 # cut line excluded (half-open)
1633
+
1634
+ # Same numbers arriving with adversarial float error.
1635
+ noisy_lo = 0.0 - 19 * 0.8 # -15.200000000000001
1636
+ coords2 = _scan_coords(noisy_lo, 0.0, 0.8, anchor=-14.4)
1637
+ assert abs(coords2[0] - (-15.2)) < 1e-6
1638
+
1639
+
1640
+ def test_split_seam_on_a_grid_line_reassembles_at_one_fil_pitch(tmp_path) -> None:
1641
+ # Frame y[-15, 15] with 2 rows puts the cut at y=0 — exactly on a
1642
+ # scanline of the shared grid. The seam line must be printed by exactly
1643
+ # one piece, and the reassembled seam must keep one-fil bead pitch (a
1644
+ # dropped line printed a visible one-pixel gap at every seam).
1645
+ from gcode_viewer import parse_gcode_path
1646
+
1647
+ layer = box(-25.0, -15.0, 25.0, 15.0)
1648
+ stack = _stack(layer, layer, name="flagish")
1649
+ stack = LayerStack(
1650
+ layers=stack.layers,
1651
+ z_values=stack.z_values,
1652
+ bounds=((-25.0, -15.0, 0.0), (25.0, 15.0, 2.0)),
1653
+ layer_height=1.0,
1654
+ name="flagish",
1655
+ )
1656
+ pieces = split_layer_stack_grid(stack, columns=1, rows=2, grid=0.8)
1657
+ reference = build_reference_stack(list(pieces), grid=0.8)
1658
+
1659
+ world_lines: dict[str, list[float]] = {}
1660
+ for piece in pieces:
1661
+ gcode_path = generate_vector_gcode(
1662
+ piece,
1663
+ shape_name=piece.name,
1664
+ pressure=25,
1665
+ valve=7,
1666
+ port=3,
1667
+ fil_width=0.8,
1668
+ layer_height=1.0,
1669
+ motion=reference,
1670
+ output_dir=tmp_path,
1671
+ )
1672
+ parsed = parse_gcode_path(gcode_path.read_text())
1673
+ _ox, oy = parsed["path_origin"]
1674
+ world_lines[piece.name] = sorted(
1675
+ {round(y + oy, 6) for seg in parsed["print_segments"] for _x, y, _z in seg}
1676
+ )
1677
+
1678
+ top = world_lines[pieces[0].name] # row 1 = top strip
1679
+ bottom = world_lines[pieces[1].name]
1680
+ # The cut line at y=0 belongs to the TOP piece (its material starts there).
1681
+ assert abs(top[0] - 0.0) < 1e-6
1682
+ assert abs(bottom[-1] - (-0.8)) < 1e-6
1683
+ # Seam pitch is exactly one fil; the line is printed exactly once.
1684
+ assert abs((top[0] - bottom[-1]) - 0.8) < 1e-6
1685
+ overlap = set(top) & set(bottom)
1686
+ assert not overlap
1687
+
1688
+
1689
+ def test_boundary_grid_line_grazing_from_outside_still_prints() -> None:
1690
+ from vector_toolpath import _axis_raster_segments
1691
+
1692
+ # Real flag-split floats: the grid line computes as -15.200000000000001
1693
+ # while the material's bottom edge is -15.199999999999999 — the line
1694
+ # grazes the material from OUTSIDE by two ulps. The chord probe must
1695
+ # still find the boundary sweep or the assembled seam gets a one-fil gap.
1696
+ material = MultiPolygon([box(-25.0, -15.2, 25.0, 0.0)])
1697
+ segments = _axis_raster_segments(
1698
+ material, material, 0.8, axis="X", scan_anchor=-14.4
1699
+ )
1700
+ print_ys = sorted({y0 for _x0, y0, _x1, _y1, color in segments if color == 255})
1701
+ assert abs(print_ys[0] - (-15.2)) < 1e-6 # boundary sweep printed
vector_toolpath.py CHANGED
@@ -367,8 +367,13 @@ def _scan_coords(
367
  count = int(math.floor((hi - lo) / fil_width + 1e-9))
368
  return [anchor + index * fil_width for index in range(count)]
369
 
370
- k_lo = math.ceil((lo - anchor) / fil_width - 1e-9)
371
- k_hi = math.floor((hi - anchor) / fil_width - 1e-9)
 
 
 
 
 
372
  return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)]
373
 
374
 
@@ -411,10 +416,20 @@ def _axis_raster_segments(
411
  scan_lo, scan_hi, fil_width
412
  )
413
 
 
 
 
 
 
 
 
 
 
414
  segments: list[Seg] = []
415
  sweep_number = 0
416
  for coord in coords:
417
- motion_runs = _chord_runs(motion, axis, coord)
 
418
  if not motion_runs:
419
  continue
420
 
@@ -426,7 +441,7 @@ def _axis_raster_segments(
426
  valve_runs: list[tuple[float, float]] = []
427
  else:
428
  valve_runs = []
429
- for lo, hi in _chord_runs(valve, axis, coord):
430
  lo = max(lo, sweep_lo)
431
  hi = min(hi, sweep_hi)
432
  if hi - lo > EPS:
 
367
  count = int(math.floor((hi - lo) / fil_width + 1e-9))
368
  return [anchor + index * fil_width for index in range(count)]
369
 
370
+ # The epsilon is in grid-cell units and must swamp float noise from the
371
+ # anchor/edge arithmetic: a piece whose material starts EXACTLY on a grid
372
+ # line (a split cut on the line) can compute (lo-anchor)/fil as
373
+ # -1.0000000000000009, and a 1e-9 epsilon then ceils the boundary line
374
+ # away — nobody prints it and every assembled seam gets a one-fil gap.
375
+ k_lo = math.ceil((lo - anchor) / fil_width - 1e-6)
376
+ k_hi = math.floor((hi - anchor) / fil_width - 1e-6)
377
  return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)]
378
 
379
 
 
416
  scan_lo, scan_hi, fil_width
417
  )
418
 
419
+ # A grid line can graze the material boundary from OUTSIDE by float ulps
420
+ # (split cuts sit exactly on grid lines, and the piece's material edge IS
421
+ # the cut): probe the chords a hair inside the bounds so the boundary
422
+ # sweep is still found, while emitting at the true grid coordinate.
423
+ # A dropped boundary sweep prints a one-fil gap at every assembled seam.
424
+ probe_eps = fil_width * 1e-6
425
+ probe_lo = min(scan_lo + probe_eps, (scan_lo + scan_hi) / 2.0)
426
+ probe_hi = max(scan_hi - probe_eps, (scan_lo + scan_hi) / 2.0)
427
+
428
  segments: list[Seg] = []
429
  sweep_number = 0
430
  for coord in coords:
431
+ probe = min(max(coord, probe_lo), probe_hi)
432
+ motion_runs = _chord_runs(motion, axis, probe)
433
  if not motion_runs:
434
  continue
435
 
 
441
  valve_runs: list[tuple[float, float]] = []
442
  else:
443
  valve_runs = []
444
+ for lo, hi in _chord_runs(valve, axis, probe):
445
  lo = max(lo, sweep_lo)
446
  hi = min(hi, sweep_hi)
447
  if hi - lo > EPS: