Spaces:
Running
Sync multi-material assemblies in the table, add Flip Z, fix split seam gaps
Browse filesShape 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 +2 -1
- app.py +414 -44
- stl_slicer.py +40 -5
- tests/test_nozzle_spacing.py +279 -2
- tests/test_stl_slicer.py +76 -0
- tests/test_vector_gcode.py +80 -0
- vector_toolpath.py +19 -4
|
@@ -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 |
-
-
|
|
|
|
| 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 |
|
|
@@ -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
|
| 2290 |
|
| 2291 |
try:
|
| 2292 |
row_index, column_index = int(selected[0]), int(selected[1])
|
| 2293 |
except (TypeError, ValueError):
|
| 2294 |
-
return
|
| 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
|
| 2298 |
if last_delete_at and now - float(last_delete_at) < DELETE_SHAPE_COOLDOWN_SECONDS:
|
| 2299 |
-
return
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2377 |
-
|
| 2378 |
-
|
| 2379 |
-
|
| 2380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 2427 |
-
# an assembly part changes
|
| 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
|
| 2495 |
records: list[dict],
|
| 2496 |
layer_height: float,
|
| 2497 |
scale_mode: str | None,
|
| 2498 |
-
) -> list[float] | None:
|
| 2499 |
-
"""
|
| 2500 |
-
|
| 2501 |
-
|
| 2502 |
-
|
| 2503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2504 |
"""
|
| 2505 |
-
|
| 2506 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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=
|
|
|
|
|
|
|
|
|
|
| 2555 |
)
|
| 2556 |
record["layer_stack"] = stack
|
| 2557 |
-
record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode,
|
| 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 |
-
"""
|
| 2568 |
-
|
|
|
|
| 2569 |
for nozzle, members in sorted(_multi_material_groups(records).items()):
|
| 2570 |
-
|
| 2571 |
-
if
|
| 2572 |
continue
|
|
|
|
| 2573 |
for member in members:
|
| 2574 |
-
|
| 2575 |
if messages is not None:
|
| 2576 |
names = ", ".join(str(m.get("name") or f"Shape {m['idx']}") for m in members)
|
| 2577 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
| 2970 |
-
current = _slice_params_snapshot(record, layer_height, scale_mode,
|
| 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,
|
| 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],
|
|
@@ -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(
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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:
|
|
@@ -111,7 +111,7 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
|
|
| 111 |
|
| 112 |
rows = _shape_settings_rows(records)
|
| 113 |
assert SHAPE_SETTINGS_HEADERS[6:9] == ["Valve", "Nozzle", "Port"]
|
| 114 |
-
assert SHAPE_SETTINGS_HEADERS[-
|
| 115 |
contour_pos = SHAPE_SETTINGS_HEADERS.index("Contour Tracing")
|
| 116 |
lead_in_pos = SHAPE_SETTINGS_HEADERS.index("Lead In")
|
| 117 |
assert rows[0][6:9] == [4, 1, 1]
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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,
|
| 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:
|