CyGuy8 Claude Fable 5 commited on
Commit
4df575e
·
1 Parent(s): 52d8525

Contour Order option: trace outlines before or after each layer's infill

Browse files

- New Contour Order dropdown in the Generate G-Code options: "After
infill" (default, unchanged behavior) or "Before infill", which traces
each layer's contours right after the Z lift and runs the raster
afterwards.
- The same order drives every shape, so shared reference motion stays in
sync across parallel heads (verified: identical path lengths and
endpoints with contours first).
- Included in the stale-G-code fingerprint and the settings export/import
round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (5) hide show
  1. README.md +1 -0
  2. app.py +29 -1
  3. tests/test_vector_gcode.py +92 -0
  4. vector_gcode.py +4 -0
  5. vector_toolpath.py +62 -22
README.md CHANGED
@@ -61,6 +61,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
61
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
62
  - Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
63
  - **Sweep Buffer (mm)** sets the valve-settle travel before and after each raster line (default 0.8, 0 disables it); it applies to the axis rasters and both woodpiles, and the same value drives every shape so the shared motion stays in sync
 
64
  - Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
65
  - The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
66
  - A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
 
61
  - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
62
  - Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
63
  - **Sweep Buffer (mm)** sets the valve-settle travel before and after each raster line (default 0.8, 0 disables it); it applies to the axis rasters and both woodpiles, and the same value drives every shape so the shared motion stays in sync
64
+ - **Contour Order** picks when each layer's contours are traced: after the layer's infill (default) or before it, right after the layer's Z lift; the same order drives every shape so the shared motion stays in sync
65
  - Slicing is automatic: **Generate G-Code** slices every shape (fresh or stale) before writing G-code, and **Split Selected Shape into Grid Pieces** slices before splitting — "upload, then Generate G-Code" works in one click with no separate slice step
66
  - The generation status leads with the shared print path length and a time estimate at the Visualization tab's nozzle speed, and generation auto-renders the parallel view so switching to the Visualization tab always shows the current print
67
  - A **stale-G-code banner** appears above the Generate button whenever the table or any generation option changes after files were generated (each file carries a settings fingerprint), so outdated G-code is never downloaded or printed unnoticed
app.py CHANGED
@@ -41,6 +41,8 @@ from stl_slicer import (
41
  )
42
  from vector_gcode import MAX_PRESSURE_PSI, generate_vector_gcode
43
  from vector_toolpath import (
 
 
44
  LEAD_IN_DIRECTION_CHOICES,
45
  LEAD_IN_DIRECTION_LEFT,
46
  LEAD_IN_LINE_AUTO,
@@ -4335,6 +4337,7 @@ def _gcode_settings_snapshot(
4335
  scale_mode: str | None,
4336
  sweep_buffer: float = 0.8,
4337
  lead_in_orientation: str | None = None,
 
4338
  ) -> dict:
4339
  """Fingerprint of every setting that shapes this record's G-code.
4340
 
@@ -4366,6 +4369,7 @@ def _gcode_settings_snapshot(
4366
  "fil_width": round(_coerce_float(fil_width, 0.8), 6),
4367
  "scale_mode": _normalize_scale_mode(scale_mode),
4368
  "sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
 
4369
  }
4370
 
4371
 
@@ -4389,6 +4393,7 @@ def check_gcode_staleness(
4389
  scale_mode: str | None,
4390
  sweep_buffer: float = 0.8,
4391
  lead_in_orientation: str | None = None,
 
4392
  ) -> str:
4393
  """Warning banner text when generated G-code no longer matches the settings."""
4394
  records = _apply_shape_settings(records or [], settings_table)
@@ -4406,6 +4411,7 @@ def check_gcode_staleness(
4406
  scale_mode,
4407
  sweep_buffer,
4408
  lead_in_orientation,
 
4409
  )
4410
  for record in records:
4411
  if not record.get("gcode_path"):
@@ -4447,6 +4453,7 @@ def export_project_settings(
4447
  lead_in_direction: str | None,
4448
  lead_in_orientation: str | None,
4449
  nozzle_speed: Any,
 
4450
  ) -> tuple[str | None, str]:
4451
  """Write the session's settings to a small JSON file, keyed by STL name.
4452
 
@@ -4483,6 +4490,7 @@ def export_project_settings(
4483
  "lead_in_direction": str(lead_in_direction or LEAD_IN_DIRECTION_LEFT),
4484
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4485
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
 
4486
  },
4487
  }
4488
  settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
