Spaces:
Sleeping
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>
- README.md +1 -0
- app.py +208 -10
- stl_slicer.py +40 -1
- tests/test_nozzle_spacing.py +57 -0
- tests/test_stl_slicer.py +43 -0
|
@@ -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)
|
|
@@ -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 |
-
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
| 3685 |
-
|
|
|
|
| 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
|
| 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
|
| 3760 |
"""Per multi-material group member: (shared Z grid, shared scale anchor),
|
| 3761 |
keyed by record id."""
|
| 3762 |
-
plan_by_record: dict[int, tuple
|
| 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).
|
|
@@ -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 =
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
|
|
@@ -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 |
|