Spaces:
Running
Settings save/load, Multi-Material Demo sample set, generation progress
Browse files- Save / Load Settings accordion: Export writes every per-shape table
setting plus all generation options as a small JSON keyed by STL
filename; Import applies them to the loaded shapes by filename
(duplicates in order), restores the generation options, and lists
exported files not yet uploaded. Split pieces are derived geometry
and do not round-trip
- Multi-Material Demo sample set: checkerboard cube, wrapped egg, and
space helmet (two STLs each, LFS-tracked); loading it groups each
model's parts onto a shared nozzle - three assemblies in one click,
every part with its own valve
- Fixed the long-standing re-sync identity bug the demo set exposed:
Gradio re-uploads files under temp-cache paths, so path-only matching
treated every re-synced shape as brand-new - wiping nozzle/valve
assignments and creating the phantom duplicate rows. Records now
match by filename when the path differs
- Generate G-Code reports live progress (slicing, reference build,
then shape-by-shape)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +3 -0
- app.py +284 -9
- sample_stls/Checkerboard_Cube_1.stl +3 -0
- sample_stls/Checkerboard_Cube_2.stl +3 -0
- sample_stls/Space_Helmet_Glass.stl +3 -0
- sample_stls/Space_Helmet_Shell.stl +3 -0
- sample_stls/Wrapped_Egg_Inside.stl +3 -0
- sample_stls/Wrapped_Egg_Outside.stl +3 -0
- tests/test_nozzle_spacing.py +65 -0
|
@@ -51,6 +51,9 @@ 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 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 55 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 56 |
- **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
|
|
|
|
| 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 |
+
- 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
|
| 55 |
+
- **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
|
| 56 |
+
- Generate G-Code reports live progress (slicing, reference building, then shape-by-shape generation)
|
| 57 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 58 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 59 |
- **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
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import math
|
| 4 |
import tempfile
|
| 5 |
import time
|
|
@@ -56,6 +57,26 @@ from vector_toolpath import (
|
|
| 56 |
SAMPLE_STL_SETS = {
|
| 57 |
"Standard Shapes": ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl"),
|
| 58 |
"Simple Shapes": ("Simple_Circle.stl", "Simple_Square.stl", "Simple_Triangle.stl"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
DEFAULT_SAMPLE_STL_SET = "Standard Shapes"
|
| 61 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
|
@@ -1974,16 +1995,33 @@ def _next_unused_valve(used_valves: set[int]) -> int:
|
|
| 1974 |
|
| 1975 |
def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
|
| 1976 |
previous_by_path: dict[str | None, list[dict]] = {}
|
|
|
|
| 1977 |
for record in previous_records or []:
|
| 1978 |
previous_by_path.setdefault(record.get("stl_path"), []).append(record)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
used_nozzles: set[int] = set()
|
| 1980 |
used_valves: set[int] = {
|
| 1981 |
_coerce_int(record.get("valve"), 0) for record in (previous_records or [])
|
| 1982 |
}
|
| 1983 |
records: list[dict] = []
|
| 1984 |
for index, path in enumerate(_uploaded_file_paths(files), start=1):
|
| 1985 |
-
|
| 1986 |
-
previous = previous_queue.pop(0) if previous_queue else {}
|
| 1987 |
name = previous.get("name") or Path(path).stem or f"Shape {index}"
|
| 1988 |
default_x, default_y, default_z = _default_target_extents_for_stl(path)
|
| 1989 |
nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
|
|
@@ -2613,15 +2651,21 @@ def load_sample_shapes(
|
|
| 2613 |
sample_set: str | None = None,
|
| 2614 |
) -> tuple:
|
| 2615 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2616 |
-
|
| 2617 |
-
|
| 2618 |
-
)
|
| 2619 |
paths = [str(SAMPLE_STL_DIR / filename) for filename in filenames if (SAMPLE_STL_DIR / filename).exists()]
|
| 2620 |
merged_paths = _append_file_paths(files, paths)
|
| 2621 |
-
|
| 2622 |
-
|
| 2623 |
-
|
| 2624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2625 |
|
| 2626 |
|
| 2627 |
def _shape_delete_outputs(
|
|
@@ -4074,6 +4118,161 @@ def check_gcode_staleness(
|
|
| 4074 |
return ""
|
| 4075 |
|
| 4076 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4077 |
def assign_unique_valves(
|
| 4078 |
records: list[dict] | None, settings_table: Any
|
| 4079 |
) -> tuple[list[dict], list[list[Any]]]:
|
|
@@ -4111,13 +4310,16 @@ def generate_dynamic_gcode(
|
|
| 4111 |
nozzle_speed: Any = None,
|
| 4112 |
sweep_buffer: float = 0.8,
|
| 4113 |
lead_in_orientation: str | None = None,
|
|
|
|
| 4114 |
) -> tuple:
|
| 4115 |
records = _apply_shape_settings(records or [], settings_table)
|
| 4116 |
messages: list[str] = []
|
|
|
|
| 4117 |
_ensure_records_sliced(records, layer_height, scale_mode, messages)
|
| 4118 |
# Shared reference motion is always on: every head traces the combined
|
| 4119 |
# outline and dispenses only its own geometry. Rebuilt with the CURRENT
|
| 4120 |
# fil width so the alignment snap grid matches this generation.
|
|
|
|
| 4121 |
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
| 4122 |
contour_sources = _contour_tracing_sources(records)
|
| 4123 |
if contour_sources:
|
|
@@ -4160,6 +4362,12 @@ def generate_dynamic_gcode(
|
|
| 4160 |
# toggle, per-layer ramp) — the print host compiles every file onto one
|
| 4161 |
# timeline and duplicated toggles would flip the regulator on/off/on.
|
| 4162 |
ports_owned: set[int] = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4163 |
for record in records:
|
| 4164 |
stack = record.get("layer_stack")
|
| 4165 |
if stack is None or not getattr(stack, "layers", None):
|
|
@@ -4169,6 +4377,11 @@ def generate_dynamic_gcode(
|
|
| 4169 |
messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
|
| 4170 |
continue
|
| 4171 |
shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4172 |
port_number = _coerce_int(record.get("port"), 1)
|
| 4173 |
owns_port_pressure = port_number not in ports_owned
|
| 4174 |
try:
|
|
@@ -4244,6 +4457,7 @@ def generate_dynamic_gcode(
|
|
| 4244 |
f"**Print path {shared_length:,.0f} mm — about {estimate} at {speed:g} mm/s** "
|
| 4245 |
"(constant speed; the Visualization tab's Nozzle Speed sets the rate).",
|
| 4246 |
)
|
|
|
|
| 4247 |
return (
|
| 4248 |
records,
|
| 4249 |
ref_layers,
|
|
@@ -4575,6 +4789,25 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4575 |
label="Scaling Mode",
|
| 4576 |
)
|
| 4577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4578 |
# Visually hidden (not visible=False: Gradio would omit them
|
| 4579 |
# from the DOM entirely and the color-select relay needs them).
|
| 4580 |
color_sink = gr.Textbox(
|
|
@@ -5068,6 +5301,48 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 5068 |
outputs=[shape_records, shape_settings],
|
| 5069 |
queue=False,
|
| 5070 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5071 |
|
| 5072 |
# Stale-G-code banner: re-checked on every table change and every
|
| 5073 |
# generation option change; generation itself re-stamps the
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
import math
|
| 5 |
import tempfile
|
| 6 |
import time
|
|
|
|
| 57 |
SAMPLE_STL_SETS = {
|
| 58 |
"Standard Shapes": ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl"),
|
| 59 |
"Simple Shapes": ("Simple_Circle.stl", "Simple_Square.stl", "Simple_Triangle.stl"),
|
| 60 |
+
"Multi-Material Demo": (
|
| 61 |
+
"Checkerboard_Cube_1.stl",
|
| 62 |
+
"Checkerboard_Cube_2.stl",
|
| 63 |
+
"Wrapped_Egg_Inside.stl",
|
| 64 |
+
"Wrapped_Egg_Outside.stl",
|
| 65 |
+
"Space_Helmet_Glass.stl",
|
| 66 |
+
"Space_Helmet_Shell.stl",
|
| 67 |
+
),
|
| 68 |
+
}
|
| 69 |
+
# Default nozzle per sample file: parts of one multi-material model share a
|
| 70 |
+
# nozzle, so loading the demo set forms the assemblies automatically.
|
| 71 |
+
SAMPLE_SET_NOZZLES = {
|
| 72 |
+
"Multi-Material Demo": {
|
| 73 |
+
"Checkerboard_Cube_1.stl": 1,
|
| 74 |
+
"Checkerboard_Cube_2.stl": 1,
|
| 75 |
+
"Wrapped_Egg_Inside.stl": 2,
|
| 76 |
+
"Wrapped_Egg_Outside.stl": 2,
|
| 77 |
+
"Space_Helmet_Glass.stl": 3,
|
| 78 |
+
"Space_Helmet_Shell.stl": 3,
|
| 79 |
+
},
|
| 80 |
}
|
| 81 |
DEFAULT_SAMPLE_STL_SET = "Standard Shapes"
|
| 82 |
SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
|
|
|
|
| 1995 |
|
| 1996 |
def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
|
| 1997 |
previous_by_path: dict[str | None, list[dict]] = {}
|
| 1998 |
+
previous_by_name: dict[str, list[dict]] = {}
|
| 1999 |
for record in previous_records or []:
|
| 2000 |
previous_by_path.setdefault(record.get("stl_path"), []).append(record)
|
| 2001 |
+
if record.get("stl_path"):
|
| 2002 |
+
previous_by_name.setdefault(Path(str(record["stl_path"])).name, []).append(record)
|
| 2003 |
+
matched_ids: set[int] = set()
|
| 2004 |
+
|
| 2005 |
+
def _take_previous(path: str) -> dict:
|
| 2006 |
+
"""Match by exact path first, then by FILENAME: Gradio copies
|
| 2007 |
+
uploads into its temp cache, so the same file re-arrives under a new
|
| 2008 |
+
path — without the name fallback every re-sync treated all shapes
|
| 2009 |
+
as brand-new, wiping nozzles/valves (and duplicating rows)."""
|
| 2010 |
+
for queue in (previous_by_path.get(path), previous_by_name.get(Path(path).name)):
|
| 2011 |
+
while queue:
|
| 2012 |
+
candidate = queue.pop(0)
|
| 2013 |
+
if id(candidate) not in matched_ids:
|
| 2014 |
+
matched_ids.add(id(candidate))
|
| 2015 |
+
return candidate
|
| 2016 |
+
return {}
|
| 2017 |
+
|
| 2018 |
used_nozzles: set[int] = set()
|
| 2019 |
used_valves: set[int] = {
|
| 2020 |
_coerce_int(record.get("valve"), 0) for record in (previous_records or [])
|
| 2021 |
}
|
| 2022 |
records: list[dict] = []
|
| 2023 |
for index, path in enumerate(_uploaded_file_paths(files), start=1):
|
| 2024 |
+
previous = _take_previous(str(path))
|
|
|
|
| 2025 |
name = previous.get("name") or Path(path).stem or f"Shape {index}"
|
| 2026 |
default_x, default_y, default_z = _default_target_extents_for_stl(path)
|
| 2027 |
nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
|
|
|
|
| 2651 |
sample_set: str | None = None,
|
| 2652 |
) -> tuple:
|
| 2653 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2654 |
+
set_name = str(sample_set or "")
|
| 2655 |
+
filenames = SAMPLE_STL_SETS.get(set_name, SAMPLE_STL_SETS[DEFAULT_SAMPLE_STL_SET])
|
|
|
|
| 2656 |
paths = [str(SAMPLE_STL_DIR / filename) for filename in filenames if (SAMPLE_STL_DIR / filename).exists()]
|
| 2657 |
merged_paths = _append_file_paths(files, paths)
|
| 2658 |
+
outputs = sync_uploaded_shapes(merged_paths, records, None)
|
| 2659 |
+
nozzle_map = SAMPLE_SET_NOZZLES.get(set_name, {})
|
| 2660 |
+
if nozzle_map:
|
| 2661 |
+
# Group the set's multi-material parts onto their shared nozzles.
|
| 2662 |
+
next_records = outputs[0]
|
| 2663 |
+
for record in next_records:
|
| 2664 |
+
filename = Path(str(record.get("stl_path") or "")).name
|
| 2665 |
+
if filename in nozzle_map:
|
| 2666 |
+
record["nozzle"] = nozzle_map[filename]
|
| 2667 |
+
outputs = (next_records, _shape_settings_rows(next_records), *outputs[2:])
|
| 2668 |
+
return (gr.update(value=merged_paths), *outputs)
|
| 2669 |
|
| 2670 |
|
| 2671 |
def _shape_delete_outputs(
|
|
|
|
| 4118 |
return ""
|
| 4119 |
|
| 4120 |
|
| 4121 |
+
_SHAPE_EXPORT_FIELDS = (
|
| 4122 |
+
"name",
|
| 4123 |
+
"target_x",
|
| 4124 |
+
"target_y",
|
| 4125 |
+
"target_z",
|
| 4126 |
+
"pressure",
|
| 4127 |
+
"valve",
|
| 4128 |
+
"nozzle",
|
| 4129 |
+
"port",
|
| 4130 |
+
"color",
|
| 4131 |
+
"infill",
|
| 4132 |
+
"contour_tracing",
|
| 4133 |
+
"lead_in",
|
| 4134 |
+
)
|
| 4135 |
+
|
| 4136 |
+
|
| 4137 |
+
def export_project_settings(
|
| 4138 |
+
records: list[dict] | None,
|
| 4139 |
+
settings_table: Any,
|
| 4140 |
+
layer_height: float,
|
| 4141 |
+
fil_width: float,
|
| 4142 |
+
scale_mode: str | None,
|
| 4143 |
+
raster_pattern: str | None,
|
| 4144 |
+
pressure_ramp_enabled: bool,
|
| 4145 |
+
sweep_buffer: float,
|
| 4146 |
+
lead_in_length: float,
|
| 4147 |
+
lead_in_clearance: float,
|
| 4148 |
+
lead_in_lines: float,
|
| 4149 |
+
lead_in_direction: str | None,
|
| 4150 |
+
lead_in_orientation: str | None,
|
| 4151 |
+
nozzle_speed: Any,
|
| 4152 |
+
) -> tuple[str | None, str]:
|
| 4153 |
+
"""Write the session's settings to a small JSON file, keyed by STL name.
|
| 4154 |
+
|
| 4155 |
+
Sessions are otherwise lost on a page refresh or Space restart: the
|
| 4156 |
+
export carries every per-shape table setting plus the generation
|
| 4157 |
+
options; re-upload the same STLs and import to restore them. Split
|
| 4158 |
+
pieces are derived geometry and cannot round-trip.
|
| 4159 |
+
"""
|
| 4160 |
+
records = _apply_shape_settings(records or [], settings_table)
|
| 4161 |
+
shapes = []
|
| 4162 |
+
for record in records:
|
| 4163 |
+
if not record.get("stl_path"):
|
| 4164 |
+
continue
|
| 4165 |
+
entry = {"file": Path(str(record["stl_path"])).name}
|
| 4166 |
+
for field in _SHAPE_EXPORT_FIELDS:
|
| 4167 |
+
entry[field] = record.get(field)
|
| 4168 |
+
shapes.append(entry)
|
| 4169 |
+
if not shapes:
|
| 4170 |
+
return None, "Nothing to export: load at least one STL first."
|
| 4171 |
+
payload = {
|
| 4172 |
+
"app": "ParallelPrint",
|
| 4173 |
+
"version": 1,
|
| 4174 |
+
"shapes": shapes,
|
| 4175 |
+
"generation": {
|
| 4176 |
+
"layer_height": _coerce_float(layer_height, 0.8),
|
| 4177 |
+
"fil_width": _coerce_float(fil_width, 0.8),
|
| 4178 |
+
"scale_mode": _normalize_scale_mode(scale_mode),
|
| 4179 |
+
"raster_pattern": str(raster_pattern or ""),
|
| 4180 |
+
"pressure_ramp_enabled": bool(pressure_ramp_enabled),
|
| 4181 |
+
"sweep_buffer": _coerce_float(sweep_buffer, 0.8),
|
| 4182 |
+
"lead_in_length": _coerce_float(lead_in_length, 5.0),
|
| 4183 |
+
"lead_in_clearance": _coerce_float(lead_in_clearance, 5.0),
|
| 4184 |
+
"lead_in_lines": _coerce_int(lead_in_lines, 3),
|
| 4185 |
+
"lead_in_direction": str(lead_in_direction or LEAD_IN_DIRECTION_LEFT),
|
| 4186 |
+
"lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
|
| 4187 |
+
"nozzle_speed": _coerce_float(nozzle_speed, 10.0),
|
| 4188 |
+
},
|
| 4189 |
+
}
|
| 4190 |
+
settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
|
| 4191 |
+
settings_path.write_text(json.dumps(payload, indent=2))
|
| 4192 |
+
status = f"Exported settings for {len(shapes)} shape(s) and the generation options."
|
| 4193 |
+
if any(not record.get("stl_path") for record in records):
|
| 4194 |
+
status += " Split pieces are not exported — re-split after importing."
|
| 4195 |
+
return str(settings_path), status
|
| 4196 |
+
|
| 4197 |
+
|
| 4198 |
+
def import_project_settings(
|
| 4199 |
+
settings_upload: Any,
|
| 4200 |
+
records: list[dict] | None,
|
| 4201 |
+
settings_table: Any,
|
| 4202 |
+
) -> tuple:
|
| 4203 |
+
"""Apply an exported settings file to the loaded shapes and options.
|
| 4204 |
+
|
| 4205 |
+
Shape settings match by STL filename (duplicates apply in order); files
|
| 4206 |
+
in the export that are not currently loaded are reported so they can be
|
| 4207 |
+
uploaded and re-imported. Generation options apply regardless.
|
| 4208 |
+
"""
|
| 4209 |
+
|
| 4210 |
+
def _skip_options() -> tuple:
|
| 4211 |
+
return tuple(gr.skip() for _ in range(12))
|
| 4212 |
+
|
| 4213 |
+
paths = _uploaded_file_paths(settings_upload)
|
| 4214 |
+
if not paths:
|
| 4215 |
+
return gr.skip(), gr.skip(), "", *_skip_options()
|
| 4216 |
+
try:
|
| 4217 |
+
payload = json.loads(Path(paths[0]).read_text())
|
| 4218 |
+
if not isinstance(payload, dict) or "shapes" not in payload:
|
| 4219 |
+
raise ValueError("not a ParallelPrint settings file")
|
| 4220 |
+
except (OSError, ValueError) as exc:
|
| 4221 |
+
return gr.skip(), gr.skip(), f"Import failed: {exc}", *_skip_options()
|
| 4222 |
+
|
| 4223 |
+
records = _apply_shape_settings(records or [], settings_table)
|
| 4224 |
+
queues: dict[str, list[dict]] = {}
|
| 4225 |
+
for entry in payload.get("shapes", []):
|
| 4226 |
+
if isinstance(entry, dict) and entry.get("file"):
|
| 4227 |
+
queues.setdefault(str(entry["file"]), []).append(entry)
|
| 4228 |
+
|
| 4229 |
+
updated: list[dict] = []
|
| 4230 |
+
applied = 0
|
| 4231 |
+
for record in records:
|
| 4232 |
+
copy = dict(record)
|
| 4233 |
+
filename = Path(str(copy.get("stl_path") or "")).name
|
| 4234 |
+
queue = queues.get(filename)
|
| 4235 |
+
if queue:
|
| 4236 |
+
entry = queue.pop(0)
|
| 4237 |
+
for field in _SHAPE_EXPORT_FIELDS:
|
| 4238 |
+
if field in entry and entry[field] is not None:
|
| 4239 |
+
copy[field] = entry[field]
|
| 4240 |
+
_round_targets_to_tenths(copy)
|
| 4241 |
+
applied += 1
|
| 4242 |
+
updated.append(copy)
|
| 4243 |
+
missing = sorted({name for name, queue in queues.items() if queue})
|
| 4244 |
+
|
| 4245 |
+
generation = payload.get("generation", {}) if isinstance(payload.get("generation"), dict) else {}
|
| 4246 |
+
|
| 4247 |
+
def option(key: str):
|
| 4248 |
+
return gr.update(value=generation[key]) if key in generation else gr.skip()
|
| 4249 |
+
|
| 4250 |
+
messages = [f"Imported settings for {applied} shape(s)."]
|
| 4251 |
+
if generation:
|
| 4252 |
+
messages.append("Generation options restored.")
|
| 4253 |
+
if missing:
|
| 4254 |
+
messages.append(
|
| 4255 |
+
"Not currently loaded (upload them, then import again): " + ", ".join(f"`{name}`" for name in missing) + "."
|
| 4256 |
+
)
|
| 4257 |
+
return (
|
| 4258 |
+
updated,
|
| 4259 |
+
_shape_settings_rows(updated),
|
| 4260 |
+
" \n".join(messages),
|
| 4261 |
+
option("raster_pattern"),
|
| 4262 |
+
option("pressure_ramp_enabled"),
|
| 4263 |
+
option("sweep_buffer"),
|
| 4264 |
+
option("lead_in_length"),
|
| 4265 |
+
option("lead_in_clearance"),
|
| 4266 |
+
option("lead_in_lines"),
|
| 4267 |
+
option("lead_in_direction"),
|
| 4268 |
+
option("lead_in_orientation"),
|
| 4269 |
+
option("layer_height"),
|
| 4270 |
+
option("fil_width"),
|
| 4271 |
+
option("scale_mode"),
|
| 4272 |
+
option("nozzle_speed"),
|
| 4273 |
+
)
|
| 4274 |
+
|
| 4275 |
+
|
| 4276 |
def assign_unique_valves(
|
| 4277 |
records: list[dict] | None, settings_table: Any
|
| 4278 |
) -> tuple[list[dict], list[list[Any]]]:
|
|
|
|
| 4310 |
nozzle_speed: Any = None,
|
| 4311 |
sweep_buffer: float = 0.8,
|
| 4312 |
lead_in_orientation: str | None = None,
|
| 4313 |
+
progress: gr.Progress = gr.Progress(),
|
| 4314 |
) -> tuple:
|
| 4315 |
records = _apply_shape_settings(records or [], settings_table)
|
| 4316 |
messages: list[str] = []
|
| 4317 |
+
progress(0.02, desc="Slicing shapes…")
|
| 4318 |
_ensure_records_sliced(records, layer_height, scale_mode, messages)
|
| 4319 |
# Shared reference motion is always on: every head traces the combined
|
| 4320 |
# outline and dispenses only its own geometry. Rebuilt with the CURRENT
|
| 4321 |
# fil width so the alignment snap grid matches this generation.
|
| 4322 |
+
progress(0.12, desc="Building the shared reference outline…")
|
| 4323 |
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
| 4324 |
contour_sources = _contour_tracing_sources(records)
|
| 4325 |
if contour_sources:
|
|
|
|
| 4362 |
# toggle, per-layer ramp) — the print host compiles every file onto one
|
| 4363 |
# timeline and duplicated toggles would flip the regulator on/off/on.
|
| 4364 |
ports_owned: set[int] = set()
|
| 4365 |
+
generatable = sum(
|
| 4366 |
+
1
|
| 4367 |
+
for record in records
|
| 4368 |
+
if record.get("layer_stack") is not None and getattr(record["layer_stack"], "layers", None)
|
| 4369 |
+
)
|
| 4370 |
+
generated_count = 0
|
| 4371 |
for record in records:
|
| 4372 |
stack = record.get("layer_stack")
|
| 4373 |
if stack is None or not getattr(stack, "layers", None):
|
|
|
|
| 4377 |
messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
|
| 4378 |
continue
|
| 4379 |
shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
|
| 4380 |
+
generated_count += 1
|
| 4381 |
+
progress(
|
| 4382 |
+
0.15 + 0.8 * (generated_count - 1) / max(1, generatable),
|
| 4383 |
+
desc=f"Generating {shape_name} ({generated_count}/{generatable})…",
|
| 4384 |
+
)
|
| 4385 |
port_number = _coerce_int(record.get("port"), 1)
|
| 4386 |
owns_port_pressure = port_number not in ports_owned
|
| 4387 |
try:
|
|
|
|
| 4457 |
f"**Print path {shared_length:,.0f} mm — about {estimate} at {speed:g} mm/s** "
|
| 4458 |
"(constant speed; the Visualization tab's Nozzle Speed sets the rate).",
|
| 4459 |
)
|
| 4460 |
+
progress(1.0, desc="Done")
|
| 4461 |
return (
|
| 4462 |
records,
|
| 4463 |
ref_layers,
|
|
|
|
| 4789 |
label="Scaling Mode",
|
| 4790 |
)
|
| 4791 |
|
| 4792 |
+
with gr.Accordion("Save / Load Settings", open=False, elem_classes=["settings-accordion"]):
|
| 4793 |
+
gr.Markdown(
|
| 4794 |
+
"Sessions are lost on a page refresh, so settings travel as a small JSON file "
|
| 4795 |
+
"keyed by STL filename: **Export** downloads every table setting plus the "
|
| 4796 |
+
"generation options; re-upload the same STLs later and **Import** restores them."
|
| 4797 |
+
)
|
| 4798 |
+
with gr.Row():
|
| 4799 |
+
with gr.Column(scale=1, min_width=220):
|
| 4800 |
+
export_settings_button = gr.Button("Export Settings", variant="secondary")
|
| 4801 |
+
settings_export_file = gr.File(label="Settings File", interactive=False, height=110)
|
| 4802 |
+
with gr.Column(scale=1, min_width=220):
|
| 4803 |
+
settings_import_upload = gr.File(
|
| 4804 |
+
label="Import Settings (.json)",
|
| 4805 |
+
file_types=[".json"],
|
| 4806 |
+
interactive=True,
|
| 4807 |
+
height=110,
|
| 4808 |
+
)
|
| 4809 |
+
settings_status = gr.Markdown("")
|
| 4810 |
+
|
| 4811 |
# Visually hidden (not visible=False: Gradio would omit them
|
| 4812 |
# from the DOM entirely and the color-select relay needs them).
|
| 4813 |
color_sink = gr.Textbox(
|
|
|
|
| 5301 |
outputs=[shape_records, shape_settings],
|
| 5302 |
queue=False,
|
| 5303 |
)
|
| 5304 |
+
export_settings_button.click(
|
| 5305 |
+
fn=export_project_settings,
|
| 5306 |
+
inputs=[
|
| 5307 |
+
shape_records,
|
| 5308 |
+
shape_settings,
|
| 5309 |
+
layer_height,
|
| 5310 |
+
fil_width,
|
| 5311 |
+
scale_mode,
|
| 5312 |
+
gcode_raster_pattern,
|
| 5313 |
+
gcode_pressure_ramp_enabled,
|
| 5314 |
+
gcode_sweep_buffer,
|
| 5315 |
+
gcode_lead_in_length,
|
| 5316 |
+
gcode_lead_in_clearance,
|
| 5317 |
+
gcode_lead_in_lines,
|
| 5318 |
+
gcode_lead_in_direction,
|
| 5319 |
+
gcode_lead_in_orientation,
|
| 5320 |
+
viz_nozzle_speed,
|
| 5321 |
+
],
|
| 5322 |
+
outputs=[settings_export_file, settings_status],
|
| 5323 |
+
queue=False,
|
| 5324 |
+
)
|
| 5325 |
+
settings_import_upload.change(
|
| 5326 |
+
fn=import_project_settings,
|
| 5327 |
+
inputs=[settings_import_upload, shape_records, shape_settings],
|
| 5328 |
+
outputs=[
|
| 5329 |
+
shape_records,
|
| 5330 |
+
shape_settings,
|
| 5331 |
+
settings_status,
|
| 5332 |
+
gcode_raster_pattern,
|
| 5333 |
+
gcode_pressure_ramp_enabled,
|
| 5334 |
+
gcode_sweep_buffer,
|
| 5335 |
+
gcode_lead_in_length,
|
| 5336 |
+
gcode_lead_in_clearance,
|
| 5337 |
+
gcode_lead_in_lines,
|
| 5338 |
+
gcode_lead_in_direction,
|
| 5339 |
+
gcode_lead_in_orientation,
|
| 5340 |
+
layer_height,
|
| 5341 |
+
fil_width,
|
| 5342 |
+
scale_mode,
|
| 5343 |
+
viz_nozzle_speed,
|
| 5344 |
+
],
|
| 5345 |
+
)
|
| 5346 |
|
| 5347 |
# Stale-G-code banner: re-checked on every table change and every
|
| 5348 |
# generation option change; generation itself re-stamps the
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:de6c3859595b3e3aa15bffcae7cc099203078881f380aecc4da9125116a55cc4
|
| 3 |
+
size 7884
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:027adbb0e663967524e91022467786752d04f1666661647221d53f542129a094
|
| 3 |
+
size 8484
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b4d7e259828d41dc0a061dd6dce139d0ce6e99160dc13a8230918eceeadd3df6
|
| 3 |
+
size 40384
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:71d5e686afc55a7c26e4fbb0c97ec5e83139ebeb541c50c9d6d4839708291991
|
| 3 |
+
size 47084
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:281d3a879a7b0c09069f5940a0105cab9475d47e66f1f1313cad0cd293ff9ad0
|
| 3 |
+
size 64884
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e7c9cbfc8df66a3042dba6562498b40d33f1d3bf10cb58dd3623e83fa2360bda
|
| 3 |
+
size 24084
|
|
@@ -1471,6 +1471,71 @@ def test_layer_preview_draws_the_selection_and_its_nozzle_group() -> None:
|
|
| 1471 |
assert not fig_hint.axes[0].patches
|
| 1472 |
|
| 1473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
def test_load_sample_shapes_respects_the_selected_set() -> None:
|
| 1475 |
from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
|
| 1476 |
|
|
|
|
| 1471 |
assert not fig_hint.axes[0].patches
|
| 1472 |
|
| 1473 |
|
| 1474 |
+
def test_multi_material_demo_set_groups_parts_onto_shared_nozzles() -> None:
|
| 1475 |
+
from app import load_sample_shapes
|
| 1476 |
+
|
| 1477 |
+
outputs = load_sample_shapes(None, [], None, "Multi-Material Demo")
|
| 1478 |
+
records = outputs[1]
|
| 1479 |
+
assert [record["name"] for record in records] == [
|
| 1480 |
+
"Checkerboard_Cube_1",
|
| 1481 |
+
"Checkerboard_Cube_2",
|
| 1482 |
+
"Wrapped_Egg_Inside",
|
| 1483 |
+
"Wrapped_Egg_Outside",
|
| 1484 |
+
"Space_Helmet_Glass",
|
| 1485 |
+
"Space_Helmet_Shell",
|
| 1486 |
+
]
|
| 1487 |
+
# Parts of the same model share a nozzle (three assemblies)...
|
| 1488 |
+
assert [record["nozzle"] for record in records] == [1, 1, 2, 2, 3, 3]
|
| 1489 |
+
# ...while every part keeps its own valve.
|
| 1490 |
+
valves = [record["valve"] for record in records]
|
| 1491 |
+
assert len(set(valves)) == 6
|
| 1492 |
+
# The table rows carry the grouped nozzles too.
|
| 1493 |
+
nozzle_pos = SHAPE_SETTINGS_HEADERS.index("Nozzle")
|
| 1494 |
+
assert [row[nozzle_pos] for row in outputs[2]] == [1, 1, 2, 2, 3, 3]
|
| 1495 |
+
|
| 1496 |
+
|
| 1497 |
+
def test_project_settings_export_import_round_trip(tmp_path) -> None:
|
| 1498 |
+
from app import export_project_settings, import_project_settings
|
| 1499 |
+
|
| 1500 |
+
records = _records_from_files(["egg_inside.stl", "egg_outside.stl"], None)
|
| 1501 |
+
records[0].update(nozzle=2, valve=9, pressure=40.0, infill=50.0, contour_tracing=True, color="#d62728")
|
| 1502 |
+
records[1].update(nozzle=2, valve=10, pressure=40.0)
|
| 1503 |
+
|
| 1504 |
+
settings_path, status = export_project_settings(
|
| 1505 |
+
records, None, 0.4, 0.4, None, "Circle Spiral raster", False, 1.2,
|
| 1506 |
+
6.0, 7.0, 4, "Down", "Horizontal", 15.0,
|
| 1507 |
+
)
|
| 1508 |
+
assert settings_path and "2 shape(s)" in status
|
| 1509 |
+
|
| 1510 |
+
# A fresh session re-uploads the same files, then imports.
|
| 1511 |
+
fresh = _records_from_files(["egg_inside.stl", "egg_outside.stl", "extra.stl"], None)
|
| 1512 |
+
outputs = import_project_settings([settings_path], fresh, None)
|
| 1513 |
+
updated, rows, message = outputs[0], outputs[1], outputs[2]
|
| 1514 |
+
assert "2 shape(s)" in message
|
| 1515 |
+
assert updated[0]["nozzle"] == 2 and updated[0]["valve"] == 9
|
| 1516 |
+
assert updated[0]["pressure"] == 40.0 and updated[0]["infill"] == 50.0
|
| 1517 |
+
assert updated[0]["contour_tracing"] is True and updated[0]["color"] == "#d62728"
|
| 1518 |
+
assert updated[1]["nozzle"] == 2 and updated[1]["valve"] == 10
|
| 1519 |
+
assert updated[2]["name"] == "extra" # untouched shape keeps defaults
|
| 1520 |
+
# Generation options come back as component updates.
|
| 1521 |
+
option_updates = outputs[3:]
|
| 1522 |
+
assert option_updates[0]["value"] == "Circle Spiral raster"
|
| 1523 |
+
assert option_updates[1]["value"] is False
|
| 1524 |
+
assert option_updates[2]["value"] == 1.2
|
| 1525 |
+
assert option_updates[8]["value"] == 0.4 # layer height
|
| 1526 |
+
assert option_updates[11]["value"] == 15.0 # nozzle speed
|
| 1527 |
+
|
| 1528 |
+
# A file listed in the export but not loaded is reported.
|
| 1529 |
+
partial = import_project_settings([settings_path], _records_from_files(["egg_inside.stl"], None), None)
|
| 1530 |
+
assert "egg_outside.stl" in partial[2]
|
| 1531 |
+
|
| 1532 |
+
# Garbage input fails gracefully.
|
| 1533 |
+
bad = tmp_path / "bad.json"
|
| 1534 |
+
bad.write_text("not json")
|
| 1535 |
+
failed = import_project_settings([str(bad)], fresh, None)
|
| 1536 |
+
assert "Import failed" in failed[2]
|
| 1537 |
+
|
| 1538 |
+
|
| 1539 |
def test_load_sample_shapes_respects_the_selected_set() -> None:
|
| 1540 |
from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
|
| 1541 |
|