Spaces:
Sleeping
UI audit fixes: safe split defaults, spacing-table ghosts, rotation marker, split options in export
Browse files- Split Starting Nozzle / Starting Valve default to the next numbers past
everything in use, refresh on every table change, and re-bump after
each split - a default split can no longer land pieces on another
shape's nozzle (silently forming a multi-material assembly) or reuse
its valves.
- The ghost-row pass now covers the Advanced Grid Spacing table, and the
rule is stronger for both tables: when any live (button-wrapped) rows
exist, every outer-copy row is a render leftover - which also removes
stale rows whose pair/shape no longer exists (e.g. the self-paired
"Nozzle 2 -> Nozzle 2" leftover).
- Rotated shapes are marked at the START of the STL cell ("↻90° name").
Display-only: reading the table back strips the marker, and the group
summaries ignore it.
- Save/Load Settings now includes the Multi-Nozzle Split geometry
options (columns, rows, both interlocks, row-overlap direction,
overlap depth). Starting nozzle/valve stay auto-computed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +3 -3
- app.py +134 -21
- tests/test_nozzle_spacing.py +50 -0
|
@@ -51,11 +51,11 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 51 |
- Port groups are marked too: shapes sharing a Port get a matching underline on their Pressure and Port cells and a summary line ("Port 1: A + B share one pressure regulator (25 psi)") — one regulator per serial port is why their pressures stay in sync
|
| 52 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 53 |
- Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing can be checked before generating G-code)
|
| 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. **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 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
|
| 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
|
| 61 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
|
|
|
|
| 51 |
- Port groups are marked too: shapes sharing a Port get a matching underline on their Pressure and Port cells and a summary line ("Port 1: A + B share one pressure regulator (25 psi)") — one regulator per serial port is why their pressures stay in sync
|
| 52 |
- Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
|
| 53 |
- Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing can be checked before generating G-code)
|
| 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
|
| 61 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings for the circle spiral) — at 50% every other line prints. Parallel shapes with different infill still share one print path, and lines that **no** shape dispenses on are dropped from the motion entirely instead of being swept valve-off (every shape at 50% roughly halves the print path; with mixed infills a line survives if any shape prints it). The rectangular spiral keeps its full continuous walk; circle-spiral perimeter walls always stay
|
|
@@ -634,7 +634,23 @@ APP_HEAD = """
|
|
| 634 |
return isNaN(value) ? null : Math.round(value);
|
| 635 |
}
|
| 636 |
function cellName(td) {
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
}
|
| 639 |
function refresh() {
|
| 640 |
var container = document.getElementById('shape-settings-table');
|
|
@@ -685,16 +701,19 @@ APP_HEAD = """
|
|
| 685 |
}
|
| 686 |
entry.copies.push({tr: tr, tds: tds, live: fromLive});
|
| 687 |
});
|
| 688 |
-
// Ghost pass: when
|
| 689 |
-
//
|
| 690 |
-
//
|
| 691 |
-
//
|
|
|
|
| 692 |
entries.forEach(function (entry) {
|
| 693 |
entry.copies.forEach(function (copy) {
|
| 694 |
-
|
| 695 |
-
copy.tr.style.display = hide ? 'none' : '';
|
| 696 |
});
|
| 697 |
});
|
|
|
|
|
|
|
|
|
|
| 698 |
var byNozzle = {}, byValve = {}, byPort = {};
|
| 699 |
entries.forEach(function (entry) {
|
| 700 |
if (entry.nozzle !== null) (byNozzle[entry.nozzle] = byNozzle[entry.nozzle] || []).push(entry);
|
|
@@ -2196,11 +2215,61 @@ def _round_targets_to_tenths(record: dict) -> dict:
|
|
| 2196 |
return record
|
| 2197 |
|
| 2198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2199 |
def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
| 2200 |
return [
|
| 2201 |
[
|
| 2202 |
record["idx"],
|
| 2203 |
-
record
|
| 2204 |
round(_coerce_float(record.get("target_x"), DEFAULT_TARGET_EXTENTS[0]), 1),
|
| 2205 |
round(_coerce_float(record.get("target_y"), DEFAULT_TARGET_EXTENTS[1]), 1),
|
| 2206 |
round(_coerce_float(record.get("target_z"), DEFAULT_TARGET_EXTENTS[2]), 1),
|
|
@@ -2233,7 +2302,7 @@ def _apply_shape_settings(records: list[dict], settings_table: Any) -> list[dict
|
|
| 2233 |
copy = dict(record)
|
| 2234 |
row = by_idx.get(int(copy.get("idx", 0)))
|
| 2235 |
if row:
|
| 2236 |
-
copy["name"] = str(row[1] or copy["name"])
|
| 2237 |
if "original_x" not in copy or "original_y" not in copy or "original_z" not in copy:
|
| 2238 |
original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
|
| 2239 |
copy.setdefault("original_x", original_x)
|
|
@@ -4767,6 +4836,12 @@ def export_project_settings(
|
|
| 4767 |
lead_in_orientation: str | None,
|
| 4768 |
nozzle_speed: Any,
|
| 4769 |
contour_order: str | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4770 |
) -> tuple[str | None, str]:
|
| 4771 |
"""Write the session's settings to a small JSON file, keyed by STL name.
|
| 4772 |
|
|
@@ -4804,6 +4879,14 @@ def export_project_settings(
|
|
| 4804 |
"lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4805 |
"nozzle_speed": _coerce_float(nozzle_speed, 10.0),
|
| 4806 |
"contour_order": str(contour_order or CONTOUR_ORDER_LAST),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4807 |
},
|
| 4808 |
}
|
| 4809 |
settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
|
|
@@ -4827,7 +4910,7 @@ def import_project_settings(
|
|
| 4827 |
"""
|
| 4828 |
|
| 4829 |
def _skip_options() -> tuple:
|
| 4830 |
-
return tuple(gr.skip() for _ in range(
|
| 4831 |
|
| 4832 |
paths = _uploaded_file_paths(settings_upload)
|
| 4833 |
if not paths:
|
|
@@ -4890,6 +4973,12 @@ def import_project_settings(
|
|
| 4890 |
option("scale_mode"),
|
| 4891 |
option("nozzle_speed"),
|
| 4892 |
option("contour_order"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4893 |
)
|
| 4894 |
|
| 4895 |
|
|
@@ -5808,9 +5897,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5808 |
|
| 5809 |
shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
|
| 5810 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 5811 |
-
fn=
|
| 5812 |
inputs=[shape_records],
|
| 5813 |
-
outputs=[split_source],
|
| 5814 |
queue=False,
|
| 5815 |
).then(
|
| 5816 |
fn=_grid_spacing_table_update,
|
|
@@ -5819,9 +5908,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5819 |
queue=False,
|
| 5820 |
)
|
| 5821 |
sync_uploads_button.click(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 5822 |
-
fn=
|
| 5823 |
inputs=[shape_records],
|
| 5824 |
-
outputs=[split_source],
|
| 5825 |
queue=False,
|
| 5826 |
).then(
|
| 5827 |
fn=_grid_spacing_table_update,
|
|
@@ -5830,9 +5919,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5830 |
queue=False,
|
| 5831 |
)
|
| 5832 |
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(
|
| 5833 |
-
fn=
|
| 5834 |
inputs=[shape_records],
|
| 5835 |
-
outputs=[split_source],
|
| 5836 |
queue=False,
|
| 5837 |
).then(
|
| 5838 |
fn=_grid_spacing_table_update,
|
|
@@ -5858,9 +5947,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5858 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 5859 |
queue=False,
|
| 5860 |
).then(
|
| 5861 |
-
fn=
|
| 5862 |
inputs=[shape_records],
|
| 5863 |
-
outputs=[split_source],
|
| 5864 |
queue=False,
|
| 5865 |
).then(
|
| 5866 |
fn=_grid_spacing_table_update,
|
|
@@ -5916,7 +6005,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5916 |
outputs=[model_viewer, model_details],
|
| 5917 |
)
|
| 5918 |
|
| 5919 |
-
split_refresh_sources.click(fn=
|
| 5920 |
# .input (user selections only): programmatic dropdown refills after a
|
| 5921 |
# split must not overwrite the split result message.
|
| 5922 |
split_source.input(
|
|
@@ -5969,6 +6058,13 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5969 |
inputs=grid_spacing_refresh_inputs,
|
| 5970 |
outputs=[nozzle_grid_spacing_table],
|
| 5971 |
queue=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5972 |
)
|
| 5973 |
split_undo_button.click(
|
| 5974 |
fn=undo_last_split,
|
|
@@ -5998,6 +6094,11 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5998 |
inputs=grid_spacing_refresh_inputs,
|
| 5999 |
outputs=[nozzle_grid_spacing_table],
|
| 6000 |
queue=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6001 |
)
|
| 6002 |
assign_valves_button.click(
|
| 6003 |
fn=assign_unique_valves,
|
|
@@ -6023,6 +6124,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 6023 |
gcode_lead_in_orientation,
|
| 6024 |
viz_nozzle_speed,
|
| 6025 |
gcode_contour_order,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6026 |
],
|
| 6027 |
outputs=[settings_export_file, settings_status],
|
| 6028 |
queue=False,
|
|
@@ -6047,6 +6154,12 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 6047 |
scale_mode,
|
| 6048 |
viz_nozzle_speed,
|
| 6049 |
gcode_contour_order,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6050 |
],
|
| 6051 |
)
|
| 6052 |
|
|
@@ -6190,9 +6303,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 6190 |
inputs=layer_preview_inputs,
|
| 6191 |
outputs=layer_preview_outputs,
|
| 6192 |
).then(
|
| 6193 |
-
fn=
|
| 6194 |
inputs=[shape_records],
|
| 6195 |
-
outputs=[split_source],
|
| 6196 |
queue=False,
|
| 6197 |
).then(
|
| 6198 |
fn=check_gcode_staleness,
|
|
|
|
| 634 |
return isNaN(value) ? null : Math.round(value);
|
| 635 |
}
|
| 636 |
function cellName(td) {
|
| 637 |
+
// Strip the cell-menu glyph and the display-only leading rotation
|
| 638 |
+
// marker ("↻90° name").
|
| 639 |
+
return ((td && td.textContent) || '').replace(/\\u22ee/g, '').trim().replace(/^\\u21bb\\S*\\s*/, '');
|
| 640 |
+
}
|
| 641 |
+
function hideStaleCopies(containerId) {
|
| 642 |
+
// Gradio renders each dataframe TWICE (a button-wrapped live copy
|
| 643 |
+
// plus an outer copy that can hold stale leftover rows). When ANY
|
| 644 |
+
// live rows exist, every outer row is a render leftover — hide it.
|
| 645 |
+
var container = document.getElementById(containerId);
|
| 646 |
+
if (!container) return;
|
| 647 |
+
var rows = Array.prototype.slice.call(container.querySelectorAll('table tbody tr'));
|
| 648 |
+
var anyLive = rows.some(function (tr) { return tr.querySelector('td') && tr.closest('button'); });
|
| 649 |
+
rows.forEach(function (tr) {
|
| 650 |
+
if (!tr.querySelector('td')) return;
|
| 651 |
+
var live = !!tr.closest('button');
|
| 652 |
+
tr.style.display = (anyLive && !live) ? 'none' : '';
|
| 653 |
+
});
|
| 654 |
}
|
| 655 |
function refresh() {
|
| 656 |
var container = document.getElementById('shape-settings-table');
|
|
|
|
| 701 |
}
|
| 702 |
entry.copies.push({tr: tr, tds: tds, live: fromLive});
|
| 703 |
});
|
| 704 |
+
// Ghost pass: when the table has ANY live (button-wrapped) rows,
|
| 705 |
+
// every outer copy is a render leftover — including stale rows
|
| 706 |
+
// whose Shape number no longer exists. Only when NO live rows
|
| 707 |
+
// rendered at all does the outer table stand on its own.
|
| 708 |
+
var anyLive = entries.some(function (entry) { return entry.fromLive; });
|
| 709 |
entries.forEach(function (entry) {
|
| 710 |
entry.copies.forEach(function (copy) {
|
| 711 |
+
copy.tr.style.display = (anyLive && !copy.live) ? 'none' : '';
|
|
|
|
| 712 |
});
|
| 713 |
});
|
| 714 |
+
// The Advanced Grid Spacing table has the same double render (and
|
| 715 |
+
// showed stale self-paired rows); same rule, no styling needed.
|
| 716 |
+
hideStaleCopies('nozzle-grid-spacing-table');
|
| 717 |
var byNozzle = {}, byValve = {}, byPort = {};
|
| 718 |
entries.forEach(function (entry) {
|
| 719 |
if (entry.nozzle !== null) (byNozzle[entry.nozzle] = byNozzle[entry.nozzle] || []).push(entry);
|
|
|
|
| 2215 |
return record
|
| 2216 |
|
| 2217 |
|
| 2218 |
+
def _display_shape_name(record: dict) -> str:
|
| 2219 |
+
"""The STL cell text: a leading rotation marker (a single compact token,
|
| 2220 |
+
e.g. "↻90°") in front of the name, so a rotated shape is recognizable at
|
| 2221 |
+
a glance straight from the table."""
|
| 2222 |
+
name = str(record.get("name") or "")
|
| 2223 |
+
rotation = _record_rotation(record)
|
| 2224 |
+
if not any(rotation):
|
| 2225 |
+
return name
|
| 2226 |
+
if rotation[0] or rotation[1]:
|
| 2227 |
+
marker = f"↻X{rotation[0]:g}°Y{rotation[1]:g}°Z{rotation[2]:g}°"
|
| 2228 |
+
else:
|
| 2229 |
+
marker = f"↻{rotation[2]:g}°"
|
| 2230 |
+
return f"{marker} {name}"
|
| 2231 |
+
|
| 2232 |
+
|
| 2233 |
+
def _strip_rotation_marker(name: str) -> str:
|
| 2234 |
+
"""Remove the display-only leading rotation marker before storing a name."""
|
| 2235 |
+
text = name.strip()
|
| 2236 |
+
if text.startswith("↻"):
|
| 2237 |
+
parts = text.split(None, 1)
|
| 2238 |
+
if len(parts) == 2:
|
| 2239 |
+
return parts[1]
|
| 2240 |
+
return name
|
| 2241 |
+
|
| 2242 |
+
|
| 2243 |
+
def _split_numbering_defaults(records: list[dict] | None) -> tuple[dict, dict]:
|
| 2244 |
+
"""Collision-free Starting Nozzle / Starting Valve for the split panel.
|
| 2245 |
+
|
| 2246 |
+
Splitting assigns a RUN of sequential numbers from the starting values,
|
| 2247 |
+
so the defaults sit past every number in use — otherwise a default
|
| 2248 |
+
split silently lands pieces on other shapes' nozzles (accidentally
|
| 2249 |
+
forming multi-material assemblies) and reuses their valves.
|
| 2250 |
+
"""
|
| 2251 |
+
records = records or []
|
| 2252 |
+
nozzles = [
|
| 2253 |
+
_record_nozzle_number(record, int(record.get("idx", position) or position))
|
| 2254 |
+
for position, record in enumerate(records, start=1)
|
| 2255 |
+
]
|
| 2256 |
+
valves = [_coerce_int(record.get("valve"), 0) for record in records]
|
| 2257 |
+
next_nozzle = (max(nozzles) + 1) if nozzles else 1
|
| 2258 |
+
next_valve = max([valve for valve in valves if valve > 0] + [3]) + 1
|
| 2259 |
+
return gr.update(value=next_nozzle), gr.update(value=next_valve)
|
| 2260 |
+
|
| 2261 |
+
|
| 2262 |
+
def refresh_split_sources(records: list[dict] | None) -> tuple:
|
| 2263 |
+
"""Split panel refresh: source dropdown + collision-free numbering."""
|
| 2264 |
+
nozzle_update, valve_update = _split_numbering_defaults(records)
|
| 2265 |
+
return _dropdown_update(records), nozzle_update, valve_update
|
| 2266 |
+
|
| 2267 |
+
|
| 2268 |
def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
|
| 2269 |
return [
|
| 2270 |
[
|
| 2271 |
record["idx"],
|
| 2272 |
+
_display_shape_name(record),
|
| 2273 |
round(_coerce_float(record.get("target_x"), DEFAULT_TARGET_EXTENTS[0]), 1),
|
| 2274 |
round(_coerce_float(record.get("target_y"), DEFAULT_TARGET_EXTENTS[1]), 1),
|
| 2275 |
round(_coerce_float(record.get("target_z"), DEFAULT_TARGET_EXTENTS[2]), 1),
|
|
|
|
| 2302 |
copy = dict(record)
|
| 2303 |
row = by_idx.get(int(copy.get("idx", 0)))
|
| 2304 |
if row:
|
| 2305 |
+
copy["name"] = _strip_rotation_marker(str(row[1] or copy["name"]))
|
| 2306 |
if "original_x" not in copy or "original_y" not in copy or "original_z" not in copy:
|
| 2307 |
original_x, original_y, original_z = _default_target_extents_for_stl(str(copy.get("stl_path", "")))
|
| 2308 |
copy.setdefault("original_x", original_x)
|
|
|
|
| 4836 |
lead_in_orientation: str | None,
|
| 4837 |
nozzle_speed: Any,
|
| 4838 |
contour_order: str | None = None,
|
| 4839 |
+
split_columns: Any = None,
|
| 4840 |
+
split_rows: Any = None,
|
| 4841 |
+
split_overlapping_layers: Any = None,
|
| 4842 |
+
split_overlapping_rows: Any = None,
|
| 4843 |
+
split_row_overlap_direction: str | None = None,
|
| 4844 |
+
split_overlap: Any = None,
|
| 4845 |
) -> tuple[str | None, str]:
|
| 4846 |
"""Write the session's settings to a small JSON file, keyed by STL name.
|
| 4847 |
|
|
|
|
| 4879 |
"lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4880 |
"nozzle_speed": _coerce_float(nozzle_speed, 10.0),
|
| 4881 |
"contour_order": str(contour_order or CONTOUR_ORDER_LAST),
|
| 4882 |
+
"split_columns": max(1, _coerce_int(split_columns, 2)),
|
| 4883 |
+
"split_rows": max(1, _coerce_int(split_rows, 1)),
|
| 4884 |
+
"split_overlapping_layers": bool(split_overlapping_layers),
|
| 4885 |
+
"split_overlapping_rows": bool(split_overlapping_rows),
|
| 4886 |
+
"split_row_overlap_direction": str(
|
| 4887 |
+
split_row_overlap_direction or ROW_OVERLAP_AXES_CHOICES[0]
|
| 4888 |
+
),
|
| 4889 |
+
"split_overlap": _coerce_float(split_overlap, 0.8),
|
| 4890 |
},
|
| 4891 |
}
|
| 4892 |
settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
|
|
|
|
| 4910 |
"""
|
| 4911 |
|
| 4912 |
def _skip_options() -> tuple:
|
| 4913 |
+
return tuple(gr.skip() for _ in range(19))
|
| 4914 |
|
| 4915 |
paths = _uploaded_file_paths(settings_upload)
|
| 4916 |
if not paths:
|
|
|
|
| 4973 |
option("scale_mode"),
|
| 4974 |
option("nozzle_speed"),
|
| 4975 |
option("contour_order"),
|
| 4976 |
+
option("split_columns"),
|
| 4977 |
+
option("split_rows"),
|
| 4978 |
+
option("split_overlapping_layers"),
|
| 4979 |
+
option("split_overlapping_rows"),
|
| 4980 |
+
option("split_row_overlap_direction"),
|
| 4981 |
+
option("split_overlap"),
|
| 4982 |
)
|
| 4983 |
|
| 4984 |
|
|
|
|
| 5897 |
|
| 5898 |
shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
|
| 5899 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 5900 |
+
fn=refresh_split_sources,
|
| 5901 |
inputs=[shape_records],
|
| 5902 |
+
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 5903 |
queue=False,
|
| 5904 |
).then(
|
| 5905 |
fn=_grid_spacing_table_update,
|
|
|
|
| 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,
|
|
|
|
| 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],
|
| 5924 |
+
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 5925 |
queue=False,
|
| 5926 |
).then(
|
| 5927 |
fn=_grid_spacing_table_update,
|
|
|
|
| 5947 |
outputs=[stl_upload, *shape_sync_outputs, last_shape_delete_at],
|
| 5948 |
queue=False,
|
| 5949 |
).then(
|
| 5950 |
+
fn=refresh_split_sources,
|
| 5951 |
inputs=[shape_records],
|
| 5952 |
+
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 5953 |
queue=False,
|
| 5954 |
).then(
|
| 5955 |
fn=_grid_spacing_table_update,
|
|
|
|
| 6005 |
outputs=[model_viewer, model_details],
|
| 6006 |
)
|
| 6007 |
|
| 6008 |
+
split_refresh_sources.click(fn=refresh_split_sources, inputs=[shape_records], outputs=[split_source, split_start_nozzle, split_start_valve], queue=False)
|
| 6009 |
# .input (user selections only): programmatic dropdown refills after a
|
| 6010 |
# split must not overwrite the split result message.
|
| 6011 |
split_source.input(
|
|
|
|
| 6058 |
inputs=grid_spacing_refresh_inputs,
|
| 6059 |
outputs=[nozzle_grid_spacing_table],
|
| 6060 |
queue=False,
|
| 6061 |
+
).then(
|
| 6062 |
+
# Fresh pieces claimed a run of nozzles/valves: bump the split
|
| 6063 |
+
# defaults past them so a SECOND split cannot collide either.
|
| 6064 |
+
fn=lambda records: _split_numbering_defaults(records),
|
| 6065 |
+
inputs=[shape_records],
|
| 6066 |
+
outputs=[split_start_nozzle, split_start_valve],
|
| 6067 |
+
queue=False,
|
| 6068 |
)
|
| 6069 |
split_undo_button.click(
|
| 6070 |
fn=undo_last_split,
|
|
|
|
| 6094 |
inputs=grid_spacing_refresh_inputs,
|
| 6095 |
outputs=[nozzle_grid_spacing_table],
|
| 6096 |
queue=False,
|
| 6097 |
+
).then(
|
| 6098 |
+
fn=lambda records: _split_numbering_defaults(records),
|
| 6099 |
+
inputs=[shape_records],
|
| 6100 |
+
outputs=[split_start_nozzle, split_start_valve],
|
| 6101 |
+
queue=False,
|
| 6102 |
)
|
| 6103 |
assign_valves_button.click(
|
| 6104 |
fn=assign_unique_valves,
|
|
|
|
| 6124 |
gcode_lead_in_orientation,
|
| 6125 |
viz_nozzle_speed,
|
| 6126 |
gcode_contour_order,
|
| 6127 |
+
split_columns,
|
| 6128 |
+
split_rows,
|
| 6129 |
+
split_overlapping_layers,
|
| 6130 |
+
split_overlapping_rows,
|
| 6131 |
+
split_row_overlap_direction,
|
| 6132 |
+
split_overlap,
|
| 6133 |
],
|
| 6134 |
outputs=[settings_export_file, settings_status],
|
| 6135 |
queue=False,
|
|
|
|
| 6154 |
scale_mode,
|
| 6155 |
viz_nozzle_speed,
|
| 6156 |
gcode_contour_order,
|
| 6157 |
+
split_columns,
|
| 6158 |
+
split_rows,
|
| 6159 |
+
split_overlapping_layers,
|
| 6160 |
+
split_overlapping_rows,
|
| 6161 |
+
split_row_overlap_direction,
|
| 6162 |
+
split_overlap,
|
| 6163 |
],
|
| 6164 |
)
|
| 6165 |
|
|
|
|
| 6303 |
inputs=layer_preview_inputs,
|
| 6304 |
outputs=layer_preview_outputs,
|
| 6305 |
).then(
|
| 6306 |
+
fn=refresh_split_sources,
|
| 6307 |
inputs=[shape_records],
|
| 6308 |
+
outputs=[split_source, split_start_nozzle, split_start_valve],
|
| 6309 |
queue=False,
|
| 6310 |
).then(
|
| 6311 |
fn=check_gcode_staleness,
|
|
@@ -1860,6 +1860,50 @@ def test_apply_shape_rotation_updates_dims_and_reslices(tmp_path) -> None:
|
|
| 1860 |
assert (respun[0]["original_x"], respun[0]["original_y"], respun[0]["original_z"]) == (4.0, 10.0, 2.0)
|
| 1861 |
|
| 1862 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1863 |
def test_rotate_all_shapes_at_once(tmp_path) -> None:
|
| 1864 |
import trimesh
|
| 1865 |
|
|
@@ -1910,6 +1954,8 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
|
|
| 1910 |
settings_path, status = export_project_settings(
|
| 1911 |
records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
|
| 1912 |
6.0, 7.0, 4, "Down", "Horizontal", 15.0,
|
|
|
|
|
|
|
| 1913 |
)
|
| 1914 |
assert settings_path and "2 shape(s)" in status
|
| 1915 |
|
|
@@ -1930,6 +1976,10 @@ def test_project_settings_export_import_round_trip(tmp_path) -> None:
|
|
| 1930 |
assert option_updates[2]["value"] == 1.2
|
| 1931 |
assert option_updates[8]["value"] == 0.4 # layer height
|
| 1932 |
assert option_updates[11]["value"] == 15.0 # nozzle speed
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
|
| 1934 |
# A file listed in the export but not loaded is reported.
|
| 1935 |
partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
|
|
|
|
| 1860 |
assert (respun[0]["original_x"], respun[0]["original_y"], respun[0]["original_z"]) == (4.0, 10.0, 2.0)
|
| 1861 |
|
| 1862 |
|
| 1863 |
+
def test_split_numbering_defaults_avoid_collisions() -> None:
|
| 1864 |
+
from app import _split_numbering_defaults
|
| 1865 |
+
|
| 1866 |
+
# Shapes on nozzles 5/2/3 with valves 4/9: the split defaults must sit
|
| 1867 |
+
# PAST every number in use (splits assign sequential runs), otherwise a
|
| 1868 |
+
# default split lands pieces on other shapes' nozzles - silently forming
|
| 1869 |
+
# multi-material assemblies - and reuses their valves.
|
| 1870 |
+
records = [
|
| 1871 |
+
{"idx": 1, "name": "a", "nozzle": 5, "valve": 4},
|
| 1872 |
+
{"idx": 2, "name": "b", "nozzle": 2, "valve": 9},
|
| 1873 |
+
{"idx": 3, "name": "c", "nozzle": 3, "valve": 0},
|
| 1874 |
+
]
|
| 1875 |
+
nozzle_update, valve_update = _split_numbering_defaults(records)
|
| 1876 |
+
assert nozzle_update["value"] == 6
|
| 1877 |
+
assert valve_update["value"] == 10
|
| 1878 |
+
|
| 1879 |
+
# Empty table: the historical defaults.
|
| 1880 |
+
nozzle_update, valve_update = _split_numbering_defaults([])
|
| 1881 |
+
assert nozzle_update["value"] == 1
|
| 1882 |
+
assert valve_update["value"] == 4
|
| 1883 |
+
|
| 1884 |
+
|
| 1885 |
+
def test_rotation_marker_shows_in_table_and_strips_on_read() -> None:
|
| 1886 |
+
from app import _apply_shape_settings, _display_shape_name
|
| 1887 |
+
|
| 1888 |
+
record = {
|
| 1889 |
+
"idx": 1, "name": "bar", "stl_path": "bar.stl",
|
| 1890 |
+
"original_x": 10.0, "original_y": 4.0, "original_z": 2.0,
|
| 1891 |
+
"target_x": 10.0, "target_y": 4.0, "target_z": 2.0,
|
| 1892 |
+
"pressure": 25.0, "valve": 4, "nozzle": 1, "port": 1,
|
| 1893 |
+
"color": "#111111", "rotation": (0.0, 0.0, 90.0),
|
| 1894 |
+
}
|
| 1895 |
+
# The STL cell carries a visible LEADING rotation marker...
|
| 1896 |
+
assert _display_shape_name(record) == "↻90° bar"
|
| 1897 |
+
rows = _shape_settings_rows([record])
|
| 1898 |
+
assert rows[0][1] == "↻90° bar"
|
| 1899 |
+
# ...which is display-only: reading the table back stores the clean name.
|
| 1900 |
+
updated = _apply_shape_settings([record], rows)
|
| 1901 |
+
assert updated[0]["name"] == "bar"
|
| 1902 |
+
# Unrotated shapes show the plain name.
|
| 1903 |
+
record["rotation"] = 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 |
|
|
|
|
| 1954 |
settings_path, status = export_project_settings(
|
| 1955 |
records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
|
| 1956 |
6.0, 7.0, 4, "Down", "Horizontal", 15.0,
|
| 1957 |
+
split_columns=3, split_rows=2, split_overlapping_rows=True,
|
| 1958 |
+
split_overlap=1.5,
|
| 1959 |
)
|
| 1960 |
assert settings_path and "2 shape(s)" in status
|
| 1961 |
|
|
|
|
| 1976 |
assert option_updates[2]["value"] == 1.2
|
| 1977 |
assert option_updates[8]["value"] == 0.4 # layer height
|
| 1978 |
assert option_updates[11]["value"] == 15.0 # nozzle speed
|
| 1979 |
+
assert option_updates[13]["value"] == 3 # split columns
|
| 1980 |
+
assert option_updates[14]["value"] == 2 # split rows
|
| 1981 |
+
assert option_updates[16]["value"] is True # overlapping rows
|
| 1982 |
+
assert option_updates[18]["value"] == 1.5 # overlap mm
|
| 1983 |
|
| 1984 |
# A file listed in the export but not loaded is reported.
|
| 1985 |
partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
|