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

Shape rotation: spin a shape on the bed to print it in any direction

Browse files

- Rotate (°) input + Apply Rotation in the Selected Shape Preview
accordion: spins the selected shape about Z (around its center) before
slicing. Dimensions reset to the rotated bounding box; the 3D and
sliced-layer previews show the rotated shape; entering 0 clears it.
- The engine (rotate_mesh + slice_stl_to_layers rotation params) supports
full X/Y/Z rotations - the UI exposes only the bed spin for now, via a
thin wrapper, so tilts are a small follow-up when needed.
- Rotation is part of the slice fingerprint (auto re-slice on the next
generation) and the stale-G-code banner, and rides along in settings
export/import (with the rotated originals).
- Multi-material assembly parts rotate about the group's combined center,
so equal rotations turn the whole assembly as one rigid unit.
- Split pieces are derived geometry: rotating them is refused with a hint
to rotate the source shape and re-split.

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

Files changed (5) hide show
  1. README.md +1 -0
  2. app.py +208 -10
  3. stl_slicer.py +40 -1
  4. tests/test_nozzle_spacing.py +57 -0
  5. tests/test_stl_slicer.py +43 -0
README.md CHANGED
@@ -51,6 +51,7 @@ 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
  - 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)
 
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 table dimensions reset to the rotated bounding box, and the 3D/layer previews show the rotated shape. 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)
app.py CHANGED
@@ -35,6 +35,7 @@ from stl_slicer import (
35
  LayerStack,
36
  calculate_z_levels,
37
  load_mesh,
 
38
  scale_factors_for_target_extents,
39
  scale_mesh,
40
  slice_stl_to_layers,
@@ -1986,6 +1987,29 @@ def _default_color(index: int) -> str:
1986
  return DEFAULT_PARALLEL_COLORS[(index - 1) % len(DEFAULT_PARALLEL_COLORS)]
1987
 
1988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1989
  def _default_target_extents_for_stl(path: str) -> tuple[float, float, float]:
1990
  try:
1991
  extents = load_mesh(path).extents
@@ -2104,6 +2128,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
2104
  "infill": previous.get("infill", 100.0),
2105
  "contour_tracing": previous.get("contour_tracing", False),
2106
  "lead_in": previous.get("lead_in", False),
 
2107
  "layer_stack": previous.get("layer_stack"),
2108
  "slice_params": previous.get("slice_params"),
2109
  "gcode_path": previous.get("gcode_path"),
@@ -3450,8 +3475,14 @@ def show_selected_model(
3450
  if pos < 0:
3451
  return _viewer_update(None), "No model loaded."
3452
  record = records[pos]
 
 
 
 
 
 
3453
  return load_single_model(
3454
- record.get("stl_path"),
3455
  False, # full opacity (the 75%-opacity option was removed)
3456
  True,
3457
  scale_mode,
@@ -3461,6 +3492,97 @@ def show_selected_model(
3461
  )
3462
 
3463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3464
  def _polygon_patch(polygon, **kwargs):
3465
  """A filled matplotlib patch for a shapely Polygon, holes included."""
3466
  from matplotlib.patches import PathPatch
@@ -3589,7 +3711,7 @@ def _slice_params_snapshot(
3589
  record: dict,
3590
  layer_height: float,
3591
  scale_mode: str | None,
3592
- slice_plan: tuple[list[float], tuple[float, float, float]] | None = None,
3593
  ) -> dict:
3594
  z_levels = slice_plan[0] if slice_plan else None
3595
  anchor = slice_plan[1] if slice_plan else None
@@ -3599,6 +3721,7 @@ def _slice_params_snapshot(
3599
  "target_x": record.get("target_x"),
3600
  "target_y": record.get("target_y"),
3601
  "target_z": record.get("target_z"),
 
3602
  # Multi-material groups: the shared Z grid + scale anchor
3603
  # fingerprint. Adding/removing an assembly part changes them, which
3604
  # correctly marks every part's slices stale.
@@ -3672,23 +3795,46 @@ def _multi_material_slice_plan(
3672
  records: list[dict],
3673
  layer_height: float,
3674
  scale_mode: str | None,
3675
- ) -> tuple[list[float], tuple[float, float, float]] | None:
3676
- """(shared Z grid, shared scale anchor) for one multi-material group.
 
3677
 
3678
  Group members must slice on the SAME planes so a part that starts
3679
  higher gets empty lower layers instead of having its first material
3680
  layer treated as layer 0 — and any target-dimension scaling must happen
3681
  about ONE shared point (the group's combined un-scaled corner), or
3682
  same-factor scaling would still shift the parts relative to each other.
 
 
3683
  """
3684
- loaded: list[tuple[Any, tuple[float, float, float]]] = []
3685
- corner = [math.inf, math.inf, math.inf]
 
3686
  for record in records:
3687
  stl_path = record.get("stl_path")
3688
  if not stl_path:
3689
  continue
3690
  try:
3691
  mesh = load_mesh(stl_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3692
  scale_factors = _resolve_mesh_scale_factors(
3693
  mesh,
3694
  True,
@@ -3717,7 +3863,7 @@ def _multi_material_slice_plan(
3717
  z_hi = max(z_hi, float(scaled.bounds[1][2]))
3718
  if not math.isfinite(z_lo) or not math.isfinite(z_hi):
3719
  return None
3720
- return calculate_z_levels(z_lo, z_hi, float(layer_height)), anchor
3721
 
3722
 
3723
  def _slice_record(
@@ -3725,10 +3871,16 @@ def _slice_record(
3725
  layer_height: float,
3726
  scale_mode: str | None,
3727
  progress_callback=None,
3728
- slice_plan: tuple[list[float], tuple[float, float, float]] | None = None,
3729
  ) -> LayerStack:
3730
  stl_path = record["stl_path"]
 
 
3731
  mesh = load_mesh(stl_path)
 
 
 
 
3732
  scale_factors = _resolve_mesh_scale_factors(
3733
  mesh,
3734
  True,
@@ -3745,6 +3897,8 @@ def _slice_record(
3745
  name=str(record.get("name") or Path(stl_path).stem),
3746
  z_levels=slice_plan[0] if slice_plan else None,
3747
  scale_anchor=slice_plan[1] if slice_plan else None,
 
 
3748
  )
3749
  record["layer_stack"] = stack
3750
  record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
@@ -3756,10 +3910,10 @@ def _group_z_levels_by_record(
3756
  layer_height: float,
3757
  scale_mode: str | None,
3758
  messages: list[str] | None = None,
3759
- ) -> dict[int, tuple[list[float], tuple[float, float, float]]]:
3760
  """Per multi-material group member: (shared Z grid, shared scale anchor),
3761
  keyed by record id."""
3762
- plan_by_record: dict[int, tuple[list[float], tuple[float, float, float]]] = {}
3763
  for nozzle, members in sorted(_multi_material_groups(records).items()):
3764
  plan = _multi_material_slice_plan(members, layer_height, scale_mode)
3765
  if plan is None:
@@ -4356,6 +4510,7 @@ def _gcode_settings_snapshot(
4356
  "infill": round(_coerce_float(record.get("infill"), 100.0), 6),
4357
  "contour_tracing": bool(record.get("contour_tracing")),
4358
  "lead_in": bool(record.get("lead_in")),
 
4359
  "raster_pattern": str(raster_pattern or ""),
4360
  "pressure_ramp": bool(pressure_ramp_enabled),
4361
  "lead_in_params": (
@@ -4427,6 +4582,10 @@ _SHAPE_EXPORT_FIELDS = (
4427
  "target_x",
4428
  "target_y",
4429
  "target_z",
 
 
 
 
4430
  "pressure",
4431
  "valve",
4432
  "nozzle",
@@ -5251,6 +5410,19 @@ def build_dynamic_demo() -> gr.Blocks:
5251
  selected_shape = gr.Dropdown(label="Preview Shape", choices=[], value=None, allow_custom_value=False)
5252
  refresh_preview_button = gr.Button("Regenerate Preview", variant="secondary", size="sm")
5253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5254
  with gr.Row():
5255
  with gr.Column(scale=2, min_width=420):
5256
  model_viewer = gr.Model3D(
@@ -5766,6 +5938,32 @@ def build_dynamic_demo() -> gr.Blocks:
5766
  queue=False,
5767
  )
5768
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5769
  # Defined before the generate chain so it can auto-render the
5770
  # parallel view with fresh files (the same lists drive the
5771
  # Visualization tab wiring further down).
 
35
  LayerStack,
36
  calculate_z_levels,
37
  load_mesh,
38
+ rotate_mesh,
39
  scale_factors_for_target_extents,
40
  scale_mesh,
41
  slice_stl_to_layers,
 
1987
  return DEFAULT_PARALLEL_COLORS[(index - 1) % len(DEFAULT_PARALLEL_COLORS)]
1988
 
1989
 
1990
+ def _record_rotation(record: dict) -> tuple[float, float, float]:
1991
+ """The shape's print rotation (X, Y, Z degrees); (0, 0, 0) when unset."""
1992
+ rotation = record.get("rotation")
1993
+ if not isinstance(rotation, (list, tuple)):
1994
+ return (0.0, 0.0, 0.0)
1995
+ values = [_coerce_float(value, 0.0) for value in list(rotation)[:3]]
1996
+ while len(values) < 3:
1997
+ values.append(0.0)
1998
+ return tuple(round(value, 1) for value in values)
1999
+
2000
+
2001
+ def _rotated_display_stl(stl_path: str, rotation: tuple[float, float, float]) -> str:
2002
+ """A temp STL of the rotated mesh, so the 3D preview shows the shape the
2003
+ way it will actually print."""
2004
+ try:
2005
+ mesh = rotate_mesh(load_mesh(stl_path), rotation)
2006
+ out_path = Path(tempfile.mkdtemp(prefix="pp_rotated_")) / Path(stl_path).name
2007
+ mesh.export(out_path)
2008
+ return str(out_path)
2009
+ except Exception:
2010
+ return stl_path
2011
+
2012
+
2013
  def _default_target_extents_for_stl(path: str) -> tuple[float, float, float]:
2014
  try:
2015
  extents = load_mesh(path).extents
 
2128
  "infill": previous.get("infill", 100.0),
2129
  "contour_tracing": previous.get("contour_tracing", False),
2130
  "lead_in": previous.get("lead_in", False),
2131
+ "rotation": previous.get("rotation"),
2132
  "layer_stack": previous.get("layer_stack"),
2133
  "slice_params": previous.get("slice_params"),
2134
  "gcode_path": previous.get("gcode_path"),
 
3475
  if pos < 0:
3476
  return _viewer_update(None), "No model loaded."
3477
  record = records[pos]
3478
+ stl_path = record.get("stl_path")
3479
+ rotation = _record_rotation(record)
3480
+ if stl_path and any(rotation):
3481
+ # Preview the shape the way it will print: rotated first, so the
3482
+ # target dimensions apply to the rotated bounding box.
3483
+ stl_path = _rotated_display_stl(str(stl_path), rotation)
3484
  return load_single_model(
3485
+ stl_path,
3486
  False, # full opacity (the 75%-opacity option was removed)
3487
  True,
3488
  scale_mode,
 
3492
  )
3493
 
3494
 
3495
+ def apply_shape_rotation(
3496
+ records: list[dict] | None,
3497
+ selected: str | None,
3498
+ settings_table: Any,
3499
+ rotate_x: Any,
3500
+ rotate_y: Any,
3501
+ rotate_z: Any,
3502
+ ) -> tuple:
3503
+ """Store a print rotation on the selected shape.
3504
+
3505
+ The rotation turns the raw mesh (X, then Y, then Z, about its centre)
3506
+ before slicing, so the shape can print lying in any orientation. The
3507
+ dimensions reset to the rotated shape's natural bounding box — edit
3508
+ them afterwards as usual; slicing picks the rotation up automatically
3509
+ on the next Generate G-Code (or split).
3510
+ """
3511
+ records = _apply_shape_settings(records or [], settings_table)
3512
+ pos = _selected_record_index(records, selected)
3513
+ if pos < 0:
3514
+ return records, _shape_settings_rows(records), "Load a shape before rotating it."
3515
+ record = records[pos]
3516
+ name = str(record.get("name") or f"Shape {record.get('idx', pos + 1)}")
3517
+ if not record.get("stl_path"):
3518
+ return (
3519
+ records,
3520
+ _shape_settings_rows(records),
3521
+ f"{name} is a split piece and cannot be rotated — rotate the source "
3522
+ "shape, then split again.",
3523
+ )
3524
+
3525
+ rotation = tuple(
3526
+ round(_coerce_float(value, 0.0) % 360.0, 1)
3527
+ for value in (rotate_x, rotate_y, rotate_z)
3528
+ )
3529
+ try:
3530
+ extents = tuple(
3531
+ float(value)
3532
+ for value in rotate_mesh(load_mesh(record["stl_path"]), rotation).extents
3533
+ )
3534
+ except Exception as exc:
3535
+ return records, _shape_settings_rows(records), f"Rotation failed: {exc}"
3536
+
3537
+ record["rotation"] = rotation if any(rotation) else None
3538
+ for axis, extent in zip(("x", "y", "z"), extents):
3539
+ record[f"original_{axis}"] = round(extent, 1)
3540
+ record[f"target_{axis}"] = round(extent, 1)
3541
+
3542
+ dims = " x ".join(f"{round(extent, 1):g}" for extent in extents)
3543
+ if any(rotation):
3544
+ if rotation[0] or rotation[1]:
3545
+ angle_text = f"X {rotation[0]:g}°, Y {rotation[1]:g}°, Z {rotation[2]:g}°"
3546
+ else:
3547
+ angle_text = f"{rotation[2]:g}°"
3548
+ status = (
3549
+ f"Rotated {name} to {angle_text}. "
3550
+ f"Dimensions reset to the rotated size ({dims} mm) — edit them as needed; "
3551
+ "the next Generate G-Code (or split) slices the rotated shape."
3552
+ )
3553
+ else:
3554
+ status = f"Rotation cleared for {name}; dimensions reset to {dims} mm."
3555
+ return records, _shape_settings_rows(records), status
3556
+
3557
+
3558
+ def apply_shape_z_rotation(
3559
+ records: list[dict] | None,
3560
+ selected: str | None,
3561
+ settings_table: Any,
3562
+ angle: Any,
3563
+ ) -> tuple:
3564
+ """UI entry point: the single Rotate input spins the shape on the bed
3565
+ (about Z). The engine supports X/Y tilts too — expose more inputs here
3566
+ when they are needed."""
3567
+ return apply_shape_rotation(records, selected, settings_table, 0.0, 0.0, angle)
3568
+
3569
+
3570
+ def selected_shape_rotation(
3571
+ records: list[dict] | None, selected: str | None
3572
+ ) -> tuple[float, float, float]:
3573
+ """The stored rotation of the selected shape, for the rotation inputs."""
3574
+ records = records or []
3575
+ pos = _selected_record_index(records, selected)
3576
+ if pos < 0:
3577
+ return 0.0, 0.0, 0.0
3578
+ return _record_rotation(records[pos])
3579
+
3580
+
3581
+ def selected_shape_z_rotation(records: list[dict] | None, selected: str | None) -> float:
3582
+ """The stored bed rotation (Z) of the selected shape, for the Rotate input."""
3583
+ return selected_shape_rotation(records, selected)[2]
3584
+
3585
+
3586
  def _polygon_patch(polygon, **kwargs):
3587
  """A filled matplotlib patch for a shapely Polygon, holes included."""
3588
  from matplotlib.patches import PathPatch
 
3711
  record: dict,
3712
  layer_height: float,
3713
  scale_mode: str | None,
3714
+ slice_plan: tuple | None = None,
3715
  ) -> dict:
3716
  z_levels = slice_plan[0] if slice_plan else None
3717
  anchor = slice_plan[1] if slice_plan else None
 
3721
  "target_x": record.get("target_x"),
3722
  "target_y": record.get("target_y"),
3723
  "target_z": record.get("target_z"),
3724
+ "rotation": _record_rotation(record),
3725
  # Multi-material groups: the shared Z grid + scale anchor
3726
  # fingerprint. Adding/removing an assembly part changes them, which
3727
  # correctly marks every part's slices stale.
 
3795
  records: list[dict],
3796
  layer_height: float,
3797
  scale_mode: str | None,
3798
+ ) -> tuple[list[float], tuple[float, float, float], tuple[float, float, float] | None] | None:
3799
+ """(shared Z grid, shared scale anchor, shared rotation centre) for one
3800
+ multi-material group.
3801
 
3802
  Group members must slice on the SAME planes so a part that starts
3803
  higher gets empty lower layers instead of having its first material
3804
  layer treated as layer 0 — and any target-dimension scaling must happen
3805
  about ONE shared point (the group's combined un-scaled corner), or
3806
  same-factor scaling would still shift the parts relative to each other.
3807
+ Rotations likewise happen about the group's combined RAW centre, so
3808
+ equal rotations turn the whole assembly as one rigid unit.
3809
  """
3810
+ raw: list[tuple[dict, Any]] = []
3811
+ raw_lo = [math.inf, math.inf, math.inf]
3812
+ raw_hi = [-math.inf, -math.inf, -math.inf]
3813
  for record in records:
3814
  stl_path = record.get("stl_path")
3815
  if not stl_path:
3816
  continue
3817
  try:
3818
  mesh = load_mesh(stl_path)
3819
+ except Exception:
3820
+ continue
3821
+ raw.append((record, mesh))
3822
+ for axis in range(3):
3823
+ raw_lo[axis] = min(raw_lo[axis], float(mesh.bounds[0][axis]))
3824
+ raw_hi[axis] = max(raw_hi[axis], float(mesh.bounds[1][axis]))
3825
+ if not raw or not all(math.isfinite(value) for value in raw_lo):
3826
+ return None
3827
+ rotation_center = tuple(
3828
+ (lo + hi) / 2.0 for lo, hi in zip(raw_lo, raw_hi)
3829
+ )
3830
+
3831
+ loaded: list[tuple[Any, tuple[float, float, float]]] = []
3832
+ corner = [math.inf, math.inf, math.inf]
3833
+ for record, mesh in raw:
3834
+ rotation = _record_rotation(record)
3835
+ if any(rotation):
3836
+ mesh = rotate_mesh(mesh, rotation, center=rotation_center)
3837
+ try:
3838
  scale_factors = _resolve_mesh_scale_factors(
3839
  mesh,
3840
  True,
 
3863
  z_hi = max(z_hi, float(scaled.bounds[1][2]))
3864
  if not math.isfinite(z_lo) or not math.isfinite(z_hi):
3865
  return None
3866
+ return calculate_z_levels(z_lo, z_hi, float(layer_height)), anchor, rotation_center
3867
 
3868
 
3869
  def _slice_record(
 
3871
  layer_height: float,
3872
  scale_mode: str | None,
3873
  progress_callback=None,
3874
+ slice_plan: tuple | None = None,
3875
  ) -> LayerStack:
3876
  stl_path = record["stl_path"]
3877
+ rotation = _record_rotation(record)
3878
+ rotation_center = slice_plan[2] if slice_plan and len(slice_plan) > 2 else None
3879
  mesh = load_mesh(stl_path)
3880
+ if any(rotation):
3881
+ # Scale factors come from the ROTATED bounding box: the target
3882
+ # dimensions describe the shape as it will print.
3883
+ mesh = rotate_mesh(mesh, rotation, center=rotation_center)
3884
  scale_factors = _resolve_mesh_scale_factors(
3885
  mesh,
3886
  True,
 
3897
  name=str(record.get("name") or Path(stl_path).stem),
3898
  z_levels=slice_plan[0] if slice_plan else None,
3899
  scale_anchor=slice_plan[1] if slice_plan else None,
3900
+ rotation=rotation if any(rotation) else None,
3901
+ rotation_center=rotation_center,
3902
  )
3903
  record["layer_stack"] = stack
3904
  record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, slice_plan)
 
3910
  layer_height: float,
3911
  scale_mode: str | None,
3912
  messages: list[str] | None = None,
3913
+ ) -> dict[int, tuple]:
3914
  """Per multi-material group member: (shared Z grid, shared scale anchor),
3915
  keyed by record id."""
3916
+ plan_by_record: dict[int, tuple] = {}
3917
  for nozzle, members in sorted(_multi_material_groups(records).items()):
3918
  plan = _multi_material_slice_plan(members, layer_height, scale_mode)
3919
  if plan is None:
 
4510
  "infill": round(_coerce_float(record.get("infill"), 100.0), 6),
4511
  "contour_tracing": bool(record.get("contour_tracing")),
4512
  "lead_in": bool(record.get("lead_in")),
4513
+ "rotation": _record_rotation(record),
4514
  "raster_pattern": str(raster_pattern or ""),
4515
  "pressure_ramp": bool(pressure_ramp_enabled),
4516
  "lead_in_params": (
 
4582
  "target_x",
4583
  "target_y",
4584
  "target_z",
4585
+ "original_x",
4586
+ "original_y",
4587
+ "original_z",
4588
+ "rotation",
4589
  "pressure",
4590
  "valve",
4591
  "nozzle",
 
5410
  selected_shape = gr.Dropdown(label="Preview Shape", choices=[], value=None, allow_custom_value=False)
5411
  refresh_preview_button = gr.Button("Regenerate Preview", variant="secondary", size="sm")
5412
 
5413
+ with gr.Row():
5414
+ rotate_input = gr.Number(
5415
+ label="Rotate (°)", value=0.0, step=15.0, min_width=140,
5416
+ info="Spins the shape on the bed - print it in any direction.",
5417
+ scale=1,
5418
+ )
5419
+ apply_rotation_button = gr.Button(
5420
+ "Apply Rotation", variant="secondary", size="sm",
5421
+ min_width=140, scale=1,
5422
+ )
5423
+ gr.HTML("", scale=3)
5424
+ rotation_status = gr.Markdown("")
5425
+
5426
  with gr.Row():
5427
  with gr.Column(scale=2, min_width=420):
5428
  model_viewer = gr.Model3D(
 
5938
  queue=False,
5939
  )
5940
 
5941
+ apply_rotation_button.click(
5942
+ fn=apply_shape_z_rotation,
5943
+ inputs=[shape_records, selected_shape, shape_settings, rotate_input],
5944
+ outputs=[shape_records, shape_settings, rotation_status],
5945
+ ).then(
5946
+ fn=show_selected_model,
5947
+ inputs=preview_inputs,
5948
+ outputs=[model_viewer, model_details],
5949
+ ).then(
5950
+ fn=update_layer_preview,
5951
+ inputs=layer_preview_inputs,
5952
+ outputs=layer_preview_outputs,
5953
+ ).then(
5954
+ fn=check_gcode_staleness,
5955
+ inputs=stale_inputs,
5956
+ outputs=[gcode_stale_banner],
5957
+ queue=False,
5958
+ )
5959
+ # Selecting a shape shows its stored rotation in the input.
5960
+ selected_shape.change(
5961
+ fn=selected_shape_z_rotation,
5962
+ inputs=[shape_records, selected_shape],
5963
+ outputs=[rotate_input],
5964
+ queue=False,
5965
+ )
5966
+
5967
  # Defined before the generate chain so it can auto-render the
5968
  # parallel view with fresh files (the same lists drive the
5969
  # Visualization tab wiring further down).
stl_slicer.py CHANGED
@@ -119,6 +119,34 @@ def scale_mesh(
119
  return scaled
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def scale_factors_for_target_extents(
123
  mesh: trimesh.Trimesh,
124
  target_extents: Sequence[float],
@@ -301,6 +329,8 @@ def slice_stl_to_layers(
301
  scale_anchor: Sequence[float] | None = None,
302
  flip_z: bool = False,
303
  z_flip_mid: float | None = None,
 
 
304
  ) -> LayerStack:
305
  """Slice an STL into per-layer vector outlines (world-XY millimetres).
306
 
@@ -310,13 +340,22 @@ def slice_stl_to_layers(
310
  is the point target-dimension scaling happens about (assembly parts share
311
  their group's corner so they stay assembled when rescaled).
312
 
 
 
 
 
 
 
313
  `flip_z` mirrors the scaled mesh about the horizontal plane at
314
  `z_flip_mid` (its own Z midpoint by default) — printing the shape the
315
  other way up. Assembly parts pass their GROUP's midplane so the whole
316
  assembly flips as one unit.
317
  """
318
  stl_path = Path(stl_path)
319
- mesh = scale_mesh(load_mesh(stl_path), scale_factors, anchor=scale_anchor)
 
 
 
320
  if flip_z:
321
  mid = (
322
  float(z_flip_mid)
 
119
  return scaled
120
 
121
 
122
+ def rotate_mesh(
123
+ mesh: trimesh.Trimesh,
124
+ rotation: Sequence[float] | None,
125
+ center: Sequence[float] | None = None,
126
+ ) -> trimesh.Trimesh:
127
+ """Rotated copy of `mesh`: X, then Y, then Z angles in degrees, about
128
+ `center` (the mesh's own bounding-box centre by default).
129
+
130
+ Multi-material assembly parts pass their GROUP's combined centre so
131
+ equal rotations turn the whole assembly as one rigid unit.
132
+ """
133
+ angles = tuple(float(value or 0.0) for value in (rotation or (0.0, 0.0, 0.0)))
134
+ rotated = mesh.copy()
135
+ if all(abs(angle) < 1e-9 for angle in angles):
136
+ return rotated
137
+ point = np.asarray(
138
+ (mesh.bounds[0] + mesh.bounds[1]) / 2.0 if center is None else center,
139
+ dtype=float,
140
+ )
141
+ for angle, axis in zip(angles, ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))):
142
+ if abs(angle) < 1e-9:
143
+ continue
144
+ rotated.apply_transform(
145
+ trimesh.transformations.rotation_matrix(math.radians(angle), axis, point)
146
+ )
147
+ return rotated
148
+
149
+
150
  def scale_factors_for_target_extents(
151
  mesh: trimesh.Trimesh,
152
  target_extents: Sequence[float],
 
329
  scale_anchor: Sequence[float] | None = None,
330
  flip_z: bool = False,
331
  z_flip_mid: float | None = None,
332
+ rotation: Sequence[float] | None = None,
333
+ rotation_center: Sequence[float] | None = None,
334
  ) -> LayerStack:
335
  """Slice an STL into per-layer vector outlines (world-XY millimetres).
336
 
 
340
  is the point target-dimension scaling happens about (assembly parts share
341
  their group's corner so they stay assembled when rescaled).
342
 
343
+ `rotation` turns the RAW mesh (X, then Y, then Z degrees, about
344
+ `rotation_center` — its own bbox centre by default) BEFORE scaling, so
345
+ the target dimensions apply to the rotated shape's bounding box: the
346
+ shape can be printed lying in any orientation. Callers computing scale
347
+ factors must derive them from the rotated mesh (see `rotate_mesh`).
348
+
349
  `flip_z` mirrors the scaled mesh about the horizontal plane at
350
  `z_flip_mid` (its own Z midpoint by default) — printing the shape the
351
  other way up. Assembly parts pass their GROUP's midplane so the whole
352
  assembly flips as one unit.
353
  """
354
  stl_path = Path(stl_path)
355
+ mesh = load_mesh(stl_path)
356
+ if rotation is not None:
357
+ mesh = rotate_mesh(mesh, rotation, center=rotation_center)
358
+ mesh = scale_mesh(mesh, scale_factors, anchor=scale_anchor)
359
  if flip_z:
360
  mid = (
361
  float(z_flip_mid)
tests/test_nozzle_spacing.py CHANGED
@@ -1751,6 +1751,63 @@ def test_multi_material_demo_set_groups_parts_onto_shared_nozzles() -> None:
1751
  assert [row[nozzle_pos] for row in outputs[2]] == [1, 1, 2, 2, 3, 3]
1752
 
1753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1754
  def test_project_settings_export_import_round_trip(tmp_path) -> None:
1755
  from app import export_project_settings, import_project_settings
1756
 
 
1751
  assert [row[nozzle_pos] for row in outputs[2]] == [1, 1, 2, 2, 3, 3]
1752
 
1753
 
1754
+ def test_apply_shape_rotation_updates_dims_and_reslices(tmp_path) -> None:
1755
+ import trimesh
1756
+
1757
+ from app import _slice_record, apply_shape_rotation, selected_shape_rotation
1758
+
1759
+ mesh = trimesh.creation.box(extents=(10.0, 4.0, 2.0))
1760
+ stl_path = tmp_path / "bar.stl"
1761
+ mesh.export(stl_path)
1762
+
1763
+ records = _records_from_files([str(stl_path)], None)
1764
+ record = records[0]
1765
+ assert (record["target_x"], record["target_y"], record["target_z"]) == (10.0, 4.0, 2.0)
1766
+
1767
+ # Slice unrotated, then rotate: the slice_params fingerprint must change
1768
+ # so the auto re-slice kicks in on the next generation.
1769
+ _slice_record(record, 1.0, None)
1770
+ params_before = dict(record["slice_params"])
1771
+
1772
+ updated, rows, status = apply_shape_rotation(records, None, None, 90, 0, 0)
1773
+ record = updated[0]
1774
+ assert "Rotated" in status
1775
+ assert record["rotation"] == (90.0, 0.0, 0.0)
1776
+ # Dimensions reset to the rotated bounding box: (10, 4, 2) -> (10, 2, 4).
1777
+ assert (record["target_x"], record["target_y"], record["target_z"]) == (10.0, 2.0, 4.0)
1778
+ assert (record["original_x"], record["original_y"], record["original_z"]) == (10.0, 2.0, 4.0)
1779
+ assert rows[0][2:5] == [10.0, 2.0, 4.0]
1780
+
1781
+ from app import _slice_params_snapshot
1782
+
1783
+ assert _slice_params_snapshot(record, 1.0, None) != params_before
1784
+
1785
+ # Re-slicing uses the rotated mesh: 4 layers of the stood-up bar.
1786
+ stack = _slice_record(record, 1.0, None)
1787
+ assert len(stack.layers) == 4
1788
+ (x0, y0, _), (x1, y1, _) = stack.bounds
1789
+ assert round(x1 - x0, 3) == 10.0
1790
+ assert round(y1 - y0, 3) == 2.0
1791
+
1792
+ # The rotation inputs mirror the stored value; clearing it restores the
1793
+ # unrotated dimensions.
1794
+ assert selected_shape_rotation(updated, None) == (90.0, 0.0, 0.0)
1795
+ cleared, _rows, cleared_status = apply_shape_rotation(updated, None, None, 0, 0, 0)
1796
+ assert cleared[0]["rotation"] is None
1797
+ assert "cleared" in cleared_status
1798
+ assert (cleared[0]["target_x"], cleared[0]["target_y"], cleared[0]["target_z"]) == (10.0, 4.0, 2.0)
1799
+
1800
+ # The single-input UI wrapper spins the shape on the bed (about Z):
1801
+ # the 10 x 4 bar's X and Y swap.
1802
+ from app import apply_shape_z_rotation, selected_shape_z_rotation
1803
+
1804
+ spun, _rows, spun_status = apply_shape_z_rotation(cleared, None, None, 90)
1805
+ assert spun[0]["rotation"] == (0.0, 0.0, 90.0)
1806
+ assert "90°" in spun_status and "X " not in spun_status.split(".")[0]
1807
+ assert (spun[0]["target_x"], spun[0]["target_y"], spun[0]["target_z"]) == (4.0, 10.0, 2.0)
1808
+ assert selected_shape_z_rotation(spun, None) == 90.0
1809
+
1810
+
1811
  def test_project_settings_export_import_round_trip(tmp_path) -> None:
1812
  from app import export_project_settings, import_project_settings
1813
 
tests/test_stl_slicer.py CHANGED
@@ -9,12 +9,55 @@ import pytest
9
  from stl_slicer import (
10
  _compose_even_odd_polygons,
11
  calculate_z_levels,
 
12
  scale_factors_for_target_extents,
13
  scale_mesh,
14
  slice_stl_to_layers,
15
  )
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
19
  z_values = calculate_z_levels(0.0, 0.01, 0.1)
20
 
 
9
  from stl_slicer import (
10
  _compose_even_odd_polygons,
11
  calculate_z_levels,
12
+ rotate_mesh,
13
  scale_factors_for_target_extents,
14
  scale_mesh,
15
  slice_stl_to_layers,
16
  )
17
 
18
 
19
+ def test_slice_stl_to_layers_applies_rotation(tmp_path) -> None:
20
+ mesh = trimesh.creation.box(extents=(10.0, 4.0, 2.0))
21
+ stl_path = tmp_path / "bar.stl"
22
+ mesh.export(stl_path)
23
+
24
+ # 90° about X stands the bar up: extents (10, 4, 2) -> (10, 2, 4).
25
+ stack = slice_stl_to_layers(stl_path, layer_height=1.0, rotation=(90.0, 0.0, 0.0))
26
+
27
+ bounds = np.array(stack.bounds)
28
+ np.testing.assert_allclose(bounds[1] - bounds[0], (10.0, 2.0, 4.0), atol=1e-6)
29
+ assert len(stack.layers) == 4
30
+ for layer in stack.layers:
31
+ assert layer.area == pytest.approx(20.0)
32
+
33
+ # Scale factors apply to the ROTATED bounding box.
34
+ rotated = rotate_mesh(trimesh.creation.box(extents=(10.0, 4.0, 2.0)), (90.0, 0.0, 0.0))
35
+ np.testing.assert_allclose(sorted(rotated.extents), sorted((10.0, 2.0, 4.0)), atol=1e-6)
36
+ factors = scale_factors_for_target_extents(rotated, (5.0, 2.0, 2.0))
37
+ scaled_stack = slice_stl_to_layers(
38
+ stl_path,
39
+ layer_height=1.0,
40
+ rotation=(90.0, 0.0, 0.0),
41
+ scale_factors=factors,
42
+ )
43
+ scaled_bounds = np.array(scaled_stack.bounds)
44
+ np.testing.assert_allclose(scaled_bounds[1] - scaled_bounds[0], (5.0, 2.0, 2.0), atol=1e-6)
45
+
46
+
47
+ def test_rotate_mesh_uses_shared_center_for_assemblies() -> None:
48
+ left = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
49
+ right = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
50
+ right.apply_translation((4.0, 0.0, 0.0))
51
+ combined_center = (2.0, 0.0, 0.0)
52
+
53
+ # Rotating both parts 180° about Z around the SHARED centre swaps their
54
+ # positions — the assembly turns as one rigid unit.
55
+ left_rotated = rotate_mesh(left, (0.0, 0.0, 180.0), center=combined_center)
56
+ right_rotated = rotate_mesh(right, (0.0, 0.0, 180.0), center=combined_center)
57
+ np.testing.assert_allclose(left_rotated.bounds, right.bounds, atol=1e-9)
58
+ np.testing.assert_allclose(right_rotated.bounds, left.bounds, atol=1e-9)
59
+
60
+
61
  def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
62
  z_values = calculate_z_levels(0.0, 0.01, 0.1)
63