CyGuy8 Claude Fable 5 commited on
Commit
3f75aff
·
1 Parent(s): 4598c89

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 CHANGED
@@ -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
app.py CHANGED
@@ -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
- previous_queue = previous_by_path.get(path) or []
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
- filenames = SAMPLE_STL_SETS.get(
2617
- str(sample_set or ""), SAMPLE_STL_SETS[DEFAULT_SAMPLE_STL_SET]
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
- return (
2622
- gr.update(value=merged_paths),
2623
- *sync_uploaded_shapes(merged_paths, records, None),
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
sample_stls/Checkerboard_Cube_1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de6c3859595b3e3aa15bffcae7cc099203078881f380aecc4da9125116a55cc4
3
+ size 7884
sample_stls/Checkerboard_Cube_2.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:027adbb0e663967524e91022467786752d04f1666661647221d53f542129a094
3
+ size 8484
sample_stls/Space_Helmet_Glass.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4d7e259828d41dc0a061dd6dce139d0ce6e99160dc13a8230918eceeadd3df6
3
+ size 40384
sample_stls/Space_Helmet_Shell.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:71d5e686afc55a7c26e4fbb0c97ec5e83139ebeb541c50c9d6d4839708291991
3
+ size 47084
sample_stls/Wrapped_Egg_Inside.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:281d3a879a7b0c09069f5940a0105cab9475d47e66f1f1313cad0cd293ff9ad0
3
+ size 64884
sample_stls/Wrapped_Egg_Outside.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7c9cbfc8df66a3042dba6562498b40d33f1d3bf10cb58dd3623e83fa2360bda
3
+ size 24084
tests/test_nozzle_spacing.py CHANGED
@@ -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