@@ -4506,7 +4514,7 @@ def import_project_settings(
4506
  """
4507
 
4508
  def _skip_options() -> tuple:
4509
- return tuple(gr.skip() for _ in range(12))
4510
 
4511
  paths = _uploaded_file_paths(settings_upload)
4512
  if not paths:
@@ -4568,6 +4576,7 @@ def import_project_settings(
4568
  option("fil_width"),
4569
  option("scale_mode"),
4570
  option("nozzle_speed"),
 
4571
  )
4572
 
4573
 
@@ -4608,6 +4617,7 @@ def generate_dynamic_gcode(
4608
  nozzle_speed: Any = None,
4609
  sweep_buffer: float = 0.8,
4610
  lead_in_orientation: str | None = None,
 
4611
  progress: gr.Progress = gr.Progress(),
4612
  ) -> tuple:
4613
  records = _apply_shape_settings(records or [], settings_table)
@@ -4700,6 +4710,9 @@ def generate_dynamic_gcode(
4700
  raster_pattern=raster_pattern,
4701
  contour_sources=contour_sources,
4702
  active_contour_owner=int(record.get("idx", 0)),
 
 
 
4703
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
4704
  motion_infill_fractions=motion_infill_fractions,
4705
  emit_pressure_commands=owns_port_pressure,
@@ -4735,6 +4748,7 @@ def generate_dynamic_gcode(
4735
  scale_mode,
4736
  sweep_buffer,
4737
  lead_in_orientation,
 
4738
  )
4739
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
4740
  except Exception as exc:
@@ -5285,6 +5299,15 @@ def build_dynamic_demo() -> gr.Blocks:
5285
  min_width=170,
5286
  info="Valve-settle travel before/after each raster line (0 = none).",
5287
  )
 
 
 
 
 
 
 
 
 
5288
  with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
5289
  gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
5290
  with gr.Row():
@@ -5669,6 +5692,7 @@ def build_dynamic_demo() -> gr.Blocks:
5669
  gcode_lead_in_direction,
5670
  gcode_lead_in_orientation,
5671
  viz_nozzle_speed,
 
5672
  ],
5673
  outputs=[settings_export_file, settings_status],
5674
  queue=False,
@@ -5692,6 +5716,7 @@ def build_dynamic_demo() -> gr.Blocks:
5692
  fil_width,
5693
  scale_mode,
5694
  viz_nozzle_speed,
 
5695
  ],
5696
  )
5697
 
@@ -5712,6 +5737,7 @@ def build_dynamic_demo() -> gr.Blocks:
5712
  scale_mode,
5713
  gcode_sweep_buffer,
5714
  gcode_lead_in_orientation,
 
5715
  ]
5716
  shape_settings.change(
5717
  fn=check_gcode_staleness,
@@ -5731,6 +5757,7 @@ def build_dynamic_demo() -> gr.Blocks:
5731
  scale_mode,
5732
  gcode_sweep_buffer,
5733
  gcode_lead_in_orientation,
 
5734
  ):
5735
  stale_control.change(
5736
  fn=check_gcode_staleness,
@@ -5775,6 +5802,7 @@ def build_dynamic_demo() -> gr.Blocks:
5775
  viz_nozzle_speed,
5776
  gcode_sweep_buffer,
5777
  gcode_lead_in_orientation,
 
5778
  ],
5779
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
5780
  ).then(
 
41
  )
42
  from vector_gcode import MAX_PRESSURE_PSI, generate_vector_gcode
43
  from vector_toolpath import (
44
+ CONTOUR_ORDER_CHOICES,
45
+ CONTOUR_ORDER_LAST,
46
  LEAD_IN_DIRECTION_CHOICES,
47
  LEAD_IN_DIRECTION_LEFT,
48
  LEAD_IN_LINE_AUTO,
 
4337
  scale_mode: str | None,
4338
  sweep_buffer: float = 0.8,
4339
  lead_in_orientation: str | None = None,
4340
+ contour_order: str | None = None,
4341
  ) -> dict:
4342
  """Fingerprint of every setting that shapes this record's G-code.
4343
 
 
4369
  "fil_width": round(_coerce_float(fil_width, 0.8), 6),
4370
  "scale_mode": _normalize_scale_mode(scale_mode),
4371
  "sweep_buffer": round(_coerce_float(sweep_buffer, 0.8), 6),
4372
+ "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
4373
  }
4374
 
4375
 
 
4393
  scale_mode: str | None,
4394
  sweep_buffer: float = 0.8,
4395
  lead_in_orientation: str | None = None,
