Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Duplicates (duplicate contours inside a workpiece and stacked copies
of whole workpieces), and Break Apart (one workpiece per contour,
holes and open paths included). Each is a single undo step (#516)
- Contour: Perforation post-processor for dashed cuts. The laser
fires for a Cut Length and is off for a Skip Length along every
contour, starting with a full cut at the start of each contour; off
by default (#525)
- Sketcher: boolean operations. Union, Difference, Intersection and
Exclude bake the selected closed regions into multi-ring polygons
(outer contours counter-clockwise, hole contours clockwise), so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]:
Smooth = transformer_registry.get("Smooth")
LeadInOutTransformer = transformer_registry.get("LeadInOutTransformer")
TabOpsTransformer = transformer_registry.get("TabOpsTransformer")
PerforationTransformer = transformer_registry.get(
"PerforationTransformer"
)
CropTransformer = transformer_registry.get("CropTransformer")
MergeLinesTransformer = transformer_registry.get(
"MergeLinesTransformer"
Expand All @@ -269,6 +272,7 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]:
assert Smooth is not None
assert LeadInOutTransformer is not None
assert TabOpsTransformer is not None
assert PerforationTransformer is not None
assert CropTransformer is not None
assert MergeLinesTransformer is not None
assert Optimize is not None
Expand All @@ -280,6 +284,7 @@ def get_default_transformers_dicts(cls) -> tuple[list, list]:
enabled=False, lead_in_mm=0, lead_out_mm=0, auto=True
).to_dict(),
TabOpsTransformer().to_dict(),
PerforationTransformer(enabled=False).to_dict(),
CropTransformer(enabled=False).to_dict(),
optimize_dict,
], [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,25 @@ def test_create(self, mock_context):
step = ContourStep.create(mock_context, name="Created")
assert isinstance(step, ContourStep)
assert step.name == "Created"
assert len(step.per_workpiece_transformers_dicts) == 5
assert len(step.per_workpiece_transformers_dicts) == 6
assert len(step.per_step_transformers_dicts) == 3
assert step.selected_head_uid == "test-laser-uid"

def test_create_without_optimize(self, mock_context):
step = ContourStep.create(mock_context, optimize=False)
assert len(step.per_workpiece_transformers_dicts) == 4
assert len(step.per_workpiece_transformers_dicts) == 5

def test_create_adds_disabled_perforation_after_tabs(self, mock_context):
step = ContourStep.create(mock_context)
names = [t["name"] for t in step.per_workpiece_transformers_dicts]
assert "PerforationTransformer" in names
assert names.index("PerforationTransformer") == (
names.index("TabOpsTransformer") + 1
)
perforation = step.per_workpiece_transformers_dicts[
names.index("PerforationTransformer")
]
assert perforation["enabled"] is False

def test_serialization_includes_step_type(self):
step = ContourStep(name="Test")
Expand Down Expand Up @@ -88,6 +100,7 @@ def test_from_dict_adds_new_transformers_from_old_project(self):
t["name"] for t in restored.per_workpiece_transformers_dicts
]
assert "TabOpsTransformer" in wp_names
assert "PerforationTransformer" in wp_names
assert "Smooth" in wp_names
assert "CropTransformer" in wp_names
assert "Optimize" in wp_names
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .multipass_transformer import MultiPassTransformer
from .optimize_transformer import Optimize
from .overscan_transformer import OverscanTransformer
from .perforation_transformer import PerforationTransformer
from .smooth_transformer import Smooth
from .tabs_transformer import TabOpsTransformer

Expand All @@ -23,6 +24,7 @@
"MultiPassTransformer",
"Optimize",
"OverscanTransformer",
"PerforationTransformer",
"Smooth",
"TabOpsTransformer",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from __future__ import annotations

from collections.abc import Sequence
from gettext import gettext as _
from typing import TYPE_CHECKING, Any

from raygeo.geo import Matrix
from raygeo.ops.transform.tabs import TabsSpec

from rayforge.core.workpiece import WorkPiece
from rayforge.pipeline.transformer.base import OpsTransformer

if TYPE_CHECKING:
from raygeo.geo import Geometry


MIN_LENGTH = 0.01
EPSILON = 1e-4


class PerforationTransformer(OpsTransformer):
"""
Turns cut paths into a dashed cut: the laser fires for
``cut_length`` and is off for ``skip_length``, repeated along
every contour of the workpiece.

The gaps are placed in millimetres along each contour of the
workpiece's boundaries, starting with a full cut at the start of
every contour, and handed to the tabs gap machinery, which cuts
them out of the nearest toolpath.
"""

DEFAULT_CUT_LENGTH = 2.0
DEFAULT_SKIP_LENGTH = 1.0

def __init__(
self,
enabled: bool = True,
cut_length: float = DEFAULT_CUT_LENGTH,
skip_length: float = DEFAULT_SKIP_LENGTH,
):
super().__init__(enabled=enabled)
self._cut_length = max(MIN_LENGTH, cut_length)
self._skip_length = max(MIN_LENGTH, skip_length)

@property
def cut_length(self) -> float:
return self._cut_length

@cut_length.setter
def cut_length(self, value: float) -> None:
self._cut_length = max(MIN_LENGTH, value)
self.changed.send(self)

@property
def skip_length(self) -> float:
return self._skip_length

@skip_length.setter
def skip_length(self, value: float) -> None:
self._skip_length = max(MIN_LENGTH, value)
self.changed.send(self)

@property
def label(self) -> str:
return _("Perforation")

