Spaces:
Sleeping
Split progress, Undo Delete, slice-on-demand preview, assembly rotation note; drop two repair buttons
Browse files- Splitting reports live per-shape slicing progress (and Generate G-Code's
slicing phase now shows per-shape progress too).
- Undo Delete: every delete pushes a snapshot (settings, slices, and
G-code included; last 10) and one click restores the removed shape.
- Regenerate Preview slices the SELECTED shape on demand (plus its
nozzle-group assembly mates, which share one Z grid), so the
sliced-layer preview works before any G-code has been generated.
- Rotating an assembly member now notes that rotations turn about the
group's combined center - apply the same angle to every part to turn
the assembly as one unit.
- Removed the Sync Uploaded STLs and Assign Unique Valves buttons: the
uploader auto-syncs, and unique-valve assignment is automatic for new
shapes and split pieces (the assign_unique_valves logic stays, tested,
in case the button is ever wanted back).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +4 -2
- app.py +145 -35
- tests/test_nozzle_spacing.py +61 -4
|
@@ -54,7 +54,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 54 |
- **Rotate a shape to print in any direction**: the Selected Shape Preview accordion has a Rotate (°) input that spins the selected shape on the bed (about Z, around its center) before slicing — the configured table dimensions follow the rotation (a spun 20 × 8 shape becomes 8 × 20, keeping any custom scaling), and the 3D/layer previews show the rotated shape, and the table marks rotated shapes at the start of the STL column ("↻90° name"). **Rotate All Shapes** applies the same angle to every uploaded shape at once (each carrying its own dimensions through the turn; split pieces are skipped). Assembly parts sharing a nozzle rotate about the group's combined center, so equal rotations turn the whole assembly as one unit. The rotation exports/imports with the other settings. (The engine also supports X/Y tilts, not yet exposed in the UI)
|
| 55 |
- Bundled sample sets include a **Multi-Material Demo** (checkerboard cube, wrapped egg, space helmet — two STLs each) that loads with the parts of each model already grouped onto shared nozzles, forming three assemblies in one click
|
| 56 |
- **Save / Load Settings**: exports every table setting plus the generation options (Multi-Nozzle Split geometry options included) as a small JSON keyed by STL filename; re-upload the same STLs later (or after a Space restart) and import to restore the whole setup — files in the export that aren't loaded yet are listed so they can be added. Split pieces are derived geometry and don't round-trip
|
| 57 |
-
- Generate G-Code reports live progress (slicing, reference building, then shape-by-shape generation)
|
|
|
|
|
|
|
| 58 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape. The Starting Nozzle / Starting Valve defaults always sit past every number in use (and re-bump after each split), so a default split never lands pieces on another shape's nozzle or valve
|
| 59 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 60 |
- **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
|
|
@@ -66,7 +68,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 66 |
- Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
|
| 67 |
- The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
|
| 68 |
- A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
|
| 69 |
-
- **
|
| 70 |
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps) in the Visualization tab's Nozzle Spacing accordion, with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 71 |
- Previews selected generated G-code inline
|
| 72 |
- One Visualization tab that defaults to the parallel print of every generated shape (configured nozzle spacing, animated, with a server-side GIF export), and can switch to a single tool path from any generated shape or an uploaded G-code file
|
|
|
|
| 54 |
- **Rotate a shape to print in any direction**: the Selected Shape Preview accordion has a Rotate (°) input that spins the selected shape on the bed (about Z, around its center) before slicing — the configured table dimensions follow the rotation (a spun 20 × 8 shape becomes 8 × 20, keeping any custom scaling), and the 3D/layer previews show the rotated shape, and the table marks rotated shapes at the start of the STL column ("↻90° name"). **Rotate All Shapes** applies the same angle to every uploaded shape at once (each carrying its own dimensions through the turn; split pieces are skipped). Assembly parts sharing a nozzle rotate about the group's combined center, so equal rotations turn the whole assembly as one unit. The rotation exports/imports with the other settings. (The engine also supports X/Y tilts, not yet exposed in the UI)
|
| 55 |
- Bundled sample sets include a **Multi-Material Demo** (checkerboard cube, wrapped egg, space helmet — two STLs each) that loads with the parts of each model already grouped onto shared nozzles, forming three assemblies in one click
|
| 56 |
- **Save / Load Settings**: exports every table setting plus the generation options (Multi-Nozzle Split geometry options included) as a small JSON keyed by STL filename; re-upload the same STLs later (or after a Space restart) and import to restore the whole setup — files in the export that aren't loaded yet are listed so they can be added. Split pieces are derived geometry and don't round-trip
|
| 57 |
+
- Generate G-Code reports live progress (per-shape slicing, reference building, then shape-by-shape generation); splitting reports the same per-shape slicing progress
|
| 58 |
+
- Deleting a shape can be undone: **Undo Delete** restores the removed shape with all its settings, slices, and G-code (last 10 deletes)
|
| 59 |
+
- **Regenerate Preview** slices the selected shape on demand (plus its assembly mates), so the sliced-layer preview works before any G-code has been generated
|
| 60 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape. The Starting Nozzle / Starting Valve defaults always sit past every number in use (and re-bump after each split), so a default split never lands pieces on another shape's nozzle or valve
|
| 61 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 62 |
- **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
|
|
|
|
| 68 |
- Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
|
| 69 |
- The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
|
| 70 |
- A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
|
| 71 |
+
- **Undo Split** unwinds splits one at a time, newest first (each split pushes a snapshot onto an undo stack, kept for the last 10 splits)
|
| 72 |
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps) in the Visualization tab's Nozzle Spacing accordion, with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 73 |
- Previews selected generated G-code inline
|
| 74 |
- One Visualization tab that defaults to the parallel print of every generated shape (configured nozzle spacing, animated, with a server-side GIF export), and can switch to a single tool path from any generated shape or an uploaded G-code file
|
|
@@ -3033,13 +3033,14 @@ def _shape_select_noop() -> tuple:
|
|
| 3033 |
lands late); echoing records/rows here raced the dimension normalizer's
|
| 3034 |
write-back — the stale echo clobbered the recomputed proportions on the
|
| 3035 |
first Keep Proportions edit and re-rendered the table mid-typing."""
|
| 3036 |
-
return tuple(gr.skip() for _ in range(
|
| 3037 |
|
| 3038 |
|
| 3039 |
def delete_shape_from_settings(
|
| 3040 |
records: list[dict] | None,
|
| 3041 |
settings_table: Any | None,
|
| 3042 |
last_delete_at: float | None,
|
|
|
|
| 3043 |
evt: gr.SelectData,
|
| 3044 |
) -> tuple:
|
| 3045 |
now = time.monotonic()
|
|
@@ -3067,11 +3068,35 @@ def delete_shape_from_settings(
|
|
| 3067 |
next_records = _reindex_shape_records([
|
| 3068 |
record for record in current_records if int(record.get("idx", 0)) != delete_idx
|
| 3069 |
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3070 |
upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
|
| 3071 |
-
return
|
| 3072 |
-
|
| 3073 |
-
|
| 3074 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3075 |
)
|
| 3076 |
|
| 3077 |
|
|
@@ -3655,6 +3680,14 @@ def apply_shape_rotation(
|
|
| 3655 |
)
|
| 3656 |
else:
|
| 3657 |
status = f"Rotation cleared for {name}; dimensions follow back to {dims} mm."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3658 |
return records, _shape_settings_rows(records), status
|
| 3659 |
|
| 3660 |
|
|
@@ -3778,6 +3811,40 @@ def apply_all_shapes_z_rotation(
|
|
| 3778 |
return apply_all_shapes_rotation(records, settings_table, 0.0, 0.0, angle)
|
| 3779 |
|
| 3780 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3781 |
def apply_shape_z_rotation(
|
| 3782 |
records: list[dict] | None,
|
| 3783 |
selected: str | None,
|
|
@@ -3857,7 +3924,10 @@ def update_layer_preview(
|
|
| 3857 |
if stack is None or not getattr(stack, "layers", None):
|
| 3858 |
return (
|
| 3859 |
gr.update(maximum=1, value=1),
|
| 3860 |
-
_layer_preview_message(
|
|
|
|
|
|
|
|
|
|
| 3861 |
)
|
| 3862 |
|
| 3863 |
layer_count = len(stack.layers)
|
|
@@ -4440,6 +4510,7 @@ def split_selected_shape_for_grid(
|
|
| 4440 |
overlapping_rows: bool = False,
|
| 4441 |
overlap_size: float | None = None,
|
| 4442 |
row_overlap_direction: str | None = None,
|
|
|
|
| 4443 |
) -> tuple:
|
| 4444 |
records = _apply_shape_settings(records or [], settings_table)
|
| 4445 |
# Overlap depth for both interlock styles; blank/zero falls back to one
|
|
@@ -4453,7 +4524,17 @@ def split_selected_shape_for_grid(
|
|
| 4453 |
# separate slicing step.
|
| 4454 |
slice_messages: list[str] = []
|
| 4455 |
if records:
|
| 4456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4457 |
# Undo stack: each split PUSHES its pre-split records (sliced), and Undo
|
| 4458 |
# Split pops them one at a time (capped to the last 10 splits). Pushed
|
| 4459 |
# only when a split actually happened — every failure path returns the
|
|
@@ -4602,10 +4683,13 @@ def _ensure_records_sliced(
|
|
| 4602 |
layer_height: float,
|
| 4603 |
scale_mode: str | None,
|
| 4604 |
messages: list[str],
|
|
|
|
| 4605 |
) -> bool:
|
| 4606 |
-
"""Re-slice records whose layers are missing or stale for the current
|
|
|
|
|
|
|
| 4607 |
plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
|
| 4608 |
-
|
| 4609 |
for record in records:
|
| 4610 |
stl_path = record.get("stl_path")
|
| 4611 |
if not stl_path:
|
|
@@ -4614,6 +4698,14 @@ def _ensure_records_sliced(
|
|
| 4614 |
current = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
|
| 4615 |
if record.get("layer_stack") is not None and record.get("slice_params") == current:
|
| 4616 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4617 |
try:
|
| 4618 |
stack = _slice_record(record, layer_height, scale_mode, None, slice_plan)
|
| 4619 |
messages.append(
|
|
@@ -4622,8 +4714,7 @@ def _ensure_records_sliced(
|
|
| 4622 |
except Exception as exc:
|
| 4623 |
record["layer_stack"] = None
|
| 4624 |
messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
|
| 4625 |
-
|
| 4626 |
-
return resliced
|
| 4627 |
|
| 4628 |
|
| 4629 |
def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> float:
|
|
@@ -5025,7 +5116,15 @@ def generate_dynamic_gcode(
|
|
| 5025 |
records = _apply_shape_settings(records or [], settings_table)
|
| 5026 |
messages: list[str] = []
|
| 5027 |
progress(0.02, desc="Slicing shapes…")
|
| 5028 |
-
_ensure_records_sliced(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5029 |
# Shared reference motion is always on: every head traces the combined
|
| 5030 |
# outline and dispenses only its own geometry. Rebuilt with the CURRENT
|
| 5031 |
# fil width so the alignment snap grid matches this generation.
|
|
@@ -5491,6 +5590,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5491 |
with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
|
| 5492 |
shape_records = gr.State([])
|
| 5493 |
last_shape_delete_at = gr.State(0.0)
|
|
|
|
| 5494 |
ref_layers = gr.State(None)
|
| 5495 |
|
| 5496 |
with gr.Tab("Shapes & G-Code"):
|
|
@@ -5517,9 +5617,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5517 |
label="Sample Set",
|
| 5518 |
container=False,
|
| 5519 |
)
|
| 5520 |
-
sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
|
| 5521 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
| 5522 |
-
|
| 5523 |
scale_mode = gr.Radio(
|
| 5524 |
choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
|
| 5525 |
value=SCALE_MODE_TARGET_DIMENSIONS,
|
|
@@ -5907,17 +6006,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5907 |
outputs=[nozzle_grid_spacing_table],
|
| 5908 |
queue=False,
|
| 5909 |
)
|
| 5910 |
-
sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 5911 |
-
fn=refresh_split_sources,
|
| 5912 |
-
inputs=[shape_records],
|
| 5913 |
-
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 5914 |
-
queue=False,
|
| 5915 |
-
).then(
|
| 5916 |
-
fn=_grid_spacing_table_update,
|
| 5917 |
-
inputs=grid_spacing_refresh_inputs,
|
| 5918 |
-
outputs=[nozzle_grid_spacing_table],
|
| 5919 |
-
queue=False,
|
| 5920 |
-
)
|
| 5921 |
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, sample_set_selector], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 5922 |
fn=refresh_split_sources,
|
| 5923 |
inputs=[shape_records],
|
|
@@ -5943,8 +6031,8 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5943 |
)
|
| 5944 |
shape_settings.select(
|
| 5945 |
fn=delete_shape_from_settings,
|
| 5946 |
-
inputs=[shape_records, shape_settings, last_shape_delete_at],
|
| 5947 |
-
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 5948 |
queue=False,
|
| 5949 |
).then(
|
| 5950 |
fn=refresh_split_sources,
|
|
@@ -5957,6 +6045,21 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5957 |
outputs=[nozzle_grid_spacing_table],
|
| 5958 |
queue=False,
|
| 5959 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5960 |
|
| 5961 |
preview_inputs = [shape_records, selected_shape, shape_settings, scale_mode]
|
| 5962 |
# .change is the ONLY event the Dataframe emits for cell edits (it has
|
|
@@ -5976,14 +6079,27 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5976 |
queue=False,
|
| 5977 |
)
|
| 5978 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 5979 |
-
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 5980 |
# Sliced-layer preview. The slider uses .release only: the handler
|
| 5981 |
# writes the slider back (clamped max/value), and a .change wiring
|
| 5982 |
# would re-fire on that programmatic write.
|
| 5983 |
layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
|
| 5984 |
layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
|
| 5985 |
selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 5986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5987 |
layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 5988 |
scale_mode.change(
|
| 5989 |
fn=normalize_shape_dimensions_for_mode,
|
|
@@ -6100,12 +6216,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 6100 |
outputs=[split_start_nozzle, split_start_valve],
|
| 6101 |
queue=False,
|
| 6102 |
)
|
| 6103 |
-
assign_valves_button.click(
|
| 6104 |
-
fn=assign_unique_valves,
|
| 6105 |
-
inputs=[shape_records, shape_settings],
|
| 6106 |
-
outputs=[shape_records, shape_settings],
|
| 6107 |
-
queue=False,
|
| 6108 |
-
)
|
| 6109 |
export_settings_button.click(
|
| 6110 |
fn=export_project_settings,
|
| 6111 |
inputs=[
|
|
|
|
| 3033 |
lands late); echoing records/rows here raced the dimension normalizer's
|
| 3034 |
write-back — the stale echo clobbered the recomputed proportions on the
|
| 3035 |
first Keep Proportions edit and re-rendered the table mid-typing."""
|
| 3036 |
+
return tuple(gr.skip() for _ in range(10))
|
| 3037 |
|
| 3038 |
|
| 3039 |
def delete_shape_from_settings(
|
| 3040 |
records: list[dict] | None,
|
| 3041 |
settings_table: Any | None,
|
| 3042 |
last_delete_at: float | None,
|
| 3043 |
+
undo_stack: list[list[dict]] | None,
|
| 3044 |
evt: gr.SelectData,
|
| 3045 |
) -> tuple:
|
| 3046 |
now = time.monotonic()
|
|
|
|
| 3068 |
next_records = _reindex_shape_records([
|
| 3069 |
record for record in current_records if int(record.get("idx", 0)) != delete_idx
|
| 3070 |
])
|
| 3071 |
+
if len(next_records) == len(current_records):
|
| 3072 |
+
return _shape_select_noop()
|
| 3073 |
+
# Undo stack: every delete pushes the pre-delete records (settings,
|
| 3074 |
+
# slices, and G-code included), capped to the last 10 deletes.
|
| 3075 |
+
next_undo_stack = [
|
| 3076 |
+
*(undo_stack or []),
|
| 3077 |
+
[dict(record) for record in current_records],
|
| 3078 |
+
][-10:]
|
| 3079 |
upload_paths = [record.get("stl_path") for record in next_records if record.get("stl_path")]
|
| 3080 |
+
return (
|
| 3081 |
+
*_shape_delete_outputs(
|
| 3082 |
+
next_records,
|
| 3083 |
+
now,
|
| 3084 |
+
gr.update(value=upload_paths),
|
| 3085 |
+
),
|
| 3086 |
+
next_undo_stack,
|
| 3087 |
+
)
|
| 3088 |
+
|
| 3089 |
+
|
| 3090 |
+
def undo_last_delete(undo_stack: list[list[dict]] | None) -> tuple:
|
| 3091 |
+
"""Restore the records from before the most recent delete."""
|
| 3092 |
+
stack = list(undo_stack or [])
|
| 3093 |
+
if not stack:
|
| 3094 |
+
return _shape_select_noop()
|
| 3095 |
+
restored = [dict(record) for record in stack.pop()]
|
| 3096 |
+
upload_paths = [record.get("stl_path") for record in restored if record.get("stl_path")]
|
| 3097 |
+
return (
|
| 3098 |
+
*_shape_delete_outputs(restored, None, gr.update(value=upload_paths)),
|
| 3099 |
+
stack,
|
| 3100 |
)
|
| 3101 |
|
| 3102 |
|
|
|
|
| 3680 |
)
|
| 3681 |
else:
|
| 3682 |
status = f"Rotation cleared for {name}; dimensions follow back to {dims} mm."
|
| 3683 |
+
nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
|
| 3684 |
+
group_members = _multi_material_groups(records).get(nozzle)
|
| 3685 |
+
if group_members and len(group_members) > 1:
|
| 3686 |
+
status += (
|
| 3687 |
+
f" \n{name} is part of the nozzle {nozzle} multi-material assembly: "
|
| 3688 |
+
"rotations turn about the group's combined center, so apply the SAME "
|
| 3689 |
+
"angle to every part to turn the whole assembly as one unit."
|
| 3690 |
+
)
|
| 3691 |
return records, _shape_settings_rows(records), status
|
| 3692 |
|
| 3693 |
|
|
|
|
| 3811 |
return apply_all_shapes_rotation(records, settings_table, 0.0, 0.0, angle)
|
| 3812 |
|
| 3813 |
|
| 3814 |
+
def slice_selected_shape_for_preview(
|
| 3815 |
+
records: list[dict] | None,
|
| 3816 |
+
selected: str | None,
|
| 3817 |
+
settings_table: Any,
|
| 3818 |
+
layer_height: float,
|
| 3819 |
+
scale_mode: str | None,
|
| 3820 |
+
progress: gr.Progress = gr.Progress(),
|
| 3821 |
+
) -> list[dict]:
|
| 3822 |
+
"""Regenerate Preview slices the SELECTED shape on demand (plus its
|
| 3823 |
+
nozzle-group assembly mates, which must share one Z grid) — the layer
|
| 3824 |
+
preview works before any G-code has been generated."""
|
| 3825 |
+
records = _apply_shape_settings(records or [], settings_table)
|
| 3826 |
+
pos = _selected_record_index(records, selected)
|
| 3827 |
+
if pos < 0:
|
| 3828 |
+
return records
|
| 3829 |
+
record = records[pos]
|
| 3830 |
+
if not record.get("stl_path"):
|
| 3831 |
+
return records
|
| 3832 |
+
nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
|
| 3833 |
+
group = _multi_material_groups(records).get(nozzle) or [record]
|
| 3834 |
+
messages: list[str] = []
|
| 3835 |
+
progress(0.1, desc="Slicing for preview…")
|
| 3836 |
+
_ensure_records_sliced(
|
| 3837 |
+
group,
|
| 3838 |
+
float(layer_height or 0.8),
|
| 3839 |
+
scale_mode,
|
| 3840 |
+
messages,
|
| 3841 |
+
progress_callback=lambda done, total, name: progress(
|
| 3842 |
+
0.1 + 0.8 * done / max(1, total), desc=f"Slicing {name}…"
|
| 3843 |
+
),
|
| 3844 |
+
)
|
| 3845 |
+
return records
|
| 3846 |
+
|
| 3847 |
+
|
| 3848 |
def apply_shape_z_rotation(
|
| 3849 |
records: list[dict] | None,
|
| 3850 |
selected: str | None,
|
|
|
|
| 3924 |
if stack is None or not getattr(stack, "layers", None):
|
| 3925 |
return (
|
| 3926 |
gr.update(maximum=1, value=1),
|
| 3927 |
+
_layer_preview_message(
|
| 3928 |
+
"Press Regenerate Preview to slice this shape and see its layer "
|
| 3929 |
+
"outlines. (Generate G-Code and splits slice automatically too.)"
|
| 3930 |
+
),
|
| 3931 |
)
|
| 3932 |
|
| 3933 |
layer_count = len(stack.layers)
|
|
|
|
| 4510 |
overlapping_rows: bool = False,
|
| 4511 |
overlap_size: float | None = None,
|
| 4512 |
row_overlap_direction: str | None = None,
|
| 4513 |
+
progress: gr.Progress = gr.Progress(),
|
| 4514 |
) -> tuple:
|
| 4515 |
records = _apply_shape_settings(records or [], settings_table)
|
| 4516 |
# Overlap depth for both interlock styles; blank/zero falls back to one
|
|
|
|
| 4524 |
# separate slicing step.
|
| 4525 |
slice_messages: list[str] = []
|
| 4526 |
if records:
|
| 4527 |
+
progress(0.02, desc="Checking slices…")
|
| 4528 |
+
_ensure_records_sliced(
|
| 4529 |
+
records,
|
| 4530 |
+
float(layer_height or 0.8),
|
| 4531 |
+
scale_mode,
|
| 4532 |
+
slice_messages,
|
| 4533 |
+
progress_callback=lambda done, total, name: progress(
|
| 4534 |
+
0.05 + 0.7 * done / max(1, total), desc=f"Slicing {name}…"
|
| 4535 |
+
),
|
| 4536 |
+
)
|
| 4537 |
+
progress(0.8, desc="Splitting into grid pieces…")
|
| 4538 |
# Undo stack: each split PUSHES its pre-split records (sliced), and Undo
|
| 4539 |
# Split pops them one at a time (capped to the last 10 splits). Pushed
|
| 4540 |
# only when a split actually happened — every failure path returns the
|
|
|
|
| 4683 |
layer_height: float,
|
| 4684 |
scale_mode: str | None,
|
| 4685 |
messages: list[str],
|
| 4686 |
+
progress_callback=None,
|
| 4687 |
) -> bool:
|
| 4688 |
+
"""Re-slice records whose layers are missing or stale for the current
|
| 4689 |
+
settings. `progress_callback(done, total, name)` is called before each
|
| 4690 |
+
actual re-slice so callers can show per-shape progress."""
|
| 4691 |
plan_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
|
| 4692 |
+
stale: list[tuple[dict, tuple | None]] = []
|
| 4693 |
for record in records:
|
| 4694 |
stl_path = record.get("stl_path")
|
| 4695 |
if not stl_path:
|
|
|
|
| 4698 |
current = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
|
| 4699 |
if record.get("layer_stack") is not None and record.get("slice_params") == current:
|
| 4700 |
continue
|
| 4701 |
+
stale.append((record, slice_plan))
|
| 4702 |
+
for position, (record, slice_plan) in enumerate(stale):
|
| 4703 |
+
if progress_callback is not None:
|
| 4704 |
+
progress_callback(
|
| 4705 |
+
position,
|
| 4706 |
+
len(stale),
|
| 4707 |
+
str(record.get("name") or f"Shape {record.get('idx')}"),
|
| 4708 |
+
)
|
| 4709 |
try:
|
| 4710 |
stack = _slice_record(record, layer_height, scale_mode, None, slice_plan)
|
| 4711 |
messages.append(
|
|
|
|
| 4714 |
except Exception as exc:
|
| 4715 |
record["layer_stack"] = None
|
| 4716 |
messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
|
| 4717 |
+
return bool(stale)
|
|
|
|
| 4718 |
|
| 4719 |
|
| 4720 |
def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> float:
|
|
|
|
| 5116 |
records = _apply_shape_settings(records or [], settings_table)
|
| 5117 |
messages: list[str] = []
|
| 5118 |
progress(0.02, desc="Slicing shapes…")
|
| 5119 |
+
_ensure_records_sliced(
|
| 5120 |
+
records,
|
| 5121 |
+
layer_height,
|
| 5122 |
+
scale_mode,
|
| 5123 |
+
messages,
|
| 5124 |
+
progress_callback=lambda done, total, name: progress(
|
| 5125 |
+
0.02 + 0.09 * done / max(1, total), desc=f"Slicing {name}…"
|
| 5126 |
+
),
|
| 5127 |
+
)
|
| 5128 |
# Shared reference motion is always on: every head traces the combined
|
| 5129 |
# outline and dispenses only its own geometry. Rebuilt with the CURRENT
|
| 5130 |
# fil width so the alignment snap grid matches this generation.
|
|
|
|
| 5590 |
with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
|
| 5591 |
shape_records = gr.State([])
|
| 5592 |
last_shape_delete_at = gr.State(0.0)
|
| 5593 |
+
delete_undo = gr.State(None)
|
| 5594 |
ref_layers = gr.State(None)
|
| 5595 |
|
| 5596 |
with gr.Tab("Shapes & G-Code"):
|
|
|
|
| 5617 |
label="Sample Set",
|
| 5618 |
container=False,
|
| 5619 |
)
|
|
|
|
| 5620 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
| 5621 |
+
undo_delete_button = gr.Button("Undo Delete", variant="secondary", size="sm")
|
| 5622 |
scale_mode = gr.Radio(
|
| 5623 |
choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
|
| 5624 |
value=SCALE_MODE_TARGET_DIMENSIONS,
|
|
|
|
| 6006 |
outputs=[nozzle_grid_spacing_table],
|
| 6007 |
queue=False,
|
| 6008 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6009 |
load_samples_button.click(fn=load_sample_shapes, inputs=[stl_upload, shape_records, shape_settings, sample_set_selector], outputs=[stl_upload, *shape_sync_outputs]).then(
|
| 6010 |
fn=refresh_split_sources,
|
| 6011 |
inputs=[shape_records],
|
|
|
|
| 6031 |
)
|
| 6032 |
shape_settings.select(
|
| 6033 |
fn=delete_shape_from_settings,
|
| 6034 |
+
inputs=[shape_records, shape_settings, last_shape_delete_at, delete_undo],
|
| 6035 |
+
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at, delete_undo],
|
| 6036 |
queue=False,
|
| 6037 |
).then(
|
| 6038 |
fn=refresh_split_sources,
|
|
|
|
| 6045 |
outputs=[nozzle_grid_spacing_table],
|
| 6046 |
queue=False,
|
| 6047 |
)
|
| 6048 |
+
undo_delete_button.click(
|
| 6049 |
+
fn=undo_last_delete,
|
| 6050 |
+
inputs=[delete_undo],
|
| 6051 |
+
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at, delete_undo],
|
| 6052 |
+
).then(
|
| 6053 |
+
fn=refresh_split_sources,
|
| 6054 |
+
inputs=[shape_records],
|
| 6055 |
+
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 6056 |
+
queue=False,
|
| 6057 |
+
).then(
|
| 6058 |
+
fn=_grid_spacing_table_update,
|
| 6059 |
+
inputs=grid_spacing_refresh_inputs,
|
| 6060 |
+
outputs=[nozzle_grid_spacing_table],
|
| 6061 |
+
queue=False,
|
| 6062 |
+
)
|
| 6063 |
|
| 6064 |
preview_inputs = [shape_records, selected_shape, shape_settings, scale_mode]
|
| 6065 |
# .change is the ONLY event the Dataframe emits for cell edits (it has
|
|
|
|
| 6079 |
queue=False,
|
| 6080 |
)
|
| 6081 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
|
|
|
| 6082 |
# Sliced-layer preview. The slider uses .release only: the handler
|
| 6083 |
# writes the slider back (clamped max/value), and a .change wiring
|
| 6084 |
# would re-fire on that programmatic write.
|
| 6085 |
layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
|
| 6086 |
layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
|
| 6087 |
selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 6088 |
+
# Regenerate Preview slices the selected shape on demand first, so
|
| 6089 |
+
# the layer preview works before any G-code has been generated.
|
| 6090 |
+
refresh_preview_button.click(
|
| 6091 |
+
fn=slice_selected_shape_for_preview,
|
| 6092 |
+
inputs=[shape_records, selected_shape, shape_settings, layer_height, scale_mode],
|
| 6093 |
+
outputs=[shape_records],
|
| 6094 |
+
).then(
|
| 6095 |
+
fn=show_selected_model,
|
| 6096 |
+
inputs=preview_inputs,
|
| 6097 |
+
outputs=[model_viewer, model_details],
|
| 6098 |
+
).then(
|
| 6099 |
+
fn=update_layer_preview,
|
| 6100 |
+
inputs=layer_preview_inputs,
|
| 6101 |
+
outputs=layer_preview_outputs,
|
| 6102 |
+
)
|
| 6103 |
layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 6104 |
scale_mode.change(
|
| 6105 |
fn=normalize_shape_dimensions_for_mode,
|
|
|
|
| 6216 |
outputs=[split_start_nozzle, split_start_valve],
|
| 6217 |
queue=False,
|
| 6218 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6219 |
export_settings_button.click(
|
| 6220 |
fn=export_project_settings,
|
| 6221 |
inputs=[
|
|
@@ -975,7 +975,7 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
|
| 975 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 976 |
]
|
| 977 |
|
| 978 |
-
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 979 |
updated_records = outputs[1]
|
| 980 |
updated_settings = outputs[2]
|
| 981 |
|
|
@@ -986,6 +986,21 @@ def test_delete_shape_reindexes_without_losing_shape_data() -> None:
|
|
| 986 |
assert updated_settings[1][1] == "last"
|
| 987 |
assert updated_settings[1][2:5] == [30.0, 31.0, 32.0]
|
| 988 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
|
| 990 |
def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
| 991 |
class Event:
|
|
@@ -997,12 +1012,13 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
|
| 997 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 998 |
]
|
| 999 |
|
| 1000 |
-
first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 1001 |
assert [record["name"] for record in first_outputs[1]] == ["first", "last"]
|
| 1002 |
|
| 1003 |
second_outputs = delete_shape_from_settings(
|
| 1004 |
first_outputs[1],
|
| 1005 |
first_outputs[2],
|
|
|
|
| 1006 |
first_outputs[-1],
|
| 1007 |
Event(),
|
| 1008 |
)
|
|
@@ -1022,9 +1038,9 @@ def test_non_delete_cell_selection_touches_nothing() -> None:
|
|
| 1022 |
{"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"},
|
| 1023 |
]
|
| 1024 |
|
| 1025 |
-
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 1026 |
|
| 1027 |
-
assert len(outputs) ==
|
| 1028 |
assert all(not isinstance(value, list) for value in outputs)
|
| 1029 |
assert not isinstance(outputs[1], list) # records State untouched
|
| 1030 |
|
|
@@ -1904,6 +1920,47 @@ def test_rotation_marker_shows_in_table_and_strips_on_read() -> None:
|
|
| 1904 |
assert _display_shape_name(record) == "bar"
|
| 1905 |
|
| 1906 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1907 |
def test_rotate_all_shapes_at_once(tmp_path) -> None:
|
| 1908 |
import trimesh
|
| 1909 |
|
|
|
|
| 975 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 976 |
]
|
| 977 |
|
| 978 |
+
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
|
| 979 |
updated_records = outputs[1]
|
| 980 |
updated_settings = outputs[2]
|
| 981 |
|
|
|
|
| 986 |
assert updated_settings[1][1] == "last"
|
| 987 |
assert updated_settings[1][2:5] == [30.0, 31.0, 32.0]
|
| 988 |
|
| 989 |
+
# The delete pushed an undo snapshot; Undo Delete restores everything.
|
| 990 |
+
from app import undo_last_delete
|
| 991 |
+
|
| 992 |
+
undo_stack = outputs[-1]
|
| 993 |
+
assert isinstance(undo_stack, list) and len(undo_stack) == 1
|
| 994 |
+
restored_outputs = undo_last_delete(undo_stack)
|
| 995 |
+
restored = restored_outputs[1]
|
| 996 |
+
assert [record["name"] for record in restored] == ["first", "middle", "last"]
|
| 997 |
+
assert restored[1]["pressure"] == 30
|
| 998 |
+
assert restored_outputs[-1] == [] # stack popped
|
| 999 |
+
|
| 1000 |
+
# Undo with an empty stack touches nothing.
|
| 1001 |
+
empty = undo_last_delete([])
|
| 1002 |
+
assert not isinstance(empty[1], list)
|
| 1003 |
+
|
| 1004 |
|
| 1005 |
def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
|
| 1006 |
class Event:
|
|
|
|
| 1012 |
{"idx": 3, "name": "last", "stl_path": "last.stl", "target_x": 30.0, "target_y": 31.0, "target_z": 32.0, "pressure": 35, "valve": 6, "port": 3, "color": "#333333"},
|
| 1013 |
]
|
| 1014 |
|
| 1015 |
+
first_outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
|
| 1016 |
assert [record["name"] for record in first_outputs[1]] == ["first", "last"]
|
| 1017 |
|
| 1018 |
second_outputs = delete_shape_from_settings(
|
| 1019 |
first_outputs[1],
|
| 1020 |
first_outputs[2],
|
| 1021 |
+
first_outputs[-2], # last_delete_at (the undo stack is now last)
|
| 1022 |
first_outputs[-1],
|
| 1023 |
Event(),
|
| 1024 |
)
|
|
|
|
| 1038 |
{"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"},
|
| 1039 |
]
|
| 1040 |
|
| 1041 |
+
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, None, Event())
|
| 1042 |
|
| 1043 |
+
assert len(outputs) == 10
|
| 1044 |
assert all(not isinstance(value, list) for value in outputs)
|
| 1045 |
assert not isinstance(outputs[1], list) # records State untouched
|
| 1046 |
|
|
|
|
| 1920 |
assert _display_shape_name(record) == "bar"
|
| 1921 |
|
| 1922 |
|
| 1923 |
+
def test_regenerate_preview_slices_the_selected_shape_on_demand(tmp_path) -> None:
|
| 1924 |
+
import trimesh
|
| 1925 |
+
|
| 1926 |
+
from app import slice_selected_shape_for_preview
|
| 1927 |
+
|
| 1928 |
+
trimesh.creation.box(extents=(4.0, 4.0, 2.0)).export(tmp_path / "cube.stl")
|
| 1929 |
+
records = _records_from_files([str(tmp_path / "cube.stl")], None)
|
| 1930 |
+
assert records[0].get("layer_stack") is None
|
| 1931 |
+
|
| 1932 |
+
updated = slice_selected_shape_for_preview(records, None, None, 1.0, None)
|
| 1933 |
+
stack = updated[0].get("layer_stack")
|
| 1934 |
+
assert stack is not None and len(stack.layers) == 2
|
| 1935 |
+
|
| 1936 |
+
# Split pieces (no STL) are a no-op.
|
| 1937 |
+
piece = [{"idx": 1, "name": "piece", "stl_path": None, "split_group_id": "s"}]
|
| 1938 |
+
assert slice_selected_shape_for_preview(piece, None, None, 1.0, None) == piece
|
| 1939 |
+
|
| 1940 |
+
|
| 1941 |
+
def test_rotation_status_notes_assembly_membership(tmp_path) -> None:
|
| 1942 |
+
import trimesh
|
| 1943 |
+
|
| 1944 |
+
from app import apply_shape_z_rotation
|
| 1945 |
+
|
| 1946 |
+
for name in ("inner", "outer"):
|
| 1947 |
+
trimesh.creation.box(extents=(4.0, 4.0, 2.0)).export(tmp_path / f"{name}.stl")
|
| 1948 |
+
records = _records_from_files(
|
| 1949 |
+
[str(tmp_path / "inner.stl"), str(tmp_path / "outer.stl")], None
|
| 1950 |
+
)
|
| 1951 |
+
# Same nozzle -> a multi-material assembly.
|
| 1952 |
+
records[0]["nozzle"] = records[1]["nozzle"] = 1
|
| 1953 |
+
|
| 1954 |
+
_updated, _rows, status = apply_shape_z_rotation(records, None, None, 90)
|
| 1955 |
+
assert "multi-material assembly" in status
|
| 1956 |
+
assert "SAME angle" in status
|
| 1957 |
+
|
| 1958 |
+
# A solo shape gets no assembly note.
|
| 1959 |
+
solo = _records_from_files([str(tmp_path / "inner.stl")], None)
|
| 1960 |
+
_updated, _rows, solo_status = apply_shape_z_rotation(solo, None, None, 90)
|
| 1961 |
+
assert "assembly" not in solo_status
|
| 1962 |
+
|
| 1963 |
+
|
| 1964 |
def test_rotate_all_shapes_at_once(tmp_path) -> None:
|
| 1965 |
import trimesh
|
| 1966 |
|