4396
+ contour_order: str | None = None,
4397
  ) -> str:
4398
  """Warning banner text when generated G-code no longer matches the settings."""
4399
  records = _apply_shape_settings(records or [], settings_table)
 
4411
  scale_mode,
4412
  sweep_buffer,
4413
  lead_in_orientation,
4414
+ contour_order,
4415
  )
4416
  for record in records:
4417
  if not record.get("gcode_path"):
 
4453
  lead_in_direction: str | None,
4454
  lead_in_orientation: str | None,
4455
  nozzle_speed: Any,
4456
+ contour_order: str | None = None,
4457
  ) -> tuple[str | None, str]:
4458
  """Write the session's settings to a small JSON file, keyed by STL name.
4459
 
 
4490
  "lead_in_direction": str(lead_in_direction or LEAD_IN_DIRECTION_LEFT),
4491
  "lead_in_orientation": str(lead_in_orientation or LEAD_IN_LINE_AUTO),
4492
  "nozzle_speed": _coerce_float(nozzle_speed, 10.0),
4493
+ "contour_order": str(contour_order or CONTOUR_ORDER_LAST),
4494
  },
4495
  }
4496
  settings_path = Path(tempfile.mkdtemp(prefix="pp_settings_")) / "parallelprint_settings.json"
 
4514
  """
4515
 
4516
  def _skip_options() -> tuple:
4517
+ return tuple(gr.skip() for _ in range(13))
4518
 
4519
  paths = _uploaded_file_paths(settings_upload)
4520
  if not paths:
 
4576
  option("fil_width"),
4577
  option("scale_mode"),
4578
  option("nozzle_speed"),
4579
+ option("contour_order"),
4580
  )
4581
 
4582
 
 
4617
  nozzle_speed: Any = None,
4618
  sweep_buffer: float = 0.8,
4619
  lead_in_orientation: str | None = None,
4620
+ contour_order: str | None = None,
4621
  progress: gr.Progress = gr.Progress(),
4622
  ) -> tuple:
4623
  records = _apply_shape_settings(records or [], settings_table)
 
4710
  raster_pattern=raster_pattern,
4711
  contour_sources=contour_sources,
4712
  active_contour_owner=int(record.get("idx", 0)),
4713
+ # Same order for every shape: contour placement is part of
4714
+ # the shared motion path.
4715
+ contour_order=contour_order,
4716
  infill=_coerce_float(record.get("infill", 100.0), 100.0) / 100.0,
4717
  motion_infill_fractions=motion_infill_fractions,
4718
  emit_pressure_commands=owns_port_pressure,
 
4748
  scale_mode,
4749
  sweep_buffer,
4750
  lead_in_orientation,
4751
+ contour_order,
4752
  )
4753
  messages.append(f"Shape {record['idx']}: wrote `{gcode_path.name}`.")
4754
  except Exception as exc:
 
5299
  min_width=170,
5300
  info="Valve-settle travel before/after each raster line (0 = none).",
5301
  )
5302
+ gcode_contour_order = gr.Dropdown(
5303
+ label="Contour Order",
5304
+ choices=list(CONTOUR_ORDER_CHOICES),
5305
+ value=CONTOUR_ORDER_LAST,
5306
+ allow_custom_value=False,
5307
+ scale=1,
5308
+ min_width=170,
5309
+ info="Trace shape outlines before or after each layer's infill.",
5310
+ )
5311
  with gr.Accordion("Lead In Options", open=False, elem_classes=["settings-accordion"]):
5312
  gr.Markdown("Applies to shapes with **Lead In** checked in the Shape Settings table.")
5313
  with gr.Row():
 
5692
  gcode_lead_in_direction,
5693
  gcode_lead_in_orientation,
5694
  viz_nozzle_speed,
5695
+ gcode_contour_order,
5696
  ],
5697
  outputs=[settings_export_file, settings_status],
5698
  queue=False,
 
5716
  fil_width,
5717
  scale_mode,
5718
  viz_nozzle_speed,
5719
+ gcode_contour_order,
5720
  ],
5721
  )
5722
 
 
5737
  scale_mode,
5738
  gcode_sweep_buffer,
5739
  gcode_lead_in_orientation,
5740
+ gcode_contour_order,
5741
  ]
5742
  shape_settings.change(
5743
  fn=check_gcode_staleness,
 
5757
  scale_mode,
5758
  gcode_sweep_buffer,
5759
  gcode_lead_in_orientation,
5760
+ gcode_contour_order,
5761
  ):