@property
def description(self) -> str:
return _(
"Cuts in dashes: the laser fires for the cut length and "
"skips the skip length"
)

def _gap_centres(self, length: float) -> list[float]:
period = self._cut_length + self._skip_length
half_gap = self._skip_length / 2.0
centres: list[float] = []
centre = self._cut_length + half_gap
while centre + half_gap <= length + EPSILON:
centres.append(centre)
centre += period
return centres

def _clips_for_contour(
self, contour: Geometry
) -> list[tuple[float, float, float]]:
centres = self._gap_centres(contour.distance())
if not centres:
return []
positions = contour.get_positions_at_distances(centres)
return [
(point[0], point[1], self._skip_length)
for _seg, _t, point in positions
]

def _generate_clips(
self, workpiece: WorkPiece
) -> list[tuple[float, float, float]]:
boundaries = workpiece.boundaries
if boundaries is None or boundaries.is_empty():
return []
width, height = workpiece.size
scaled = boundaries.copy()
if width > 0 and height > 0:
scaled.transform(Matrix.scale(width, height))
clips: list[tuple[float, float, float]] = []
for contour in scaled.split_into_contours():
clips.extend(self._clips_for_contour(contour))
return clips

def to_spec(
self,
workpiece: WorkPiece | None,
stock_geometries: Sequence[Geometry] | None,
settings: dict[str, Any] | None,
) -> TabsSpec:
clips = self._generate_clips(workpiece) if workpiece else []
return TabsSpec(tab_power=0.0, original_power=1.0, clips=clips)

def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data["cut_length"] = self._cut_length
data["skip_length"] = self._skip_length
return data

@classmethod
def from_dict(cls, data: dict[str, Any]) -> PerforationTransformer:
if data.get("name") != cls.__name__:
raise ValueError(
f"Mismatched transformer name: expected {cls.__name__},"
f" got {data.get('name')}"
)
return cls(
enabled=data.get("enabled", True),
cut_length=data.get("cut_length", cls.DEFAULT_CUT_LENGTH),
skip_length=data.get("skip_length", cls.DEFAULT_SKIP_LENGTH),
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
MultiPassTransformer,
Optimize,
OverscanTransformer,
PerforationTransformer,
Smooth,
)
from .crop_group import CropSettingsGroup
Expand All @@ -17,6 +18,7 @@
from .multipass_group import MultiPassSettingsGroup
from .optimize_group import OptimizeSettingsGroup
from .overscan_group import OverscanSettingsGroup
from .perforation_group import PerforationSettingsGroup
from .smooth_group import SmoothSettingsGroup

TRANSFORMER_WIDGETS = {
Expand All @@ -26,5 +28,6 @@
MultiPassTransformer: MultiPassSettingsGroup,
Optimize: OptimizeSettingsGroup,
OverscanTransformer: OverscanSettingsGroup,
PerforationTransformer: PerforationSettingsGroup,
Smooth: SmoothSettingsGroup,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from gettext import gettext as _
from typing import TYPE_CHECKING

from rayforge.shared.util.glib import DebounceMixin
from rayforge.ui_gtk.doceditor.post_processor.groups import (
ExpanderHost,
TransformerSettingsGroup,
)
from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow

from ..transformers import PerforationTransformer
from ..transformers.perforation_transformer import MIN_LENGTH

if TYPE_CHECKING:
from rayforge.core.step import Step


class PerforationSettingsGroup(DebounceMixin, TransformerSettingsGroup):
"""UI for configuring the PerforationTransformer."""

def __init__(
self,
title: str,
transformer: PerforationTransformer,
page: ExpanderHost,
*,
step: "Step | None" = None,
**kwargs,
):
super().__init__(title, transformer, page, step=step, **kwargs)

self.cut_row = LengthSpinRow(
_("Cut Length"),
_("Distance the laser fires before each gap"),
lower=MIN_LENGTH,
upper=1000.0,
value_in_base=transformer.cut_length,
)
self.cut_row.value_changed.connect(
lambda r: self._debounce(self._on_cut_changed, r)
)
self.add(self.cut_row)

self.skip_row = LengthSpinRow(
_("Skip Length"),
_("Distance travelled with the laser off between cuts"),
lower=MIN_LENGTH,
upper=1000.0,
value_in_base=transformer.skip_length,
)
self.skip_row.value_changed.connect(
lambda r: self._debounce(self._on_skip_changed, r)
)
self.add(self.skip_row)

def _on_cut_changed(self, row: LengthSpinRow) -> None:
self.param_changed.send(
self,
key="cut_length",
value=row.get_value_in_base_units(),
name=_("Change perforation cut length"),
)

def _on_skip_changed(self, row: LengthSpinRow) -> None:
self.param_changed.send(
self,
key="skip_length",
value=row.get_value_in_base_units(),
name=_("Change perforation skip length"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
MultiPassTransformer,
Optimize,
OverscanTransformer,
PerforationTransformer,
Smooth,
TabOpsTransformer,
)
Expand All @@ -33,5 +34,8 @@ def register_transformers(transformer_registry):
transformer_registry.register(MultiPassTransformer, addon_name=ADDON_NAME)
transformer_registry.register(Optimize, addon_name=ADDON_NAME)
transformer_registry.register(OverscanTransformer, addon_name=ADDON_NAME)
transformer_registry.register(
PerforationTransformer, addon_name=ADDON_NAME
)
transformer_registry.register(Smooth, addon_name=ADDON_NAME)
transformer_registry.register(TabOpsTransformer, addon_name=ADDON_NAME)
Loading
Loading