5762
  stale_control.change(
5763
  fn=check_gcode_staleness,
 
5802
  viz_nozzle_speed,
5803
  gcode_sweep_buffer,
5804
  gcode_lead_in_orientation,
5805
+ gcode_contour_order,
5806
  ],
5807
  outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
5808
  ).then(
tests/test_vector_gcode.py CHANGED
@@ -1683,6 +1683,98 @@ def test_split_contour_gcode_never_traces_the_cuts(tmp_path) -> None:
1683
  assert vertical_prints == []
1684
 
1685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1686
  def test_split_pieces_center_on_the_shared_motion() -> None:
1687
  from vector_toolpath import _centering_delta
1688
 
 
1683
  assert vertical_prints == []
1684
 
1685
 
1686
+ def test_contour_order_option_places_contours_first_or_last(tmp_path) -> None:
1687
+ from vector_toolpath import CONTOUR_ORDER_FIRST
1688
+
1689
+ layer = box(0.0, 0.0, 6.0, 4.0)
1690
+ stack = _stack(layer, layer, name="order")
1691
+ sources = [ContourSource(owner_idx=1, stack=stack)]
1692
+
1693
+ def per_layer_kinds(contour_order):
1694
+ tag = str(contour_order or "default").replace(" ", "_")
1695
+ gcode_path = generate_vector_gcode(
1696
+ stack,
1697
+ shape_name=f"order_{tag}",
1698
+ pressure=25,
1699
+ valve=4,
1700
+ port=1,
1701
+ fil_width=1.0,
1702
+ contour_sources=sources,
1703
+ active_contour_owner=1,
1704
+ contour_order=contour_order,
1705
+ output_dir=tmp_path / tag,
1706
+ )
1707
+ moves = _moves_with_colors(gcode_path.read_text())
1708
+ prints = [move for move in moves if move["color"] == 255]
1709
+ # The contour rims are the outermost printed Y lines; the X-raster
1710
+ # scanlines run strictly inside them.
1711
+ rim_lo = min(min(m["start"][1], m["end"][1]) for m in prints)
1712
+ rim_hi = max(max(m["start"][1], m["end"][1]) for m in prints)
1713
+
1714
+ def kind(move: dict) -> str:
1715
+ if abs(move["end"][1] - move["start"][1]) > 1e-9:
1716
+ return "contour" # vertical print: box side edge
1717
+ on_rim = (
1718
+ min(abs(move["start"][1] - rim_lo), abs(move["start"][1] - rim_hi))
1719
+ < 1e-6
1720
+ )
1721
+ return "contour" if on_rim else "infill"
1722
+
1723
+ layers: list[list[str]] = [[]]
1724
+ for move in moves:
1725
+ if abs(move["end"][2] - move["start"][2]) > 1e-9:
1726
+ layers.append([])
1727
+ continue
1728
+ if move["color"] != 255:
1729
+ continue
1730
+ layers[-1].append(kind(move))
1731
+ return [kinds for kinds in layers if kinds]
1732
+
1733
+ # Default: infill first, contours close the layer.
1734
+ for kinds in per_layer_kinds(None):
1735
+ assert "contour" in kinds and "infill" in kinds
1736
+ first_contour = kinds.index("contour")
1737
+ assert all(kind == "contour" for kind in kinds[first_contour:])
1738
+ assert kinds[0] == "infill"
1739
+
1740
+ # Before infill: contours open the layer, infill follows.
1741
+ for kinds in per_layer_kinds(CONTOUR_ORDER_FIRST):
1742
+ assert "contour" in kinds and "infill" in kinds
1743
+ first_infill = kinds.index("infill")
1744
+ assert all(kind == "infill" for kind in kinds[first_infill:])
1745
+ assert kinds[0] == "contour"
1746
+
1747
+ # Shared motion stays in sync with contours first: every head traces one
1748
+ # identical path.
1749
+ stack_b = _stack(layer, layer, name="order_b")
1750
+ reference = build_reference_stack([stack, stack_b], grid=1.0)
1751
+ shared_sources = [
1752
+ ContourSource(owner_idx=1, stack=stack),
1753
+ ContourSource(owner_idx=2, stack=stack_b),
1754
+ ]
1755
+ lengths = set()
1756
+ ends = set()
1757
+ for idx, shape in enumerate((stack, stack_b), start=1):
1758
+ path = generate_vector_gcode(
1759
+ shape,
1760
+ shape_name=f"sync_{idx}",
1761
+ pressure=25,
1762
+ valve=3 + idx,
1763
+ port=1,
1764
+ fil_width=1.0,
1765
+ motion=reference,
1766
+ contour_sources=shared_sources,
1767
+ active_contour_owner=idx,
1768
+ contour_order=CONTOUR_ORDER_FIRST,
1769
+ output_dir=tmp_path / f"sync_{idx}",
1770
+ )
1771
+ moves = _moves_with_colors(path.read_text())
1772
+ lengths.add(round(_total_length(moves), 4))
1773
+ ends.add(moves[-1]["end"])
1774
+ assert len(lengths) == 1
1775
+ assert len(ends) == 1
1776
+
1777
+
1778
  def test_split_pieces_center_on_the_shared_motion() -> None:
1779
  from vector_toolpath import _centering_delta
1780
 
vector_gcode.py CHANGED
@@ -193,6 +193,7 @@ def generate_vector_gcode(
193
  motion: LayerStack | None = None,
194
  contour_sources: list[ContourSource] | None = None,
195
  active_contour_owner: int | None = None,
 
196
  infill: float = 1.0,
197
  motion_infill_fractions: list[float] | None = None,
198
  emit_pressure_commands: bool = True,
@@ -331,6 +332,9 @@ def generate_vector_gcode(
331
  # Valve-settle travel before/after each raster sweep; one fil_width
332
  # when not given. Pass the SAME value for every shape sharing motion.
333
  sweep_buffer=sweep_buffer,
 
 
 
334
  )
335
 
336
  # World anchor: the toolpath origin expressed in the shape's own frame.
 
193
  motion: LayerStack | None = None,
194
  contour_sources: list[ContourSource] | None = None,
195
  active_contour_owner: int | None = None,
196
+ contour_order: str | None = None,
197
  infill: float = 1.0,
198
  motion_infill_fractions: list[float] | None = None,
199
  emit_pressure_commands: bool = True,
 
332
  # Valve-settle travel before/after each raster sweep; one fil_width
333
  # when not given. Pass the SAME value for every shape sharing motion.
334
  sweep_buffer=sweep_buffer,
335
+ # Same value for every shape sharing motion: contour placement is
336
+ # part of the shared path.
337
+ contour_order=contour_order,
338
  )
339
 
340
  # World anchor: the toolpath origin expressed in the shape's own frame.
vector_toolpath.py CHANGED
@@ -1281,6 +1281,16 @@ def _append_layer_contours(
1281
  return current_x, current_y
1282
 
1283
 
 
 
 
 
 
 
 
 
 
 
1284
  LEAD_IN_DIRECTION_LEFT = "Left"
1285
  LEAD_IN_DIRECTION_RIGHT = "Right"
1286
  LEAD_IN_DIRECTION_UP = "Up"
@@ -1440,9 +1450,14 @@ def plan_layer_moves(
1440
  ring_center: tuple[float, float] | None = None,
1441
  motion_infill_fractions: list[float] | None = None,
1442
  sweep_buffer: float | None = None,
 
1443
  ) -> tuple[list[dict], tuple[float, float]]:
1444
  """Assemble per-layer segments into a relative move list for all patterns.
1445
 
 
 
 
 
1446
  `scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
1447
  lines stack across layers and across split pieces, and provides the
1448
  rotation pivot for diagonal layers.
@@ -1481,6 +1496,7 @@ def plan_layer_moves(
1481
  origin_y = 0.0
1482
  raster_origin_initialized = False
1483
  contour_layers = contour_layers or []
 
1484
 
1485
  for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
1486
  if motion is None or motion.is_empty:
@@ -1671,6 +1687,18 @@ def plan_layer_moves(
1671
  origin_x, origin_y = first_x, first_y
1672
  raster_origin_initialized = True
1673
  elif layer_number > 0:
 
 
 
 
 
 
 
 
 
 
 
 
1674
  current_x, current_y = _append_relative_move(
1675
  gcode_list,
1676
  current_x,
@@ -1678,9 +1706,20 @@ def plan_layer_moves(
1678
  first_x,
1679
  first_y,
1680
  0,
1681
- z_step=layer_height,
1682
  )
1683
- else:
 
 
 
 
 
 
 
 
 
 
 
 
1684
  current_x, current_y = _append_relative_move(
1685
  gcode_list,
1686
  current_x,
@@ -1708,26 +1747,27 @@ def plan_layer_moves(
1708
  color,
1709
  )
1710
 
1711
- layer_end_x, layer_end_y = current_x, current_y
1712
- current_x, current_y = _append_layer_contours(
1713
- gcode_list,
1714
- current_x,
1715
- current_y,
1716
- contour_layers,
1717
- layer_number,
1718
- active_contour_owner,
1719
- origin_x,
1720
- origin_y,
1721
- shared_motion,
1722
- )
1723
- current_x, current_y = _append_relative_move(
1724
- gcode_list,
1725
- current_x,
1726
- current_y,
1727
- layer_end_x,
1728
- layer_end_y,
1729
- 0,
1730
- )
 
1731
 
1732
  return gcode_list, (origin_x, origin_y)
1733
 
 
1281
  return current_x, current_y
1282
 
1283
 
1284
+ CONTOUR_ORDER_LAST = "After infill"
1285
+ CONTOUR_ORDER_FIRST = "Before infill"
1286
+ CONTOUR_ORDER_CHOICES = (CONTOUR_ORDER_LAST, CONTOUR_ORDER_FIRST)
1287
+
1288
+
1289
+ def _contours_first(contour_order: str | None) -> bool:
1290
+ text = str(contour_order or "").strip().lower()
1291
+ return text.startswith("before") or text == "first"
1292
+
1293
+
1294
  LEAD_IN_DIRECTION_LEFT = "Left"
1295
  LEAD_IN_DIRECTION_RIGHT = "Right"
1296
  LEAD_IN_DIRECTION_UP = "Up"
 
1450
  ring_center: tuple[float, float] | None = None,
1451
  motion_infill_fractions: list[float] | None = None,
1452
  sweep_buffer: float | None = None,
1453
+ contour_order: str | None = None,
1454
  ) -> tuple[list[dict], tuple[float, float]]:
1455
  """Assemble per-layer segments into a relative move list for all patterns.
1456
 
1457
+ `contour_order` picks when each layer's contours are traced:
1458
+ "After infill" (default) appends them after the layer's raster;
1459
+ "Before infill" traces them first, right after the layer's Z lift.
1460
+
1461
  `scan_frame` (an XY box) pins the rasters' scanlines to a global grid so
1462
  lines stack across layers and across split pieces, and provides the
1463
  rotation pivot for diagonal layers.
 
1496
  origin_y = 0.0
1497
  raster_origin_initialized = False
1498
  contour_layers = contour_layers or []
1499
+ contours_first = _contours_first(contour_order)
1500
 
1501
  for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
1502
  if motion is None or motion.is_empty:
 
1687
  origin_x, origin_y = first_x, first_y
1688
  raster_origin_initialized = True
1689
  elif layer_number > 0:
1690
+ current_x, current_y = _append_relative_move(
1691
+ gcode_list,
1692
+ current_x,
1693
+ current_y,
1694
+ # Contours-first: lift Z in place — the contours run before
1695
+ # the travel to the raster start.
1696
+ current_x if contours_first else first_x,
1697
+ current_y if contours_first else first_y,
1698
+ 0,
1699
+ z_step=layer_height,
1700
+ )
1701
+ elif not contours_first:
1702
  current_x, current_y = _append_relative_move(
1703
  gcode_list,
1704
  current_x,
 
1706
  first_x,
1707
  first_y,
1708
  0,
 
1709
  )
1710
+
1711
+ if contours_first:
1712
+ current_x, current_y = _append_layer_contours(
1713
+ gcode_list,
1714
+ current_x,
1715
+ current_y,
1716
+ contour_layers,
1717
+ layer_number,
1718
+ active_contour_owner,
1719
+ origin_x,
1720
+ origin_y,
1721
+ shared_motion,
1722
+ )
1723
  current_x, current_y = _append_relative_move(
1724
  gcode_list,
1725
  current_x,
 
1747
  color,
1748
  )
1749
 
1750
+ if not contours_first:
1751
+ layer_end_x, layer_end_y = current_x, current_y
1752
+ current_x, current_y = _append_layer_contours(
1753
+ gcode_list,
1754
+ current_x,
1755
+ current_y,
1756
+ contour_layers,
1757
+ layer_number,
1758
+ active_contour_owner,
1759
+ origin_x,
1760
+ origin_y,
1761
+ shared_motion,
1762
+ )
1763
+ current_x, current_y = _append_relative_move(
1764
+ gcode_list,
1765
+ current_x,
1766
+ current_y,
1767
+ layer_end_x,
1768
+ layer_end_y,
1769
+ 0,
1770
+ )
1771
 
1772
  return gcode_list, (origin_x, origin_y)
1773