From 100fcee3794d821ee18e585aaa1577eb366b0a11 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:20:48 +0200 Subject: [PATCH 01/28] feat(laser): add Interval Test and Focus Test parameters The values every cell or line of the two new calibration tests is made with: evenly spread line intervals (with their LPI) and focus offsets, the row layout, and the commands the Focus Test puts between its lines: an M0 pause in manual mode, or relative Z moves (G91/G0 Z/G90) that end at the starting height, honouring Reverse Z. Offsets are limited to +/-10 mm. Refs #519 --- .../laser_essentials/calibration_tests.py | 190 ++++++++++++++++++ .../tests/test_calibration_test_params.py | 110 ++++++++++ 2 files changed, 300 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py new file mode 100644 index 000000000..958d7a379 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py @@ -0,0 +1,190 @@ +""" +Parameters and layout of the Interval Test and the Focus Test. + +Both tests are generated as ordinary project content (a layer with +workpieces and steps, see ``commands/calibration_test_cmd.py``). This +module holds the parts that do not touch the document: the values each +cell or line is made with, their positions, and the machine commands +the Focus Test puts between its lines. +""" + +from dataclasses import dataclass +from enum import Enum +from gettext import gettext as _ + +MM_PER_INCH = 25.4 + +# The Focus Test never moves the head further than this from the +# height it starts at, so a typo cannot drive it into the material. +MAX_FOCUS_OFFSET_MM = 10.0 + + +class FocusMode(Enum): + """How the focus height changes between the lines of a Focus Test.""" + + Z_AXIS = "z_axis" + MANUAL = "manual" + RAMP = "ramp" + + @property + def label(self) -> str: + labels = { + FocusMode.Z_AXIS: _("Z axis steps"), + FocusMode.MANUAL: _("Manual (pause between lines)"), + FocusMode.RAMP: _("Ramp (tilted material)"), + } + return labels[self] + + @property + def needs_commands(self) -> bool: + """True when the mode puts machine commands between lines.""" + return self != FocusMode.RAMP + + +@dataclass +class IntervalTestParams: + """Settings of an Interval Test.""" + + min_interval: float = 0.05 + max_interval: float = 0.25 + count: int = 5 + cell_size: float = 10.0 + spacing: float = 5.0 + power_percent: float = 30.0 + speed: float = 3000.0 + include_labels: bool = True + label_power_percent: float = 10.0 + label_speed: float = 1000.0 + label_height: float = 2.5 + + +@dataclass +class FocusTestParams: + """Settings of a Focus Test.""" + + mode: FocusMode = FocusMode.MANUAL + start_offset: float = -2.0 + step: float = 0.5 + count: int = 9 + line_length: float = 15.0 + spacing: float = 6.0 + ramp_length: float = 100.0 + power_percent: float = 30.0 + speed: float = 1500.0 + include_labels: bool = True + label_power_percent: float = 10.0 + label_speed: float = 1000.0 + label_height: float = 2.5 + + +def interval_values(params: IntervalTestParams) -> list[float]: + """The line interval of every cell, evenly spread over the range.""" + if params.count < 2: + raise ValueError(_("An Interval Test needs at least two cells.")) + low, high = params.min_interval, params.max_interval + if low <= 0 or high <= low: + raise ValueError( + _( + "The minimum line interval must be above zero and below " + "the maximum." + ) + ) + step = (high - low) / (params.count - 1) + return [low + i * step for i in range(params.count)] + + +def lines_per_inch(interval_mm: float) -> float: + """The line density (LPI) that matches a line interval in mm.""" + return MM_PER_INCH / interval_mm + + +def row_positions(count: int, size: float, spacing: float) -> list[float]: + """The offsets of *count* items of *size* placed in a row.""" + return [i * (size + spacing) for i in range(count)] + + +def focus_offsets(params: FocusTestParams) -> list[float]: + """The focus offset of every line of a Focus Test.""" + if params.count < 2: + raise ValueError(_("A Focus Test needs at least two lines.")) + if params.step == 0: + raise ValueError(_("The focus step must not be zero.")) + offsets = [ + params.start_offset + i * params.step for i in range(params.count) + ] + if max(abs(offsets[0]), abs(offsets[-1])) > MAX_FOCUS_OFFSET_MM: + raise ValueError( + _( + "The focus offsets must stay within ±{limit:g} mm of " + "the starting height." + ).format(limit=MAX_FOCUS_OFFSET_MM) + ) + return offsets + + +def format_offset(offset: float) -> str: + """A focus offset as shown in labels, e.g. ``+0.5`` or ``-2.0``.""" + if abs(offset) < 1e-9: + return "0.0" + return f"{offset:+.1f}" + + +def _number(value: float) -> str: + text = f"{value:.4f}".rstrip("0").rstrip(".") + return "0" if text in ("-0", "") else text + + +def _z_move(distance: float, reverse_z: bool) -> list[str]: + """A relative Z move that leaves the controller in G90.""" + if reverse_z: + distance = -distance + return ["G91", f"G0 Z{_number(distance)}", "G90"] + + +def focus_commands( + params: FocusTestParams, reverse_z: bool +) -> list[tuple[int, list[str], str]]: + """ + The machine commands a Focus Test runs between its lines. + + Returns ``(index, lines, name)`` tuples: *lines* run right before + line *index* (an index equal to the line count means after the last + line), and *name* describes the command in the workflow. + + A positive offset means more distance between the head and the + material. In Z axis mode the head is moved relatively with + ``G91``/``G0 Z``/``G90`` and returned to its starting height at the + end. In manual mode the job pauses with ``M0`` before every line so + the head can be moved by hand. + """ + if not params.mode.needs_commands: + return [] + offsets = focus_offsets(params) + commands: list[tuple[int, list[str], str]] = [] + if params.mode == FocusMode.Z_AXIS: + previous = 0.0 + for index, offset in enumerate(offsets): + name = _("Z to {offset} mm").format(offset=format_offset(offset)) + commands.append( + (index, _z_move(offset - previous, reverse_z), name) + ) + previous = offset + commands.append( + ( + len(offsets), + _z_move(-previous, reverse_z), + _("Z back to the starting height"), + ) + ) + return commands + for index, offset in enumerate(offsets): + if index == 0: + name = _("Pause: set the head to {offset} mm from focus").format( + offset=format_offset(offset) + ) + else: + name = _("Pause: move the head by {step} mm").format( + step=format_offset(params.step) + ) + commands.append((index, ["M0"], name)) + return commands diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py new file mode 100644 index 000000000..5554530fc --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py @@ -0,0 +1,110 @@ +import pytest +from laser_essentials.calibration_tests import ( + FocusMode, + FocusTestParams, + IntervalTestParams, + focus_commands, + focus_offsets, + format_offset, + interval_values, + lines_per_inch, + row_positions, +) + + +class TestIntervalValues: + def test_linear_including_both_ends(self): + params = IntervalTestParams( + min_interval=0.05, max_interval=0.25, count=5 + ) + assert interval_values(params) == pytest.approx( + [0.05, 0.10, 0.15, 0.20, 0.25] + ) + + def test_rejects_fewer_than_two_cells(self): + with pytest.raises(ValueError): + interval_values(IntervalTestParams(count=1)) + + @pytest.mark.parametrize("low,high", [(0.2, 0.1), (0.0, 0.1)]) + def test_rejects_bad_range(self, low, high): + params = IntervalTestParams(min_interval=low, max_interval=high) + with pytest.raises(ValueError): + interval_values(params) + + def test_lines_per_inch(self): + assert lines_per_inch(0.1) == pytest.approx(254.0) + + +class TestRowPositions: + def test_cells_are_spaced_left_to_right(self): + assert row_positions(3, 10.0, 2.0) == pytest.approx([0.0, 12.0, 24.0]) + + +class TestFocusOffsets: + def test_offsets(self): + params = FocusTestParams(start_offset=-1.0, step=0.5, count=5) + assert focus_offsets(params) == pytest.approx( + [-1.0, -0.5, 0.0, 0.5, 1.0] + ) + + def test_rejects_zero_step(self): + with pytest.raises(ValueError): + focus_offsets(FocusTestParams(step=0.0)) + + def test_rejects_unreasonable_range(self): + with pytest.raises(ValueError): + focus_offsets( + FocusTestParams(start_offset=-8.0, step=1.0, count=20) + ) + + @pytest.mark.parametrize( + "value,text", [(-2.0, "-2.0"), (0.0, "0.0"), (0.5, "+0.5")] + ) + def test_format_offset(self, value, text): + assert format_offset(value) == text + + +class TestFocusCommands: + def test_ramp_needs_no_commands(self): + params = FocusTestParams(mode=FocusMode.RAMP) + assert focus_commands(params, reverse_z=False) == [] + + def test_manual_pauses_before_every_line(self): + params = FocusTestParams( + mode=FocusMode.MANUAL, start_offset=-0.5, step=0.5, count=3 + ) + commands = focus_commands(params, reverse_z=False) + # (index of the line the command precedes, G-code lines) + assert [index for index, _code, _name in commands] == [0, 1, 2] + assert all(code == ["M0"] for _i, code, _n in commands) + names = [name for _i, _code, name in commands] + assert "-0.5" in names[0] + assert "0.5" in names[1] + + def test_z_axis_moves_relatively_and_returns(self): + params = FocusTestParams( + mode=FocusMode.Z_AXIS, start_offset=-1.0, step=0.5, count=3 + ) + commands = focus_commands(params, reverse_z=False) + assert [index for index, _code, _name in commands] == [0, 1, 2, 3] + assert commands[0][1] == ["G91", "G0 Z-1", "G90"] + assert commands[1][1] == ["G91", "G0 Z0.5", "G90"] + assert commands[2][1] == ["G91", "G0 Z0.5", "G90"] + # Back to the starting height after the last line. + assert commands[3][1] == ["G91", "G0 Z0", "G90"] + + def test_z_axis_return_balances_moves(self): + params = FocusTestParams( + mode=FocusMode.Z_AXIS, start_offset=0.5, step=0.25, count=4 + ) + commands = focus_commands(params, reverse_z=False) + total = sum(float(code[1].split("Z")[1]) for _i, code, _n in commands) + assert total == pytest.approx(0.0) + + def test_z_axis_respects_reversed_axis(self): + params = FocusTestParams( + mode=FocusMode.Z_AXIS, start_offset=-1.0, step=0.5, count=2 + ) + commands = focus_commands(params, reverse_z=True) + assert commands[0][1] == ["G91", "G0 Z1", "G90"] + assert commands[1][1] == ["G91", "G0 Z-0.5", "G90"] From 331f89bf7b1196fee4b88983d4ebae1dafba54fc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:20:48 +0200 Subject: [PATCH 02/28] feat(laser): generate Interval and Focus Tests as project content CalibrationTestCmd creates each test as its own layer. Every cell or line is a vector workpiece with its own step, linked through generated_workpiece_uid like the Material Test Grid, so it stays editable. Interval cells are Engrave steps with constant power and their own line interval; focus lines are Contour steps, with Command steps in between for the manual and Z axis modes. Labels share one workpiece and one low-power contour step that runs first. The test is centered on the work area and added in one undoable step. Golden G-code tests cover the interval pitch per cell, the M0 pauses (always after M5) and the balanced Z moves. Refs #519 --- .../laser_essentials/commands/__init__.py | 2 + .../commands/calibration_test_cmd.py | 400 ++++++++++++++++++ .../data/calibration_tests/focus_manual.gcode | 21 + .../data/calibration_tests/focus_z_axis.gcode | 30 ++ .../calibration_tests/interval_test.gcode | 78 ++++ tests/doceditor/test_calibration_tests.py | 251 +++++++++++ 6 files changed, 782 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py create mode 100644 tests/doceditor/data/calibration_tests/focus_manual.gcode create mode 100644 tests/doceditor/data/calibration_tests/focus_z_axis.gcode create mode 100644 tests/doceditor/data/calibration_tests/interval_test.gcode create mode 100644 tests/doceditor/test_calibration_tests.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py index bea20ae93..58d9901f3 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py @@ -4,8 +4,10 @@ Provides command implementations for laser operations. """ +from .calibration_test_cmd import CalibrationTestCmd from .material_test_cmd import MaterialTestCmd __all__ = [ + "CalibrationTestCmd", "MaterialTestCmd", ] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py new file mode 100644 index 000000000..31ef62bf8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py @@ -0,0 +1,400 @@ +""" +Creates Interval Tests and Focus Tests as ordinary project content. + +Each test becomes its own layer. Every cell or line is a workpiece +with its own step, linked through ``Step.generated_workpiece_uid`` the +same way the Material Test Grid links its step, so each one keeps its +own settings and stays editable in the step settings. Labels share one +workpiece with one low-power contour step. The Focus Test puts Command +steps (``M0`` pauses or relative Z moves) between its lines. +""" + +import logging +import re +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any, Protocol, cast + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.shape.text import FontConfig, text_to_geometry + +from rayforge.core.layer import Layer +from rayforge.core.source_asset import SourceAsset +from rayforge.core.step import Step +from rayforge.core.step_registry import step_registry +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand +from rayforge.image.svg.exporter import GeometrySvgExporter +from rayforge.image.svg.importer import SvgImporter +from rayforge.pipeline.intent_builder import _driver_uses_gcode +from rayforge.pipeline.transformer.registry import transformer_registry + +from ..calibration_tests import ( + FocusMode, + FocusTestParams, + IntervalTestParams, + focus_commands, + focus_offsets, + format_offset, + interval_values, + lines_per_inch, + row_positions, +) +from ..steps import ContourStep, EngraveStep +from ..steps.laser_step import LaserStep + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + + class OverscanTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +logger = logging.getLogger(__name__) + +# Gap between a cell or line and its label, in label heights. +_LABEL_GAP = 0.6 +# Spacing of the distance ticks along the Focus Test ramp, in mm. +_RAMP_TICK_MM = 10.0 +_RAMP_TICK_LENGTH = 2.0 + + +def _rect(x: float, y: float, w: float, h: float) -> Geometry: + geo = Geometry() + geo.move_to(x, y) + geo.line_to(x + w, y) + geo.line_to(x + w, y + h) + geo.line_to(x, y + h) + geo.close_path() + return geo + + +def _line(x0: float, y0: float, x1: float, y1: float) -> Geometry: + geo = Geometry() + geo.move_to(x0, y0) + geo.line_to(x1, y1) + return geo + + +def _text( + text: str, + height: float, + center_x: float, + top: float, + max_width: float | None = None, +) -> Geometry: + """ + Glyph outlines of *text*, *height* mm tall and centered below *top*, + scaled down further when needed to fit in *max_width*. + """ + geo = text_to_geometry(text, FontConfig(size=10.0)) + min_x, min_y, max_x, max_y = geo.rect() + scale = height / max(max_y - min_y, 1e-9) + if max_width is not None and (max_x - min_x) * scale > max_width: + scale = max_width / max(max_x - min_x, 1e-9) + width = (max_x - min_x) * scale + text_height = (max_y - min_y) * scale + matrix = ( + Matrix.translation(center_x - width / 2, top - text_height) + @ Matrix.scale(scale, scale) + @ Matrix.translation(-min_x, -min_y) + ) + geo.transform(matrix) + return geo + + +class CalibrationTestCmd: + """Creates Interval Tests and Focus Tests in the document.""" + + def __init__(self, editor: "DocEditor"): + self._editor = editor + self._pending_assets: list[SourceAsset] = [] + + # -- Availability ----------------------------------------------------- + + def available_focus_modes(self) -> list[FocusMode]: + """ + The Focus Test modes the current machine supports: Z axis steps + need a Z axis, and both Z and manual mode need a G-code driver + and the Command step (Automation addon). + """ + modes = [FocusMode.RAMP] + machine = self._editor.context.machine + if machine is None or step_registry.get("CommandStep") is None: + return modes + if not _driver_uses_gcode(machine): + return modes + modes.insert(0, FocusMode.MANUAL) + if machine.has_z_axis: + modes.insert(0, FocusMode.Z_AXIS) + return modes + + # -- Interval Test ---------------------------------------------------- + + def create_interval_test(self, params: IntervalTestParams) -> Layer: + """Adds an Interval Test layer and returns it.""" + intervals = interval_values(params) + layer = Layer(_("Interval Test")) + size = params.cell_size + xs = row_positions(len(intervals), size, params.spacing) + cell_y = self._label_block_height(params, lines=2) + if params.include_labels: + labels = Geometry() + for x, interval in zip(xs, intervals): + center = x + size / 2 + top = cell_y - _LABEL_GAP * params.label_height + fit = (size + params.spacing) * 0.9 + labels.extend( + _text( + f"{interval:.2f}", + params.label_height, + center, + top, + fit, + ) + ) + labels.extend( + _text( + _("{lpi:.0f} LPI").format( + lpi=lines_per_inch(interval) + ), + params.label_height, + center, + top - params.label_height * 1.5, + fit, + ) + ) + self._add_labels(layer, labels, params) + for x, interval in zip(xs, intervals): + name = _("Interval {interval:.2f} mm").format(interval=interval) + wp = self._add_workpiece( + layer, _rect(x, cell_y, size, size), name, filled=True + ) + step = self._engrave_step(params.power_percent, params.speed) + step.name = name + step.line_interval_mm = interval + step.depth_mode = "CONSTANT_POWER" + self._link(layer, step, wp) + self._insert(layer, _("Add Interval Test")) + return layer + + # -- Focus Test ------------------------------------------------------- + + def create_focus_test(self, params: FocusTestParams) -> Layer: + """Adds a Focus Test layer and returns it.""" + if params.mode not in self.available_focus_modes(): + raise ValueError( + _( + "This machine does not support the focus test mode {mode}." + ).format(mode=params.mode.label) + ) + layer = Layer(_("Focus Test")) + if params.mode == FocusMode.RAMP: + self._build_ramp(layer, params) + else: + self._build_focus_lines(layer, params) + self._insert(layer, _("Add Focus Test")) + return layer + + def _build_focus_lines(self, layer: Layer, params: FocusTestParams): + offsets = focus_offsets(params) + xs = row_positions(len(offsets), 0.0, params.spacing) + line_y = self._label_block_height(params, lines=2) + if params.include_labels: + # Labels alternate between two rows, so each may be almost + # twice as wide as the line spacing. + labels = Geometry() + top = line_y - _LABEL_GAP * params.label_height + for index, (x, offset) in enumerate(zip(xs, offsets)): + row_top = top - (index % 2) * params.label_height * 1.5 + labels.extend( + _text( + format_offset(offset), + params.label_height, + x, + row_top, + params.spacing * 1.8, + ) + ) + self._add_labels(layer, labels, params) + machine = self._editor.context.machine + reverse_z = bool(machine and machine.reverse_z_axis) + commands: dict[int, tuple[list[str], str]] = { + index: (lines, name) + for index, lines, name in focus_commands(params, reverse_z) + } + for index, (x, offset) in enumerate(zip(xs, offsets)): + self._add_command(layer, commands.get(index)) + name = _("Focus {offset} mm").format(offset=format_offset(offset)) + wp = self._add_workpiece( + layer, + _line(x, line_y, x, line_y + params.line_length), + name, + filled=False, + ) + step = self._contour_step(params.power_percent, params.speed) + step.name = name + self._link(layer, step, wp) + self._add_command(layer, commands.get(len(offsets))) + + def _build_ramp(self, layer: Layer, params: FocusTestParams): + length = params.ramp_length + line_y = self._label_block_height(params, lines=1) + line_y += _RAMP_TICK_LENGTH + if params.include_labels: + labels = Geometry() + ticks = int(length // _RAMP_TICK_MM) + 1 + for i in range(ticks): + x = i * _RAMP_TICK_MM + labels.extend( + _line(x, line_y - _RAMP_TICK_LENGTH, x, line_y - 0.5) + ) + labels.extend( + _text( + f"{x:g}", + params.label_height, + x, + line_y - _RAMP_TICK_LENGTH - 0.5, + ) + ) + self._add_labels(layer, labels, params) + name = _("Focus ramp") + wp = self._add_workpiece( + layer, _line(0.0, line_y, length, line_y), name, filled=False + ) + step = self._contour_step(params.power_percent, params.speed) + step.name = name + self._link(layer, step, wp) + + # -- Building blocks -------------------------------------------------- + + @staticmethod + def _label_block_height(params, lines: int) -> float: + """Height reserved below the cells for *lines* lines of labels.""" + if not params.include_labels: + return 0.0 + height = params.label_height + return height * (_LABEL_GAP + lines + (lines - 1) * 0.5) + + def _add_labels(self, layer: Layer, labels: Geometry, params): + wp = self._add_workpiece(layer, labels, _("Labels"), filled=False) + step = self._contour_step( + params.label_power_percent, params.label_speed + ) + step.name = _("Labels") + self._link(layer, step, wp) + + def _add_command( + self, layer: Layer, command: tuple[list[str], str] | None + ): + if command is None: + return + lines, name = command + step_cls: Any = step_registry.get("CommandStep") + assert step_cls is not None + step = step_cls.create(self._editor.context, name=name) + step.set_command_text("\n".join(lines)) + assert layer.workflow is not None + layer.workflow.add_step(step) + + def _contour_step(self, power_percent: float, speed: float) -> Step: + step = ContourStep.create(self._editor.context) + self._set_laser(step, power_percent, speed) + return step + + def _engrave_step(self, power_percent: float, speed: float) -> EngraveStep: + step = EngraveStep.create(self._editor.context) + self._set_laser(step, power_percent, speed) + self._update_overscan(step) + return step + + @staticmethod + def _set_laser(step: LaserStep, power_percent: float, speed: float): + step.set_power(power_percent / 100.0) + step.set_cut_speed(int(speed)) + + def _update_overscan(self, step: Step): + """Recomputes the automatic overscan for the step's speed.""" + machine = self._editor.context.machine + overscan = cast( + "OverscanTransformerType | None", + transformer_registry.get("OverscanTransformer"), + ) + if machine is None or overscan is None: + return + distance = overscan.calculate_auto_distance( + step.cut_speed, machine.acceleration + ) + for spec in step.per_workpiece_transformers_dicts: + if spec.get("name") == "OverscanTransformer" and spec.get("auto"): + spec["distance_mm"] = distance + + @staticmethod + def _link(layer: Layer, step: Step, wp: WorkPiece): + step.generated_workpiece_uid = wp.uid + assert layer.workflow is not None + layer.workflow.add_step(step) + + def _add_workpiece( + self, layer: Layer, geometry: Geometry, name: str, filled: bool + ) -> WorkPiece: + """ + Imports *geometry* as a vector workpiece at its own coordinates + (relative to the test's bottom-left corner). + """ + svg = GeometrySvgExporter(geometry).export().decode("utf-8") + if filled: + svg = re.sub(r'fill="none"', 'fill="black"', svg) + svg = re.sub(r'stroke="black"', 'stroke="none"', svg) + result = SvgImporter(svg.encode("utf-8")).get_doc_items( + PassthroughSpec() + ) + if not result or not result.payload or not result.payload.items: + raise RuntimeError(f"Could not create the {name} workpiece.") + source: SourceAsset = result.payload.source + source.name = name + wp = result.payload.items[0] + assert isinstance(wp, WorkPiece) + wp.name = name + world = wp.get_world_geometry() + assert world is not None + min_x, min_y, _max_x, _max_y = world.rect() + target_x, target_y, _tx, _ty = geometry.rect() + wp.pos = ( + wp.pos[0] + target_x - min_x, + wp.pos[1] + target_y - min_y, + ) + layer.add_child(wp) + self._pending_assets.append(source) + return wp + + def _insert(self, layer: Layer, undo_name: str): + """Centers the test on the work area and adds it undoably.""" + rects = [ + wp.get_world_geometry().rect() # type: ignore[union-attr] + for wp in layer.all_workpieces + ] + min_x = min(r[0] for r in rects) + min_y = min(r[1] for r in rects) + max_x = max(r[2] for r in rects) + max_y = max(r[3] for r in rects) + dims = self._editor.machine_dimensions + if dims: + dx = dims[0] / 2 - (min_x + max_x) / 2 + dy = dims[1] / 2 - (min_y + max_y) / 2 + for wp in layer.all_workpieces: + wp.pos = (wp.pos[0] + dx, wp.pos[1] + dy) + doc = self._editor.doc + for asset in self._pending_assets: + doc.add_asset(asset) + self._pending_assets = [] + with self._editor.history_manager.transaction(undo_name) as t: + t.execute( + AddLayerAndSetActiveCommand( + self._editor, layer, name=undo_name + ) + ) + logger.info("Created %s", layer.name) diff --git a/tests/doceditor/data/calibration_tests/focus_manual.gcode b/tests/doceditor/data/calibration_tests/focus_manual.gcode new file mode 100644 index 000000000..d3416af16 --- /dev/null +++ b/tests/doceditor/data/calibration_tests/focus_manual.gcode @@ -0,0 +1,21 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +M0 +G0 X197 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +M0 +G0 X200 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +M0 +G0 X203 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/doceditor/data/calibration_tests/focus_z_axis.gcode b/tests/doceditor/data/calibration_tests/focus_z_axis.gcode new file mode 100644 index 000000000..67146b520 --- /dev/null +++ b/tests/doceditor/data/calibration_tests/focus_z_axis.gcode @@ -0,0 +1,30 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G91 +G0 Z-0.5 +G90 +G0 X197 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +G91 +G0 Z0.5 +G90 +G0 X200 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +G91 +G0 Z0.5 +G90 +G0 X203 Y197.5 +M4 S200 +G1 Y202.5 F1200 +M5 +G91 +G0 Z-0.5 +G90 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/doceditor/data/calibration_tests/interval_test.gcode b/tests/doceditor/data/calibration_tests/interval_test.gcode new file mode 100644 index 000000000..011bc989f --- /dev/null +++ b/tests/doceditor/data/calibration_tests/interval_test.gcode @@ -0,0 +1,78 @@ +G21 ;Set units to mm +G90 ;Absolute positioning +G54 +T0 +G0 X194.682 Y202 +G1 X195.031 F2000 +M4 S400 +G1 X198.969 F2000 +M5 +G1 X199.318 F2000 +G0 Y201.5 +G1 X198.969 F2000 +M4 S400 +G1 X195.031 F2000 +M5 +G1 X194.682 F2000 +G0 Y201 +G1 X195.031 F2000 +M4 S400 +G1 X198.969 F2000 +M5 +G1 X199.318 F2000 +G0 Y200.5 +G1 X198.969 F2000 +M4 S400 +G1 X195.031 F2000 +M5 +G1 X194.682 F2000 +G0 Y200 +G1 X195.031 F2000 +M4 S400 +G1 X198.969 F2000 +M5 +G1 X199.318 F2000 +G0 Y199.5 +G1 X198.969 F2000 +M4 S400 +G1 X195.031 F2000 +M5 +G1 X194.682 F2000 +G0 Y199 +G1 X195.031 F2000 +M4 S400 +G1 X198.969 F2000 +M5 +G1 X199.318 F2000 +G0 Y198.5 +G1 X198.969 F2000 +M4 S400 +G1 X195.031 F2000 +M5 +G1 X194.682 F2000 +G0 X200.682 Y202 +G1 X201.031 F2000 +M4 S400 +G1 X204.969 F2000 +M5 +G1 X205.318 F2000 +G0 Y201 +G1 X204.969 F2000 +M4 S400 +G1 X201.031 F2000 +M5 +G1 X200.682 F2000 +G0 Y200 +G1 X201.031 F2000 +M4 S400 +G1 X204.969 F2000 +M5 +G1 X205.318 F2000 +G0 Y199 +G1 X204.969 F2000 +M4 S400 +G1 X201.031 F2000 +M5 +G1 X200.682 F2000 +M5 ;Ensure laser is off +G0 X0 Y0 ;Return to origin diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py new file mode 100644 index 000000000..38ec08151 --- /dev/null +++ b/tests/doceditor/test_calibration_tests.py @@ -0,0 +1,251 @@ +""" +End-to-end tests for the Interval Test and Focus Test generators: +the generated layer, its steps, and the G-code it produces on GRBL. +""" + +import importlib +import re +from itertools import pairwise +from pathlib import Path + +import pytest + +from rayforge.context import get_context +from rayforge.core.workpiece import WorkPiece +from rayforge.machine.models.machine import Origin +from rayforge.pipeline.artifact import JobArtifact + +DATA = Path(__file__).parent / "data" / "calibration_tests" + +# The laser addon is importable through the sys.path setup of the root +# conftest; static analysis does not see that path. +_calibration = importlib.import_module("laser_essentials.calibration_tests") +_command = importlib.import_module( + "laser_essentials.commands.calibration_test_cmd" +) +FocusMode = _calibration.FocusMode +FocusTestParams = _calibration.FocusTestParams +IntervalTestParams = _calibration.IntervalTestParams +CalibrationTestCmd = _command.CalibrationTestCmd + + +@pytest.fixture +def machine(context_initializer): + machine = get_context().machine + assert machine is not None + machine.set_dialect_uid("grbl") + machine.set_origin(Origin.BOTTOM_LEFT) + machine.set_axis_extents(400, 400) + return machine + + +async def _gcode(editor) -> str: + await editor.wait_until_settled() + pipeline = editor.pipeline + handle = await pipeline.generate_job_artifact_async() + assert handle is not None + with pipeline.artifact_store.checkout_handle(handle) as artifact: + assert isinstance(artifact, JobArtifact) + assert artifact.machine_code is not None + text = artifact.machine_code + await editor.wait_until_settled() + return text + + +def _assert_golden(gcode: str, name: str): + expected = (DATA / name).read_text(encoding="utf-8") + assert gcode.strip().splitlines() == expected.strip().splitlines() + + +def _steps(layer): + assert layer.workflow is not None + return list(layer.workflow.steps) + + +def _cut_moves(gcode: str) -> list[tuple[float, float]]: + """End points of all G1 moves, tracking modal X/Y.""" + x = y = 0.0 + points = [] + for line in gcode.splitlines(): + code = line.split(";")[0] + words = dict(re.findall(r"([GXY])([-+]?\d*\.?\d+)", code)) + if "X" in words: + x = float(words["X"]) + if "Y" in words: + y = float(words["Y"]) + if words.get("G") == "1": + points.append((x, y)) + return points + + +# ---------------------------------------------------------------------- +# Interval Test +# ---------------------------------------------------------------------- + + +INTERVAL_PARAMS = IntervalTestParams( + min_interval=0.5, + max_interval=1.0, + count=2, + cell_size=4.0, + spacing=2.0, + power_percent=40.0, + speed=2000.0, + include_labels=False, +) + + +@pytest.mark.asyncio +async def test_interval_test_layer(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + params = IntervalTestParams(count=3, include_labels=True) + layer = cmd.create_interval_test(params) + + assert layer in doc_editor.doc.layers + steps = _steps(layer) + # One label step, then one engrave step per cell. + assert [s.typelabel for s in steps] == [ + "Contour", + "Engrave", + "Engrave", + "Engrave", + ] + workpieces = {wp.uid: wp for wp in layer.all_workpieces} + assert len(workpieces) == 4 + for step in steps: + assert step.generated_workpiece_uid in workpieces + intervals = [s.line_interval_mm for s in steps[1:]] + assert intervals == pytest.approx([0.05, 0.15, 0.25]) + assert [s.power for s in steps[1:]] == pytest.approx([0.3] * 3) + assert steps[0].power == pytest.approx(0.1) + + +@pytest.mark.asyncio +async def test_interval_test_is_centered_and_undoable(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + layer = cmd.create_interval_test(IntervalTestParams(count=4)) + rects = [ + wp.get_world_geometry().rect() # type: ignore[union-attr] + for wp in layer.all_workpieces + ] + center_x = (min(r[0] for r in rects) + max(r[2] for r in rects)) / 2 + center_y = (min(r[1] for r in rects) + max(r[3] for r in rects)) / 2 + assert (center_x, center_y) == pytest.approx((200.0, 200.0), abs=0.01) + + doc_editor.history_manager.undo() + assert layer not in doc_editor.doc.layers + + +@pytest.mark.asyncio +async def test_interval_test_scan_line_pitch(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + layer = cmd.create_interval_test(INTERVAL_PARAMS) + cells = sorted( + (wp for wp in layer.all_workpieces if isinstance(wp, WorkPiece)), + key=lambda wp: wp.pos[0], + ) + gcode = await _gcode(doc_editor) + + for cell, interval in zip(cells, (0.5, 1.0)): + x0, y0, x1, y1 = cell.get_world_geometry().rect() # type: ignore + ys = sorted( + { + round(y, 3) + for x, y in _cut_moves(gcode) + if x0 - 0.01 <= x <= x1 + 0.01 and y0 <= y <= y1 + } + ) + assert len(ys) >= 3 + pitches = [b - a for a, b in pairwise(ys)] + assert pitches == pytest.approx([interval] * len(pitches), abs=0.01) + # Every cell is filled at the same, constant power (40 % of S1000). + powers = set(re.findall(r"^M4 S(\d+)$", gcode, re.MULTILINE)) + assert powers == {"400"} + _assert_golden(gcode, "interval_test.gcode") + + +# ---------------------------------------------------------------------- +# Focus Test +# ---------------------------------------------------------------------- + + +def _focus(mode, **kwargs) -> FocusTestParams: + values = { + "mode": mode, + "start_offset": -0.5, + "step": 0.5, + "count": 3, + "line_length": 5.0, + "spacing": 3.0, + "power_percent": 20.0, + "speed": 1200.0, + "include_labels": False, + } + values.update(kwargs) + return FocusTestParams(**values) + + +@pytest.mark.asyncio +async def test_focus_modes_without_z_axis(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + modes = cmd.available_focus_modes() + assert FocusMode.MANUAL in modes + assert FocusMode.RAMP in modes + if not machine.has_z_axis: + assert FocusMode.Z_AXIS not in modes + with pytest.raises(ValueError): + cmd.create_focus_test(_focus(FocusMode.Z_AXIS)) + + +@pytest.mark.asyncio +async def test_focus_manual_pauses_between_lines(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + layer = cmd.create_focus_test(_focus(FocusMode.MANUAL)) + names = [s.name for s in _steps(layer)] + assert len(names) == 6 + assert names[0].startswith("Pause") + assert names[1] == "Focus -0.5 mm" + assert names[3] == "Focus 0.0 mm" + assert names[5] == "Focus +0.5 mm" + + gcode = await _gcode(doc_editor) + lines = [line.strip() for line in gcode.splitlines()] + assert lines.count("M0") == 3 + # Every pause comes after the laser was switched off. + for index, line in enumerate(lines): + if line == "M0": + previous = [p for p in lines[:index] if p.startswith("M")] + assert not previous or previous[-1].startswith("M5") + _assert_golden(gcode, "focus_manual.gcode") + + +@pytest.mark.asyncio +async def test_focus_z_axis_moves_and_returns(doc_editor, machine, mocker): + mocker.patch.object( + type(machine), "has_z_axis", new_callable=mocker.PropertyMock + ).return_value = True + cmd = CalibrationTestCmd(doc_editor) + assert FocusMode.Z_AXIS in cmd.available_focus_modes() + cmd.create_focus_test(_focus(FocusMode.Z_AXIS)) + + gcode = await _gcode(doc_editor) + z_moves = [ + float(m.group(1)) + for m in re.finditer(r"^G0 Z([-+]?\d*\.?\d+)$", gcode, re.MULTILINE) + ] + assert z_moves == pytest.approx([-0.5, 0.5, 0.5, -0.5]) + assert sum(z_moves) == pytest.approx(0.0) + assert gcode.count("G91") == 4 + _assert_golden(gcode, "focus_z_axis.gcode") + + +@pytest.mark.asyncio +async def test_focus_ramp_has_no_commands(doc_editor, machine): + cmd = CalibrationTestCmd(doc_editor) + layer = cmd.create_focus_test( + _focus(FocusMode.RAMP, ramp_length=30.0, include_labels=True) + ) + assert [s.typelabel for s in _steps(layer)] == ["Contour", "Contour"] + gcode = await _gcode(doc_editor) + assert "M0" not in gcode + assert "G91" not in gcode From 72bf7f644b29bf51e56d2a0d5253a4339c075678 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:20:48 +0200 Subject: [PATCH 03/28] feat(laser): add Interval Test and Focus Test to the Tools menu Each entry opens a dialog for the settings, with unit-aware lengths and speeds capped at the machine's maximum. The Focus Test dialog only offers the methods the machine supports and explains the procedure for the chosen one. Refs #519 --- .../laser_essentials/frontend.py | 55 ++- .../widgets/calibration_test_dialogs.py | 327 ++++++++++++++++++ .../ui_gtk/test_calibration_test_dialogs.py | 66 ++++ 3 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py index 2cf40a2ec..b5c59b636 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -13,8 +13,12 @@ from rayforge.ui_gtk.action_registry import MenuPlacement from rayforge.ui_gtk.icons import register_icon_path -from .commands import MaterialTestCmd +from .commands import CalibrationTestCmd, MaterialTestCmd from .widgets import ASSEMBLER_WIDGETS +from .widgets.calibration_test_dialogs import ( + FocusTestDialog, + IntervalTestDialog, +) ADDON_NAME = "laser_essentials" _ICONS_DIR = Path(__file__).parent / "resources" / "icons" @@ -35,6 +39,9 @@ def register_step_settings_pages(step_settings_page_registry): def register_commands(command_registry): """Register editor command handlers.""" command_registry.register("material_test", MaterialTestCmd, ADDON_NAME) + command_registry.register( + "calibration_tests", CalibrationTestCmd, ADDON_NAME + ) @hookimpl @@ -55,3 +62,49 @@ def on_activate(action, param): label=_("Create Material Test Grid"), menu=MenuPlacement(menu_id="tools", priority=100), ) + + def _max_speed(window) -> float: + machine = window.doc_editor.context.machine + return float(machine.max_cut_speed) if machine else 60000.0 + + interval_action = Gio.SimpleAction.new("interval_test", None) + + def on_interval_test(action, param): + window = action_registry.window + editor = window.doc_editor + IntervalTestDialog( + window, + editor.calibration_tests.create_interval_test, + max_speed=_max_speed(window), + ).present() + + interval_action.connect("activate", on_interval_test) + action_registry.register( + action_name="interval_test", + action=interval_action, + addon_name=ADDON_NAME, + label=_("Create Interval Test"), + menu=MenuPlacement(menu_id="tools", priority=101), + ) + + focus_action = Gio.SimpleAction.new("focus_test", None) + + def on_focus_test(action, param): + window = action_registry.window + editor = window.doc_editor + cmd = editor.calibration_tests + FocusTestDialog( + window, + cmd.create_focus_test, + cmd.available_focus_modes(), + max_speed=_max_speed(window), + ).present() + + focus_action.connect("activate", on_focus_test) + action_registry.register( + action_name="focus_test", + action=focus_action, + addon_name=ADDON_NAME, + label=_("Create Focus Test"), + menu=MenuPlacement(menu_id="tools", priority=102), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py new file mode 100644 index 000000000..c31b74b76 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py @@ -0,0 +1,327 @@ +"""Dialogs that create an Interval Test or a Focus Test.""" + +from collections.abc import Callable +from gettext import gettext as _ +from typing import Any + +from gi.repository import Adw, Gtk + +from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow +from rayforge.ui_gtk.shared.pref_rows import ( + LengthSpinRow, + SpeedSpinRow, + SpinRow, +) + +from ..calibration_tests import ( + MAX_FOCUS_OFFSET_MM, + FocusMode, + FocusTestParams, + IntervalTestParams, +) + +_FOCUS_HELP = { + FocusMode.Z_AXIS: _( + "Focus the laser as usual first. The job moves the head to the " + "first offset, engraves one line per offset and returns the head " + "to the starting height. A positive offset means more distance " + "between the head and the material." + ), + FocusMode.MANUAL: _( + "Focus the laser as usual first. The labels are engraved at that " + "height. Before each line the job pauses: set the head to the " + "first offset, then move it by one step at every following " + "pause, and press Resume. A positive offset means more distance " + "between the head and the material." + ), + FocusMode.RAMP: _( + "Prop up one end of a flat strip so it rises evenly under the " + "line. The thinnest part of the line marks the focus; the ticks " + "show the distance along the line, so the height there follows " + "from the rise." + ), +} + + +class _CalibrationTestDialog(PatchedDialogWindow): + """Shared frame: a header with Cancel/Create and one page.""" + + def __init__( + self, + parent: Gtk.Window | None, + title: str, + on_create: Callable[[Any], object], + ): + super().__init__(transient_for=parent, modal=True) + self.set_title(title) + self.set_default_size(520, 640) + self._on_create = on_create + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + self.set_content(toolbar) + + cancel = Gtk.Button(label=_("Cancel")) + cancel.connect("clicked", lambda _w: self.close()) + header.pack_start(cancel) + self.create_button = Gtk.Button(label=_("Create")) + self.create_button.add_css_class("suggested-action") + self.create_button.connect("clicked", self._on_create_clicked) + header.pack_end(self.create_button) + + self.banner = Adw.Banner() + toolbar.add_top_bar(self.banner) + self.page = Adw.PreferencesPage() + toolbar.set_content(self.page) + + def params(self) -> object: + raise NotImplementedError + + def _on_create_clicked(self, _button): + try: + self._on_create(self.params()) + except ValueError as e: + self.banner.set_title(str(e)) + self.banner.set_revealed(True) + return + self.close() + + @staticmethod + def _power_row(title: str, value: float) -> SpinRow: + return SpinRow( + title, + _("Percent of the maximum power"), + lower=0.1, + upper=100.0, + step_increment=1.0, + digits=1, + value=value, + ) + + def _add_label_group(self, defaults) -> None: + group = Adw.PreferencesGroup(title=_("Labels")) + self.labels_row = Adw.SwitchRow(title=_("Include Labels")) + self.labels_row.set_active(defaults.include_labels) + group.add(self.labels_row) + self.label_power_row = self._power_row( + _("Label Power"), defaults.label_power_percent + ) + group.add(self.label_power_row) + self.label_speed_row = SpeedSpinRow( + _("Label Speed"), + lower=1, + upper=60000, + value_in_base=defaults.label_speed, + ) + group.add(self.label_speed_row) + self.labels_row.connect( + "notify::active", lambda *_a: self._sync_label_rows() + ) + self._sync_label_rows() + self.page.add(group) + + def _sync_label_rows(self): + active = self.labels_row.get_active() + self.label_power_row.set_sensitive(active) + self.label_speed_row.set_sensitive(active) + + +class IntervalTestDialog(_CalibrationTestDialog): + """Asks for the settings of an Interval Test.""" + + def __init__( + self, + parent: Gtk.Window | None, + on_create: Callable[[IntervalTestParams], object], + max_speed: float = 60000.0, + ): + super().__init__(parent, _("Create Interval Test"), on_create) + d = IntervalTestParams() + group = Adw.PreferencesGroup( + title=_("Cells"), + description=_( + "Engraves a row of filled squares, each with its own line " + "interval, at the same power and speed." + ), + ) + self.count_row = SpinRow( + _("Number of Cells"), lower=2, upper=20, value=d.count + ) + self.min_row = LengthSpinRow( + _("Smallest Interval"), + lower=0.01, + upper=5.0, + step_increment=0.01, + digits=3, + value_in_base=d.min_interval, + ) + self.max_row = LengthSpinRow( + _("Largest Interval"), + lower=0.01, + upper=5.0, + step_increment=0.01, + digits=3, + value_in_base=d.max_interval, + ) + self.size_row = LengthSpinRow( + _("Cell Size"), + lower=1.0, + upper=100.0, + value_in_base=d.cell_size, + ) + self.spacing_row = LengthSpinRow( + _("Spacing"), lower=0.0, upper=50.0, value_in_base=d.spacing + ) + for row in ( + self.count_row, + self.min_row, + self.max_row, + self.size_row, + self.spacing_row, + ): + group.add(row) + self.page.add(group) + + laser = Adw.PreferencesGroup(title=_("Laser")) + self.power_row = self._power_row(_("Power"), d.power_percent) + self.speed_row = SpeedSpinRow( + _("Speed"), + lower=1, + upper=max_speed, + value_in_base=min(d.speed, max_speed), + ) + laser.add(self.power_row) + laser.add(self.speed_row) + self.page.add(laser) + self._add_label_group(d) + + def params(self) -> IntervalTestParams: + return IntervalTestParams( + min_interval=self.min_row.get_value_in_base_units(), + max_interval=self.max_row.get_value_in_base_units(), + count=self.count_row.get_int_value(), + cell_size=self.size_row.get_value_in_base_units(), + spacing=self.spacing_row.get_value_in_base_units(), + power_percent=self.power_row.get_value(), + speed=self.speed_row.get_value_in_base_units(), + include_labels=self.labels_row.get_active(), + label_power_percent=self.label_power_row.get_value(), + label_speed=self.label_speed_row.get_value_in_base_units(), + ) + + +class FocusTestDialog(_CalibrationTestDialog): + """Asks for the settings of a Focus Test.""" + + def __init__( + self, + parent: Gtk.Window | None, + on_create: Callable[[FocusTestParams], object], + modes: list[FocusMode], + max_speed: float = 60000.0, + ): + super().__init__(parent, _("Create Focus Test"), on_create) + d = FocusTestParams() + self._modes = modes + group = Adw.PreferencesGroup(title=_("Focus Steps")) + self.steps_group = group + self.mode_row = Adw.ComboRow( + title=_("Method"), + model=Gtk.StringList.new([m.label for m in modes]), + ) + if d.mode in modes: + self.mode_row.set_selected(modes.index(d.mode)) + self.mode_row.connect("notify::selected", lambda *_a: self._sync()) + group.add(self.mode_row) + + limit = MAX_FOCUS_OFFSET_MM + self.start_row = LengthSpinRow( + _("First Offset"), + lower=-limit, + upper=limit, + step_increment=0.1, + digits=2, + value_in_base=d.start_offset, + ) + self.step_row = LengthSpinRow( + _("Step"), + lower=-limit, + upper=limit, + step_increment=0.1, + digits=2, + value_in_base=d.step, + ) + self.count_row = SpinRow( + _("Number of Lines"), lower=2, upper=41, value=d.count + ) + self.length_row = LengthSpinRow( + _("Line Length"), + lower=1.0, + upper=200.0, + value_in_base=d.line_length, + ) + self.spacing_row = LengthSpinRow( + _("Spacing"), lower=0.5, upper=50.0, value_in_base=d.spacing + ) + self.ramp_row = LengthSpinRow( + _("Ramp Length"), + lower=10.0, + upper=1000.0, + value_in_base=d.ramp_length, + ) + self._step_rows = ( + self.start_row, + self.step_row, + self.count_row, + self.length_row, + self.spacing_row, + ) + for row in (*self._step_rows, self.ramp_row): + group.add(row) + self.page.add(group) + + laser = Adw.PreferencesGroup(title=_("Laser")) + self.power_row = self._power_row(_("Power"), d.power_percent) + self.speed_row = SpeedSpinRow( + _("Speed"), + lower=1, + upper=max_speed, + value_in_base=min(d.speed, max_speed), + ) + laser.add(self.power_row) + laser.add(self.speed_row) + self.page.add(laser) + self._add_label_group(d) + self._sync() + + @property + def mode(self) -> FocusMode: + index = self.mode_row.get_selected() + if 0 <= index < len(self._modes): + return self._modes[index] + return FocusMode.RAMP + + def _sync(self): + mode = self.mode + self.steps_group.set_description(_FOCUS_HELP[mode]) + is_ramp = mode == FocusMode.RAMP + for row in self._step_rows: + row.set_visible(not is_ramp) + self.ramp_row.set_visible(is_ramp) + + def params(self) -> FocusTestParams: + return FocusTestParams( + mode=self.mode, + start_offset=self.start_row.get_value_in_base_units(), + step=self.step_row.get_value_in_base_units(), + count=self.count_row.get_int_value(), + line_length=self.length_row.get_value_in_base_units(), + spacing=self.spacing_row.get_value_in_base_units(), + ramp_length=self.ramp_row.get_value_in_base_units(), + power_percent=self.power_row.get_value(), + speed=self.speed_row.get_value_in_base_units(), + include_labels=self.labels_row.get_active(), + label_power_percent=self.label_power_row.get_value(), + label_speed=self.label_speed_row.get_value_in_base_units(), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py new file mode 100644 index 000000000..ee077f7cb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py @@ -0,0 +1,66 @@ +# flake8: noqa: E402 +"""UI tests for the Interval Test and Focus Test dialogs.""" + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from laser_essentials.calibration_tests import ( + FocusMode, + FocusTestParams, + IntervalTestParams, +) +from laser_essentials.widgets.calibration_test_dialogs import ( + FocusTestDialog, + IntervalTestDialog, +) + + +@pytest.mark.ui +def test_interval_dialog_defaults_and_create(ui_context): + created: list[IntervalTestParams] = [] + dialog = IntervalTestDialog(None, created.append, max_speed=3000) + assert dialog.params() == IntervalTestParams() + + dialog.count_row._spin_button.set_value(7) + dialog.create_button.emit("clicked") + assert len(created) == 1 + assert created[0].count == 7 + + +@pytest.mark.ui +def test_interval_dialog_shows_errors(ui_context): + def refuse(params): + raise ValueError("bad range") + + dialog = IntervalTestDialog(None, refuse) + dialog.create_button.emit("clicked") + assert dialog.banner.get_revealed() + assert dialog.banner.get_title() == "bad range" + + +@pytest.mark.ui +def test_focus_dialog_modes(ui_context): + created: list[FocusTestParams] = [] + dialog = FocusTestDialog( + None, created.append, [FocusMode.MANUAL, FocusMode.RAMP] + ) + assert dialog.mode == FocusMode.MANUAL + assert dialog.step_row.get_visible() + assert not dialog.ramp_row.get_visible() + + dialog.mode_row.set_selected(1) + assert dialog.mode == FocusMode.RAMP + assert not dialog.step_row.get_visible() + assert dialog.ramp_row.get_visible() + + dialog.create_button.emit("clicked") + assert created[0].mode == FocusMode.RAMP + + +@pytest.mark.ui +def test_focus_dialog_without_manual_mode(ui_context): + dialog = FocusTestDialog(None, lambda p: None, [FocusMode.RAMP]) + assert dialog.mode == FocusMode.RAMP From 6a7554c4dccbb245d552d4368253db03f433d5f8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 9 Oct 2026 13:20:48 +0200 Subject: [PATCH 04/28] docs: document the Interval and Focus Tests Refs #519 --- CHANGELOG.md | 7 +++ .../operations/interval-and-focus-tests.md | 47 +++++++++++++++++ .../operations/interval-and-focus-tests.md | 51 +++++++++++++++++++ .../operations/interval-and-focus-tests.md | 49 ++++++++++++++++++ .../operations/interval-and-focus-tests.md | 50 ++++++++++++++++++ .../operations/interval-and-focus-tests.md | 42 +++++++++++++++ .../operations/interval-and-focus-tests.md | 49 ++++++++++++++++++ .../operations/interval-and-focus-tests.md | 46 +++++++++++++++++ .../operations/interval-and-focus-tests.md | 29 +++++++++++ website/sidebars.js | 1 + 10 files changed, 371 insertions(+) create mode 100644 website/docs/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..ecba21bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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) +- Tools: Interval Test and Focus Test generators next to the Material + Test Grid. The Interval Test engraves a row of squares, each with its + own line interval (labelled in mm and LPI); the Focus Test engraves + one line per focus offset, either with relative Z moves (machines + with a Z axis), with an M0 pause before each line to move the head + by hand, or as one long line over a tilted strip. Both are created as + an editable layer with one operation per cell or line (#519) - 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 diff --git a/website/docs/features/operations/interval-and-focus-tests.md b/website/docs/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..15dad41a8 --- /dev/null +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -0,0 +1,47 @@ +--- +description: + "Find the best line interval for engraving and the focal point of your laser with the Interval + Test and the Focus Test." +--- + +# Interval and Focus Tests + +Next to the [Material Test Grid](material-test-grid.md), the Tools menu offers two more calibration +jobs. Both are created as an ordinary layer with workpieces and operations: you can move it, frame +it and preview it like any other content, and change the settings of each cell or line afterwards +in its operation. + +## Interval Test + +**Tools → Create Interval Test** engraves a row of filled squares. Every square has its own +**Engrave** operation with its own line interval, spread evenly from the smallest to the largest +interval you enter, while power and speed stay the same for all of them. The labels under each +square show the interval in millimeters and the matching line density in lines per inch (LPI). + +Pick the square that is filled evenly without visible lines and without burning too deep, and use +its interval for engravings on that material. The labels are cut with a separate low-power +operation before the squares. + +## Focus Test + +**Tools → Create Focus Test** finds the height at which the beam is sharpest. A positive offset +means more distance between the head and the material. The thinnest line marks the best focus. + +| Method | How the height changes | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **Z axis steps** | The head moves to each offset with relative Z moves and returns to its starting height at the end | +| **Manual (pause between lines)** | The job pauses (`M0`) before each line; you move the head by hand and press Resume | +| **Ramp (tilted material)** | One long line with distance ticks; you prop up one end of a flat strip so it rises under the line | + +Z axis steps are only offered on machines with a Z axis, and both Z axis steps and the manual method +need a G-code controller, because they use [Command](command.md) operations between the lines. The +offsets are limited to ±10 mm from the starting height. + +For the manual method, focus the laser as usual first. The labels are engraved at that height. +At the first pause, set the head to the first offset; at every following pause, move it by one +step. Check that your controller stops on `M0` and that the Resume button continues the job before +you rely on it, for example with a dry run at 0% power. + +For the ramp, the height under any point of the line follows from the rise of the strip: at a +distance _d_ along a line of length _L_ on a strip that rises _h_, the material is _h_ × _d_ / _L_ +higher than at the start. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..90b96adee --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,51 @@ +--- +description: + "Finde mit dem Intervalltest und dem Fokustest den besten Linienabstand zum Gravieren und den + Brennpunkt deines Lasers." +--- + +# Intervall- und Fokustests + +Neben dem [Materialtest-Raster](material-test-grid.md) bietet das Menü Werkzeuge zwei weitere +Kalibrierungsjobs. Beide werden als normale Ebene mit Werkstücken und Operationen angelegt: Du kannst +sie verschieben, umranden und in der Vorschau ansehen wie jeden anderen Inhalt und die Einstellungen +jedes Feldes oder jeder Linie danach in ihrer Operation ändern. + +## Intervalltest + +**Werkzeuge → Intervalltest erstellen** graviert eine Reihe gefüllter Quadrate. Jedes Quadrat hat +eine eigene **Gravieren**-Operation mit eigenem Linienabstand, gleichmäßig verteilt vom kleinsten bis +zum größten eingegebenen Abstand, während Leistung und Geschwindigkeit für alle gleich bleiben. Die +Beschriftungen unter jedem Quadrat zeigen den Abstand in Millimetern und die passende Liniendichte in +Linien pro Zoll (LPI). + +Wähle das Quadrat, das gleichmäßig ohne sichtbare Linien und ohne zu tiefes Einbrennen gefüllt ist, +und verwende seinen Abstand für Gravuren auf diesem Material. Die Beschriftungen werden vor den +Quadraten mit einer eigenen Operation mit geringer Leistung geschnitten. + +## Fokustest + +**Werkzeuge → Fokustest erstellen** findet die Höhe, in der der Strahl am schärfsten ist. Ein +positiver Versatz bedeutet mehr Abstand zwischen Kopf und Material. Die dünnste Linie markiert den +besten Fokus. + +| Methode | Wie sich die Höhe ändert | +| ----------------------------------- | ------------------------------------------------------------------------------------------------ | +| **Z-Achsen-Schritte** | Der Kopf fährt mit relativen Z-Bewegungen zu jedem Versatz und am Ende zurück zur Ausgangshöhe | +| **Manuell (Pause zwischen Linien)** | Der Job pausiert (`M0`) vor jeder Linie; du verschiebst den Kopf von Hand und drückst Fortsetzen | +| **Rampe (schräges Material)** | Eine lange Linie mit Abstandsstrichen; du stützt ein Ende eines flachen Streifens ab | + +Z-Achsen-Schritte werden nur bei Maschinen mit Z-Achse angeboten, und sowohl Z-Achsen-Schritte als +auch die manuelle Methode benötigen einen G-Code-Controller, weil sie zwischen den Linien +[Befehls](command.md)-Operationen verwenden. Die Versätze sind auf ±10 mm von der Ausgangshöhe +begrenzt. + +Fokussiere den Laser bei der manuellen Methode zuerst wie gewohnt. Die Beschriftungen werden in +dieser Höhe graviert. Stelle den Kopf bei der ersten Pause auf den ersten Versatz und verschiebe ihn +bei jeder weiteren Pause um einen Schritt. Prüfe, ob dein Controller bei `M0` anhält und die +Fortsetzen-Schaltfläche den Job weiterlaufen lässt, bevor du dich darauf verlässt, zum Beispiel mit +einem Probelauf bei 0 % Leistung. + +Bei der Rampe ergibt sich die Höhe unter jedem Punkt der Linie aus dem Anstieg des Streifens: Im +Abstand _d_ entlang einer Linie der Länge _L_ auf einem Streifen, der um _h_ ansteigt, liegt das +Material _h_ × _d_ / _L_ höher als am Anfang. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..23e30d104 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,49 @@ +--- +description: + "Encuentra el mejor intervalo de línea para grabar y el punto focal de tu láser con la prueba de + intervalo y la prueba de enfoque." +--- + +# Pruebas de intervalo y de enfoque + +Junto a la [cuadrícula de prueba de material](material-test-grid.md), el menú Herramientas ofrece +dos trabajos de calibración más. Ambos se crean como una capa normal con piezas y operaciones: puedes +moverla, encuadrarla y previsualizarla como cualquier otro contenido, y cambiar después los ajustes +de cada celda o línea en su operación. + +## Prueba de intervalo + +**Herramientas → Crear prueba de intervalo** graba una fila de cuadrados rellenos. Cada cuadrado +tiene su propia operación **Grabar** con su propio intervalo de línea, repartido de forma uniforme +entre el intervalo menor y el mayor que indiques, mientras la potencia y la velocidad son las mismas +para todos. Las etiquetas bajo cada cuadrado muestran el intervalo en milímetros y la densidad de +líneas correspondiente en líneas por pulgada (LPI). + +Elige el cuadrado que esté relleno de forma uniforme, sin líneas visibles y sin quemarse demasiado, +y usa su intervalo para grabar en ese material. Las etiquetas se cortan antes que los cuadrados con +una operación propia de baja potencia. + +## Prueba de enfoque + +**Herramientas → Crear prueba de enfoque** encuentra la altura a la que el haz es más nítido. Un +desplazamiento positivo significa más distancia entre el cabezal y el material. La línea más fina +marca el mejor enfoque. + +| Método | Cómo cambia la altura | +| --------------------------------- | -------------------------------------------------------------------------------------------------- | +| **Pasos del eje Z** | El cabezal va a cada desplazamiento con movimientos Z relativos y vuelve a la altura inicial | +| **Manual (pausa entre líneas)** | El trabajo se pausa (`M0`) antes de cada línea; mueves el cabezal a mano y pulsas Reanudar | +| **Rampa (material inclinado)** | Una línea larga con marcas de distancia; levantas un extremo de una tira plana | + +Los pasos del eje Z solo se ofrecen en máquinas con eje Z, y tanto los pasos del eje Z como el +método manual necesitan un controlador G-code, porque usan operaciones de [Comando](command.md) +entre las líneas. Los desplazamientos están limitados a ±10 mm de la altura inicial. + +Con el método manual, enfoca primero el láser como de costumbre. Las etiquetas se graban a esa +altura. En la primera pausa, coloca el cabezal en el primer desplazamiento; en cada pausa siguiente, +muévelo un paso. Comprueba que tu controlador se detiene con `M0` y que el botón Reanudar continúa +el trabajo antes de confiar en ello, por ejemplo con una prueba en seco al 0 % de potencia. + +En la rampa, la altura bajo cualquier punto de la línea se deduce de la subida de la tira: a una +distancia _d_ a lo largo de una línea de longitud _L_ sobre una tira que sube _h_, el material está +_h_ × _d_ / _L_ más alto que al principio. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..f257fa174 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,50 @@ +--- +description: + "Trouvez le meilleur intervalle de ligne pour la gravure et le point focal de votre laser avec le + test d'intervalle et le test de mise au point." +--- + +# Tests d'intervalle et de mise au point + +À côté de la [grille de test de matériau](material-test-grid.md), le menu Outils propose deux autres +tâches de calibrage. Les deux sont créées comme un calque ordinaire avec des pièces et des +opérations : vous pouvez le déplacer, le cadrer et le prévisualiser comme tout autre contenu, puis +modifier les réglages de chaque cellule ou ligne dans son opération. + +## Test d'intervalle + +**Outils → Créer un test d'intervalle** grave une rangée de carrés remplis. Chaque carré a sa propre +opération **Graver** avec son propre intervalle de ligne, réparti régulièrement du plus petit au plus +grand intervalle saisi, tandis que la puissance et la vitesse restent les mêmes pour tous. Les +étiquettes sous chaque carré indiquent l'intervalle en millimètres et la densité de lignes +correspondante en lignes par pouce (LPI). + +Choisissez le carré rempli de façon homogène, sans lignes visibles et sans brûlure trop profonde, et +utilisez son intervalle pour les gravures sur ce matériau. Les étiquettes sont découpées avant les +carrés avec une opération séparée à faible puissance. + +## Test de mise au point + +**Outils → Créer un test de mise au point** trouve la hauteur à laquelle le faisceau est le plus +net. Un décalage positif signifie plus de distance entre la tête et le matériau. La ligne la plus +fine indique la meilleure mise au point. + +| Méthode | Comment la hauteur change | +| -------------------------------------- | ----------------------------------------------------------------------------------------------- | +| **Pas de l'axe Z** | La tête rejoint chaque décalage par des mouvements Z relatifs et revient à la hauteur de départ | +| **Manuel (pause entre les lignes)** | La tâche se met en pause (`M0`) avant chaque ligne ; vous déplacez la tête et appuyez Reprendre | +| **Rampe (matériau incliné)** | Une longue ligne graduée ; vous surélevez une extrémité d'une bande plate | + +Les pas de l'axe Z ne sont proposés que sur les machines dotées d'un axe Z, et les pas de l'axe Z +comme la méthode manuelle nécessitent un contrôleur G-code, car ils utilisent des opérations +[Commande](command.md) entre les lignes. Les décalages sont limités à ±10 mm de la hauteur de départ. + +Avec la méthode manuelle, faites d'abord la mise au point du laser comme d'habitude. Les étiquettes +sont gravées à cette hauteur. À la première pause, placez la tête au premier décalage ; à chaque +pause suivante, déplacez-la d'un pas. Vérifiez que votre contrôleur s'arrête sur `M0` et que le +bouton Reprendre poursuit la tâche avant de vous y fier, par exemple avec un essai à vide à 0 % de +puissance. + +Pour la rampe, la hauteur sous chaque point de la ligne découle de la pente de la bande : à une +distance _d_ le long d'une ligne de longueur _L_ sur une bande qui monte de _h_, le matériau est +_h_ × _d_ / _L_ plus haut qu'au départ. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..0e008377b --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,42 @@ +--- +description: + "इंटरवल टेस्ट और फ़ोकस टेस्ट से उकेरने के लिए सबसे अच्छा रेखा अंतराल और अपने लेज़र का फ़ोकल बिंदु खोजें।" +--- + +# इंटरवल और फ़ोकस टेस्ट + +[मटेरियल टेस्ट ग्रिड](material-test-grid.md) के अलावा, टूल्स मेनू दो और कैलिब्रेशन जॉब देता है। दोनों +वर्कपीस और ऑपरेशन वाली एक सामान्य लेयर के रूप में बनते हैं: आप इसे किसी भी अन्य सामग्री की तरह खिसका, +फ़्रेम और पूर्वावलोकन कर सकते हैं, और बाद में हर सेल या रेखा की सेटिंग उसके ऑपरेशन में बदल सकते हैं। + +## इंटरवल टेस्ट + +**टूल्स → इंटरवल टेस्ट बनाएँ** भरे हुए वर्गों की एक पंक्ति उकेरता है। हर वर्ग का अपना **उकेरें** ऑपरेशन +होता है जिसका अपना रेखा अंतराल होता है, जो आपके दिए सबसे छोटे से सबसे बड़े अंतराल तक समान रूप से फैला होता +है, जबकि पावर और गति सभी के लिए समान रहती है। हर वर्ग के नीचे के लेबल मिलीमीटर में अंतराल और उससे मेल +खाती रेखा घनत्व को लाइन प्रति इंच (LPI) में दिखाते हैं। + +वह वर्ग चुनें जो बिना दिखने वाली रेखाओं और बिना ज़्यादा गहरे जलाव के समान रूप से भरा हो, और उस सामग्री +पर उकेरने के लिए उसका अंतराल इस्तेमाल करें। लेबल वर्गों से पहले कम पावर वाले अलग ऑपरेशन से काटे जाते हैं। + +## फ़ोकस टेस्ट + +**टूल्स → फ़ोकस टेस्ट बनाएँ** वह ऊँचाई खोजता है जिस पर बीम सबसे तीक्ष्ण होती है। धनात्मक ऑफ़सेट का अर्थ +है हेड और सामग्री के बीच अधिक दूरी। सबसे पतली रेखा सबसे अच्छा फ़ोकस दिखाती है। + +| विधि | ऊँचाई कैसे बदलती है | +| ----------------------------------- | ------------------------------------------------------------------------------------ | +| **Z अक्ष चरण** | हेड सापेक्ष Z गतियों से हर ऑफ़सेट पर जाता है और अंत में शुरुआती ऊँचाई पर लौटता है | +| **मैनुअल (रेखाओं के बीच विराम)** | हर रेखा से पहले जॉब रुकता है (`M0`); आप हेड को हाथ से खिसकाते हैं और फिर से शुरू दबाते हैं | +| **रैंप (झुकी हुई सामग्री)** | दूरी के निशानों वाली एक लंबी रेखा; आप किसी सपाट पट्टी का एक सिरा ऊपर उठाते हैं | + +Z अक्ष चरण केवल Z अक्ष वाली मशीनों पर उपलब्ध हैं, और Z अक्ष चरण तथा मैनुअल विधि दोनों के लिए G-code +कंट्रोलर चाहिए, क्योंकि वे रेखाओं के बीच [कमांड](command.md) ऑपरेशन का उपयोग करते हैं। ऑफ़सेट शुरुआती +ऊँचाई से ±10 mm तक सीमित हैं। + +मैनुअल विधि में पहले लेज़र को हमेशा की तरह फ़ोकस करें। लेबल उसी ऊँचाई पर उकेरे जाते हैं। पहले विराम पर +हेड को पहले ऑफ़सेट पर रखें; हर अगले विराम पर उसे एक चरण खिसकाएँ। इस पर भरोसा करने से पहले जाँचें कि आपका +कंट्रोलर `M0` पर रुकता है और फिर से शुरू बटन जॉब को आगे बढ़ाता है, उदाहरण के लिए 0% पावर पर ड्राई रन से। + +रैंप में, रेखा के किसी भी बिंदु के नीचे की ऊँचाई पट्टी के उठाव से निकलती है: _h_ ऊपर उठने वाली पट्टी पर +_L_ लंबाई की रेखा के साथ _d_ दूरी पर सामग्री शुरुआत से _h_ × _d_ / _L_ ऊँची होती है। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..1c782b073 --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,49 @@ +--- +description: + "Encontre o melhor intervalo de linha para gravação e o ponto focal do seu laser com o teste de + intervalo e o teste de foco." +--- + +# Testes de intervalo e de foco + +Ao lado da [grade de teste de material](material-test-grid.md), o menu Ferramentas oferece mais dois +trabalhos de calibração. Ambos são criados como uma camada comum com peças e operações: você pode +movê-la, enquadrá-la e visualizá-la como qualquer outro conteúdo, e alterar depois as configurações +de cada célula ou linha na sua operação. + +## Teste de intervalo + +**Ferramentas → Criar teste de intervalo** grava uma fileira de quadrados preenchidos. Cada quadrado +tem sua própria operação **Gravar** com seu próprio intervalo de linha, distribuído uniformemente do +menor ao maior intervalo informado, enquanto a potência e a velocidade são as mesmas para todos. Os +rótulos abaixo de cada quadrado mostram o intervalo em milímetros e a densidade de linhas +correspondente em linhas por polegada (LPI). + +Escolha o quadrado preenchido de forma uniforme, sem linhas visíveis e sem queimar demais, e use o +seu intervalo para gravações nesse material. Os rótulos são cortados antes dos quadrados com uma +operação separada de baixa potência. + +## Teste de foco + +**Ferramentas → Criar teste de foco** encontra a altura em que o feixe é mais nítido. Um +deslocamento positivo significa mais distância entre a cabeça e o material. A linha mais fina marca +o melhor foco. + +| Método | Como a altura muda | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| **Passos do eixo Z** | A cabeça vai a cada deslocamento com movimentos Z relativos e volta à altura inicial no final | +| **Manual (pausa entre linhas)** | O trabalho pausa (`M0`) antes de cada linha; você move a cabeça à mão e pressiona Retomar | +| **Rampa (material inclinado)** | Uma linha longa com marcas de distância; você levanta uma extremidade de uma tira plana | + +Os passos do eixo Z só são oferecidos em máquinas com eixo Z, e tanto os passos do eixo Z quanto o +método manual precisam de um controlador G-code, porque usam operações de [Comando](command.md) +entre as linhas. Os deslocamentos são limitados a ±10 mm da altura inicial. + +No método manual, primeiro foque o laser como de costume. Os rótulos são gravados nessa altura. Na +primeira pausa, coloque a cabeça no primeiro deslocamento; em cada pausa seguinte, mova-a um passo. +Verifique se o seu controlador para com `M0` e se o botão Retomar continua o trabalho antes de +confiar nisso, por exemplo com um teste a seco a 0 % de potência. + +Na rampa, a altura sob qualquer ponto da linha resulta da subida da tira: a uma distância _d_ ao +longo de uma linha de comprimento _L_ sobre uma tira que sobe _h_, o material está _h_ × _d_ / _L_ +mais alto que no início. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..668599346 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,46 @@ +--- +description: + "Знайдіть найкращий інтервал ліній для гравіювання та фокусну точку лазера за допомогою тесту + інтервалу і тесту фокусування." +--- + +# Тести інтервалу та фокусування + +Поруч із [сіткою тесту матеріалу](material-test-grid.md) меню «Інструменти» пропонує ще два +калібрувальні завдання. Обидва створюються як звичайний шар із заготовками та операціями: його можна +переміщати, обводити рамкою й переглядати, як будь-який інший вміст, а потім змінювати параметри +кожної комірки чи лінії в її операції. + +## Тест інтервалу + +**Інструменти → Створити тест інтервалу** гравіює ряд заповнених квадратів. Кожен квадрат має власну +операцію **Гравіювання** з власним інтервалом ліній, рівномірно розподіленим від найменшого до +найбільшого введеного інтервалу, тоді як потужність і швидкість однакові для всіх. Підписи під +кожним квадратом показують інтервал у міліметрах і відповідну щільність ліній у лініях на дюйм (LPI). + +Виберіть квадрат, заповнений рівномірно, без видимих ліній і без надто глибокого пропалу, і +використовуйте його інтервал для гравіювання на цьому матеріалі. Підписи вирізаються перед +квадратами окремою операцією з низькою потужністю. + +## Тест фокусування + +**Інструменти → Створити тест фокусування** знаходить висоту, на якій промінь найрізкіший. Додатне +зміщення означає більшу відстань між голівкою і матеріалом. Найтонша лінія позначає найкращий фокус. + +| Метод | Як змінюється висота | +| ---------------------------------- | -------------------------------------------------------------------------------------------- | +| **Кроки осі Z** | Голівка переходить до кожного зміщення відносними рухами Z і наприкінці повертається назад | +| **Вручну (пауза між лініями)** | Завдання ставиться на паузу (`M0`) перед кожною лінією; ви зсуваєте голівку вручну | +| **Рампа (нахилений матеріал)** | Одна довга лінія з позначками відстані; ви підпираєте один кінець пласкої смуги | + +Кроки осі Z пропонуються лише для верстатів з віссю Z, а кроки осі Z і ручний метод потребують +G-code-контролера, бо між лініями використовують операції [Команда](command.md). Зміщення обмежені +±10 мм від початкової висоти. + +Для ручного методу спочатку сфокусуйте лазер як зазвичай. Підписи гравіюються на цій висоті. На +першій паузі встановіть голівку на перше зміщення, на кожній наступній — зсуньте її на один крок. +Перш ніж покладатися на це, перевірте, що ваш контролер зупиняється на `M0` і кнопка «Продовжити» +відновлює завдання, наприклад пробним запуском із потужністю 0 %. + +Для рампи висота під будь-якою точкою лінії випливає з підйому смуги: на відстані _d_ уздовж лінії +довжиною _L_ на смузі, що піднімається на _h_, матеріал вищий на _h_ × _d_ / _L_, ніж на початку. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md new file mode 100644 index 000000000..2fd9a7456 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,29 @@ +--- +description: "使用线间距测试和焦距测试,找到最适合雕刻的线间距以及激光的焦点。" +--- + +# 线间距与焦距测试 + +除了[材料测试网格](material-test-grid.md),工具菜单还提供另外两种校准作业。两者都会创建为包含工件和操作的普通图层:你可以像其他内容一样移动、走边框和预览,之后还可以在各自的操作中修改每个单元格或每条线的设置。 + +## 线间距测试 + +**工具 → 创建线间距测试**会雕刻一排填充方块。每个方块都有自己的**雕刻**操作和自己的线间距,从你输入的最小值到最大值均匀分布,而所有方块的功率和速度都相同。每个方块下方的标签显示以毫米为单位的线间距,以及对应的每英寸线数(LPI)。 + +选择填充均匀、看不到线条且没有烧得过深的方块,在该材料上雕刻时使用它的线间距。标签会在方块之前用单独的低功率操作切割。 + +## 焦距测试 + +**工具 → 创建焦距测试**用于找到光束最锐利的高度。正偏移表示激光头与材料之间的距离更大。最细的线即为最佳焦点。 + +| 方法 | 高度如何变化 | +| ---------------------- | ------------------------------------------------------------ | +| **Z 轴步进** | 激光头通过相对 Z 移动到达每个偏移,最后回到起始高度 | +| **手动(线之间暂停)** | 每条线之前作业暂停(`M0`);你手动移动激光头后按“继续” | +| **斜坡(倾斜材料)** | 一条带距离刻度的长线;你把平板条的一端垫高 | + +Z 轴步进仅在具有 Z 轴的机器上提供;Z 轴步进和手动方法都需要 G-code 控制器,因为它们在线之间使用[命令](command.md)操作。偏移限制在起始高度的 ±10 mm 以内。 + +使用手动方法时,先像平常一样对焦激光。标签在该高度雕刻。第一次暂停时,把激光头调到起始偏移;之后每次暂停时移动一个步进。在依赖此方法之前,请确认你的控制器会在 `M0` 处停止,并且“继续”按钮可以继续作业,例如以 0% 功率空跑一次。 + +使用斜坡时,线上任一点下方的高度由板条的升高推算:在升高 _h_ 的板条上,沿长度为 _L_ 的线距离起点 _d_ 处,材料比起点高 _h_ × _d_ / _L_。 diff --git a/website/sidebars.js b/website/sidebars.js index 52e3e43bd..e2c812e99 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -64,6 +64,7 @@ module.exports = { 'features/operations/shrink-wrap', 'features/operations/frame-outline', 'features/operations/material-test-grid', + 'features/operations/interval-and-focus-tests', 'features/operations/wavefront', 'features/operations/command', ], From 94e2b1652b14f2873ce5d64dfbc04af1ce745eff Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 18:22:20 +0200 Subject: [PATCH 05/28] feat(laser): default the Focus Test to Z axis steps when available The method list is already ordered by preference (Z axis steps, manual, ramp) and only holds what the machine supports, so the dialog now starts on its first entry instead of the manual method. Refs #519 --- .../widgets/calibration_test_dialogs.py | 3 +-- .../tests/ui_gtk/test_calibration_test_dialogs.py | 11 +++++++++++ .../features/operations/interval-and-focus-tests.md | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py index c31b74b76..23b43f551 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py @@ -230,8 +230,7 @@ def __init__( title=_("Method"), model=Gtk.StringList.new([m.label for m in modes]), ) - if d.mode in modes: - self.mode_row.set_selected(modes.index(d.mode)) + self.mode_row.set_selected(0) self.mode_row.connect("notify::selected", lambda *_a: self._sync()) group.add(self.mode_row) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py index ee077f7cb..1af5d4649 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py @@ -64,3 +64,14 @@ def test_focus_dialog_modes(ui_context): def test_focus_dialog_without_manual_mode(ui_context): dialog = FocusTestDialog(None, lambda p: None, [FocusMode.RAMP]) assert dialog.mode == FocusMode.RAMP + + +@pytest.mark.ui +def test_focus_dialog_defaults_to_z_axis_when_available(ui_context): + dialog = FocusTestDialog( + None, + lambda p: None, + [FocusMode.Z_AXIS, FocusMode.MANUAL, FocusMode.RAMP], + ) + assert dialog.mode == FocusMode.Z_AXIS + assert dialog.step_row.get_visible() diff --git a/website/docs/features/operations/interval-and-focus-tests.md b/website/docs/features/operations/interval-and-focus-tests.md index 15dad41a8..3eca053a7 100644 --- a/website/docs/features/operations/interval-and-focus-tests.md +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -35,7 +35,8 @@ means more distance between the head and the material. The thinnest line marks t Z axis steps are only offered on machines with a Z axis, and both Z axis steps and the manual method need a G-code controller, because they use [Command](command.md) operations between the lines. The -offsets are limited to ±10 mm from the starting height. +offsets are limited to ±10 mm from the starting height. The dialog starts on the first method the +machine supports, in the order of the table above. For the manual method, focus the laser as usual first. The labels are engraved at that height. At the first pause, set the head to the first offset; at every following pause, move it by one From e3798ceaf88d67d71d11124895861b052f07b903 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:28:10 +0200 Subject: [PATCH 06/28] docs: mention the Focus Test's Z axis default in the changelog Refs #550 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecba21bd3..cff8cda66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 one line per focus offset, either with relative Z moves (machines with a Z axis), with an M0 pause before each line to move the head by hand, or as one long line over a tilted strip. Both are created as - an editable layer with one operation per cell or line (#519) + an editable layer with one operation per cell or line. The Focus + Test opens on Z axis steps when the machine has a Z axis (#519, + #550) - 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 From 6a2e18fece3d723b34b1dcc7c5791e7cc22c8692 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:43:47 +0200 Subject: [PATCH 07/28] test(laser): let the editor settle after the calibration tests --- tests/doceditor/test_calibration_tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index 38ec08151..7ba0fd248 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +import pytest_asyncio from rayforge.context import get_context from rayforge.core.workpiece import WorkPiece @@ -39,6 +40,12 @@ def machine(context_initializer): return machine +@pytest_asyncio.fixture(autouse=True) +async def settle_editor(doc_editor): + yield + await doc_editor.wait_until_settled() + + async def _gcode(editor) -> str: await editor.wait_until_settled() pipeline = editor.pipeline From 4570da13acf72fa4a924ad5494d4ca061569fff9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:43 +0200 Subject: [PATCH 08/28] refactor(laser): check the driver through the machine package The Focus Test imported the private _driver_uses_gcode helper from the pipeline's intent builder. Resolve the driver class with get_driver_cls, the same way Machine does, so the addon no longer depends on pipeline internals. Refs #519 --- .../laser_essentials/commands/calibration_test_cmd.py | 4 ++-- tests/doceditor/test_calibration_tests.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py index 31ef62bf8..028b57db6 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py @@ -26,7 +26,7 @@ from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand from rayforge.image.svg.exporter import GeometrySvgExporter from rayforge.image.svg.importer import SvgImporter -from rayforge.pipeline.intent_builder import _driver_uses_gcode +from rayforge.machine.driver import get_driver_cls from rayforge.pipeline.transformer.registry import transformer_registry from ..calibration_tests import ( @@ -125,7 +125,7 @@ def available_focus_modes(self) -> list[FocusMode]: machine = self._editor.context.machine if machine is None or step_registry.get("CommandStep") is None: return modes - if not _driver_uses_gcode(machine): + if not get_driver_cls(machine.driver_name or "").uses_gcode: return modes modes.insert(0, FocusMode.MANUAL) if machine.has_z_axis: diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index 7ba0fd248..eb26fedb1 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -204,6 +204,13 @@ async def test_focus_modes_without_z_axis(doc_editor, machine): cmd.create_focus_test(_focus(FocusMode.Z_AXIS)) +@pytest.mark.asyncio +async def test_focus_modes_without_gcode_driver(doc_editor, machine, mocker): + mocker.patch.object(machine, "driver_name", "RuidaRPAAdapter") + cmd = CalibrationTestCmd(doc_editor) + assert cmd.available_focus_modes() == [FocusMode.RAMP] + + @pytest.mark.asyncio async def test_focus_manual_pauses_between_lines(doc_editor, machine): cmd = CalibrationTestCmd(doc_editor) From 9d694f2b0f72b48a3fd1f4d33cc2be96bc7e6e5e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:51:57 +0200 Subject: [PATCH 09/28] refactor(laser): build the calibration test dialogs from VarSets The Interval and Focus Test dialogs assembled their rows by hand, with spin rows for power. Declare the fields as VarSets and render them with VarSetWidget, like the Material Test Grid settings: power is now the usual percent slider, speed limits come from the machine through the speed adapter, and visibility and sensitivity use visible_when and sensitive_when. The dialog is as wide as the step settings dialog, and the existing label strings are reused. Refs #519 --- .../laser_essentials/frontend.py | 6 - .../widgets/calibration_test_dialogs.py | 461 ++++++++++-------- .../ui_gtk/test_calibration_test_dialogs.py | 48 +- 3 files changed, 280 insertions(+), 235 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py index b5c59b636..28f7eb0f6 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -63,10 +63,6 @@ def on_activate(action, param): menu=MenuPlacement(menu_id="tools", priority=100), ) - def _max_speed(window) -> float: - machine = window.doc_editor.context.machine - return float(machine.max_cut_speed) if machine else 60000.0 - interval_action = Gio.SimpleAction.new("interval_test", None) def on_interval_test(action, param): @@ -75,7 +71,6 @@ def on_interval_test(action, param): IntervalTestDialog( window, editor.calibration_tests.create_interval_test, - max_speed=_max_speed(window), ).present() interval_action.connect("activate", on_interval_test) @@ -97,7 +92,6 @@ def on_focus_test(action, param): window, cmd.create_focus_test, cmd.available_focus_modes(), - max_speed=_max_speed(window), ).present() focus_action.connect("activate", on_focus_test) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py index 23b43f551..751ff961a 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py @@ -6,12 +6,17 @@ from gi.repository import Adw, Gtk -from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow -from rayforge.ui_gtk.shared.pref_rows import ( - LengthSpinRow, - SpeedSpinRow, - SpinRow, +from rayforge.core.varset import ( + BoolVar, + IntVar, + LabeledChoiceVar, + LengthVar, + SliderFloatVar, + SpeedVar, + VarSet, ) +from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow +from rayforge.ui_gtk.varset.varsetwidget import VarSetWidget from ..calibration_tests import ( MAX_FOCUS_OFFSET_MM, @@ -20,6 +25,8 @@ IntervalTestParams, ) +DEFAULT_WIDTH = 600 + _FOCUS_HELP = { FocusMode.Z_AXIS: _( "Focus the laser as usual first. The job moves the head to the " @@ -43,6 +50,186 @@ } +def _labels_active(values: dict[str, Any]) -> bool: + return bool(values.get("include_labels")) + + +def _is_ramp(values: dict[str, Any]) -> bool: + return values.get("mode") == FocusMode.RAMP.value + + +def _is_not_ramp(values: dict[str, Any]) -> bool: + return not _is_ramp(values) + + +def _laser_varset(defaults) -> VarSet: + return VarSet( + title=_("Laser"), + vars=[ + SliderFloatVar( + key="power_percent", + label=_("Power"), + default=defaults.power_percent, + min_val=0.0, + max_val=100.0, + show_value=True, + format_suffix="%", + ), + SpeedVar( + key="speed", + label=_("Speed"), + default=int(defaults.speed), + min_val=1, + ), + ], + ) + + +def _labels_varset(defaults) -> VarSet: + return VarSet( + title=_("Labels"), + vars=[ + BoolVar( + key="include_labels", + label=_("Include Labels"), + default=defaults.include_labels, + ), + SliderFloatVar( + key="label_power_percent", + label=_("Label Engrave Power (%)"), + default=defaults.label_power_percent, + min_val=0.0, + max_val=100.0, + show_value=True, + format_suffix="%", + sensitive_when=_labels_active, + ), + SpeedVar( + key="label_speed", + label=_("Label Engrave Speed"), + default=int(defaults.label_speed), + min_val=1, + sensitive_when=_labels_active, + ), + ], + ) + + +def _interval_varset(defaults: IntervalTestParams) -> VarSet: + return VarSet( + title=_("Cells"), + description=_( + "Engraves a row of filled squares, each with its own line " + "interval, at the same power and speed." + ), + vars=[ + IntVar( + key="count", + label=_("Number of Cells"), + default=defaults.count, + min_val=2, + max_val=20, + ), + LengthVar( + key="min_interval", + label=_("Smallest Interval"), + default=defaults.min_interval, + min_val=0.01, + max_val=5.0, + digits=3, + ), + LengthVar( + key="max_interval", + label=_("Largest Interval"), + default=defaults.max_interval, + min_val=0.01, + max_val=5.0, + digits=3, + ), + LengthVar( + key="cell_size", + label=_("Cell Size"), + default=defaults.cell_size, + min_val=1.0, + max_val=100.0, + ), + LengthVar( + key="spacing", + label=_("Spacing"), + default=defaults.spacing, + min_val=0.0, + max_val=50.0, + ), + ], + ) + + +def _focus_varset(defaults: FocusTestParams, modes: list[FocusMode]) -> VarSet: + limit = MAX_FOCUS_OFFSET_MM + return VarSet( + title=_("Focus Steps"), + vars=[ + LabeledChoiceVar( + key="mode", + label=_("Method"), + choices=[(m.label, m.value) for m in modes], + default=modes[0].value, + allow_none=False, + ), + LengthVar( + key="start_offset", + label=_("First Offset"), + default=defaults.start_offset, + min_val=-limit, + max_val=limit, + digits=2, + visible_when=_is_not_ramp, + ), + LengthVar( + key="step", + label=_("Step"), + default=defaults.step, + min_val=-limit, + max_val=limit, + digits=2, + visible_when=_is_not_ramp, + ), + IntVar( + key="count", + label=_("Number of Lines"), + default=defaults.count, + min_val=2, + max_val=41, + visible_when=_is_not_ramp, + ), + LengthVar( + key="line_length", + label=_("Line Length"), + default=defaults.line_length, + min_val=1.0, + max_val=200.0, + visible_when=_is_not_ramp, + ), + LengthVar( + key="spacing", + label=_("Spacing"), + default=defaults.spacing, + min_val=0.5, + max_val=50.0, + visible_when=_is_not_ramp, + ), + LengthVar( + key="ramp_length", + label=_("Ramp Length"), + default=defaults.ramp_length, + min_val=10.0, + max_val=1000.0, + visible_when=_is_ramp, + ), + ], + ) + + class _CalibrationTestDialog(PatchedDialogWindow): """Shared frame: a header with Cancel/Create and one page.""" @@ -51,10 +238,11 @@ def __init__( parent: Gtk.Window | None, title: str, on_create: Callable[[Any], object], + varsets: list[VarSet], ): super().__init__(transient_for=parent, modal=True) self.set_title(title) - self.set_default_size(520, 640) + self.set_default_size(DEFAULT_WIDTH, -1) self._on_create = on_create toolbar = Adw.ToolbarView() @@ -72,8 +260,22 @@ def __init__( self.banner = Adw.Banner() toolbar.add_top_bar(self.banner) - self.page = Adw.PreferencesPage() - toolbar.set_content(self.page) + page = Adw.PreferencesPage() + toolbar.set_content(page) + + self.varset_widgets: list[VarSetWidget] = [] + for var_set in varsets: + widget = VarSetWidget() + widget.populate(var_set) + page.add(widget) + self.varset_widgets.append(widget) + + def values(self) -> dict[str, Any]: + """The values of every row, keyed like the params fields.""" + values: dict[str, Any] = {} + for widget in self.varset_widgets: + values.update(widget.get_values()) + return values def params(self) -> object: raise NotImplementedError @@ -87,45 +289,6 @@ def _on_create_clicked(self, _button): return self.close() - @staticmethod - def _power_row(title: str, value: float) -> SpinRow: - return SpinRow( - title, - _("Percent of the maximum power"), - lower=0.1, - upper=100.0, - step_increment=1.0, - digits=1, - value=value, - ) - - def _add_label_group(self, defaults) -> None: - group = Adw.PreferencesGroup(title=_("Labels")) - self.labels_row = Adw.SwitchRow(title=_("Include Labels")) - self.labels_row.set_active(defaults.include_labels) - group.add(self.labels_row) - self.label_power_row = self._power_row( - _("Label Power"), defaults.label_power_percent - ) - group.add(self.label_power_row) - self.label_speed_row = SpeedSpinRow( - _("Label Speed"), - lower=1, - upper=60000, - value_in_base=defaults.label_speed, - ) - group.add(self.label_speed_row) - self.labels_row.connect( - "notify::active", lambda *_a: self._sync_label_rows() - ) - self._sync_label_rows() - self.page.add(group) - - def _sync_label_rows(self): - active = self.labels_row.get_active() - self.label_power_row.set_sensitive(active) - self.label_speed_row.set_sensitive(active) - class IntervalTestDialog(_CalibrationTestDialog): """Asks for the settings of an Interval Test.""" @@ -134,81 +297,21 @@ def __init__( self, parent: Gtk.Window | None, on_create: Callable[[IntervalTestParams], object], - max_speed: float = 60000.0, ): - super().__init__(parent, _("Create Interval Test"), on_create) - d = IntervalTestParams() - group = Adw.PreferencesGroup( - title=_("Cells"), - description=_( - "Engraves a row of filled squares, each with its own line " - "interval, at the same power and speed." - ), - ) - self.count_row = SpinRow( - _("Number of Cells"), lower=2, upper=20, value=d.count - ) - self.min_row = LengthSpinRow( - _("Smallest Interval"), - lower=0.01, - upper=5.0, - step_increment=0.01, - digits=3, - value_in_base=d.min_interval, - ) - self.max_row = LengthSpinRow( - _("Largest Interval"), - lower=0.01, - upper=5.0, - step_increment=0.01, - digits=3, - value_in_base=d.max_interval, + defaults = IntervalTestParams() + super().__init__( + parent, + _("Create Interval Test"), + on_create, + [ + _interval_varset(defaults), + _laser_varset(defaults), + _labels_varset(defaults), + ], ) - self.size_row = LengthSpinRow( - _("Cell Size"), - lower=1.0, - upper=100.0, - value_in_base=d.cell_size, - ) - self.spacing_row = LengthSpinRow( - _("Spacing"), lower=0.0, upper=50.0, value_in_base=d.spacing - ) - for row in ( - self.count_row, - self.min_row, - self.max_row, - self.size_row, - self.spacing_row, - ): - group.add(row) - self.page.add(group) - - laser = Adw.PreferencesGroup(title=_("Laser")) - self.power_row = self._power_row(_("Power"), d.power_percent) - self.speed_row = SpeedSpinRow( - _("Speed"), - lower=1, - upper=max_speed, - value_in_base=min(d.speed, max_speed), - ) - laser.add(self.power_row) - laser.add(self.speed_row) - self.page.add(laser) - self._add_label_group(d) def params(self) -> IntervalTestParams: - return IntervalTestParams( - min_interval=self.min_row.get_value_in_base_units(), - max_interval=self.max_row.get_value_in_base_units(), - count=self.count_row.get_int_value(), - cell_size=self.size_row.get_value_in_base_units(), - spacing=self.spacing_row.get_value_in_base_units(), - power_percent=self.power_row.get_value(), - speed=self.speed_row.get_value_in_base_units(), - include_labels=self.labels_row.get_active(), - label_power_percent=self.label_power_row.get_value(), - label_speed=self.label_speed_row.get_value_in_base_units(), - ) + return IntervalTestParams(**self.values()) class FocusTestDialog(_CalibrationTestDialog): @@ -219,108 +322,34 @@ def __init__( parent: Gtk.Window | None, on_create: Callable[[FocusTestParams], object], modes: list[FocusMode], - max_speed: float = 60000.0, ): - super().__init__(parent, _("Create Focus Test"), on_create) - d = FocusTestParams() - self._modes = modes - group = Adw.PreferencesGroup(title=_("Focus Steps")) - self.steps_group = group - self.mode_row = Adw.ComboRow( - title=_("Method"), - model=Gtk.StringList.new([m.label for m in modes]), - ) - self.mode_row.set_selected(0) - self.mode_row.connect("notify::selected", lambda *_a: self._sync()) - group.add(self.mode_row) - - limit = MAX_FOCUS_OFFSET_MM - self.start_row = LengthSpinRow( - _("First Offset"), - lower=-limit, - upper=limit, - step_increment=0.1, - digits=2, - value_in_base=d.start_offset, - ) - self.step_row = LengthSpinRow( - _("Step"), - lower=-limit, - upper=limit, - step_increment=0.1, - digits=2, - value_in_base=d.step, - ) - self.count_row = SpinRow( - _("Number of Lines"), lower=2, upper=41, value=d.count - ) - self.length_row = LengthSpinRow( - _("Line Length"), - lower=1.0, - upper=200.0, - value_in_base=d.line_length, + defaults = FocusTestParams() + super().__init__( + parent, + _("Create Focus Test"), + on_create, + [ + _focus_varset(defaults, modes), + _laser_varset(defaults), + _labels_varset(defaults), + ], ) - self.spacing_row = LengthSpinRow( - _("Spacing"), lower=0.5, upper=50.0, value_in_base=d.spacing - ) - self.ramp_row = LengthSpinRow( - _("Ramp Length"), - lower=10.0, - upper=1000.0, - value_in_base=d.ramp_length, - ) - self._step_rows = ( - self.start_row, - self.step_row, - self.count_row, - self.length_row, - self.spacing_row, - ) - for row in (*self._step_rows, self.ramp_row): - group.add(row) - self.page.add(group) - - laser = Adw.PreferencesGroup(title=_("Laser")) - self.power_row = self._power_row(_("Power"), d.power_percent) - self.speed_row = SpeedSpinRow( - _("Speed"), - lower=1, - upper=max_speed, - value_in_base=min(d.speed, max_speed), - ) - laser.add(self.power_row) - laser.add(self.speed_row) - self.page.add(laser) - self._add_label_group(d) - self._sync() + self.steps_widget = self.varset_widgets[0] + self.steps_widget.data_changed.connect(self._on_steps_changed) + self._update_help() @property def mode(self) -> FocusMode: - index = self.mode_row.get_selected() - if 0 <= index < len(self._modes): - return self._modes[index] - return FocusMode.RAMP - - def _sync(self): - mode = self.mode - self.steps_group.set_description(_FOCUS_HELP[mode]) - is_ramp = mode == FocusMode.RAMP - for row in self._step_rows: - row.set_visible(not is_ramp) - self.ramp_row.set_visible(is_ramp) + return FocusMode(self.steps_widget.get_values()["mode"]) + + def _on_steps_changed(self, sender, key: str): + if key == "mode": + self._update_help() + + def _update_help(self): + self.steps_widget.set_description(_FOCUS_HELP[self.mode]) def params(self) -> FocusTestParams: - return FocusTestParams( - mode=self.mode, - start_offset=self.start_row.get_value_in_base_units(), - step=self.step_row.get_value_in_base_units(), - count=self.count_row.get_int_value(), - line_length=self.length_row.get_value_in_base_units(), - spacing=self.spacing_row.get_value_in_base_units(), - ramp_length=self.ramp_row.get_value_in_base_units(), - power_percent=self.power_row.get_value(), - speed=self.speed_row.get_value_in_base_units(), - include_labels=self.labels_row.get_active(), - label_power_percent=self.label_power_row.get_value(), - label_speed=self.label_speed_row.get_value_in_base_units(), - ) + values = self.values() + values["mode"] = FocusMode(values["mode"]) + return FocusTestParams(**values) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py index 1af5d4649..e7f3f196d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py @@ -13,25 +13,45 @@ IntervalTestParams, ) from laser_essentials.widgets.calibration_test_dialogs import ( + DEFAULT_WIDTH, FocusTestDialog, IntervalTestDialog, ) +def _row(dialog, key): + for widget in dialog.varset_widgets: + row = widget.row_for(key) + if row is not None: + return row + raise KeyError(key) + + @pytest.mark.ui -def test_interval_dialog_defaults_and_create(ui_context): +def test_interval_dialog_defaults_and_create(laser_machine): created: list[IntervalTestParams] = [] - dialog = IntervalTestDialog(None, created.append, max_speed=3000) + dialog = IntervalTestDialog(None, created.append) assert dialog.params() == IntervalTestParams() + assert dialog.get_default_size()[0] == DEFAULT_WIDTH - dialog.count_row._spin_button.set_value(7) + dialog.varset_widgets[0].set_values({"count": 7}) dialog.create_button.emit("clicked") assert len(created) == 1 assert created[0].count == 7 @pytest.mark.ui -def test_interval_dialog_shows_errors(ui_context): +def test_label_rows_follow_include_labels(laser_machine): + dialog = IntervalTestDialog(None, lambda p: None) + labels = dialog.varset_widgets[2] + labels.set_values({"include_labels": False}) + assert not _row(dialog, "label_power_percent").get_sensitive() + assert not _row(dialog, "label_speed").get_sensitive() + assert dialog.params().include_labels is False + + +@pytest.mark.ui +def test_interval_dialog_shows_errors(laser_machine): def refuse(params): raise ValueError("bad range") @@ -42,36 +62,38 @@ def refuse(params): @pytest.mark.ui -def test_focus_dialog_modes(ui_context): +def test_focus_dialog_modes(laser_machine): created: list[FocusTestParams] = [] dialog = FocusTestDialog( None, created.append, [FocusMode.MANUAL, FocusMode.RAMP] ) assert dialog.mode == FocusMode.MANUAL - assert dialog.step_row.get_visible() - assert not dialog.ramp_row.get_visible() + assert _row(dialog, "step").get_visible() + assert not _row(dialog, "ramp_length").get_visible() + manual_help = dialog.steps_widget.get_description() - dialog.mode_row.set_selected(1) + _row(dialog, "mode").set_selected(1) assert dialog.mode == FocusMode.RAMP - assert not dialog.step_row.get_visible() - assert dialog.ramp_row.get_visible() + assert not _row(dialog, "step").get_visible() + assert _row(dialog, "ramp_length").get_visible() + assert dialog.steps_widget.get_description() != manual_help dialog.create_button.emit("clicked") assert created[0].mode == FocusMode.RAMP @pytest.mark.ui -def test_focus_dialog_without_manual_mode(ui_context): +def test_focus_dialog_without_manual_mode(laser_machine): dialog = FocusTestDialog(None, lambda p: None, [FocusMode.RAMP]) assert dialog.mode == FocusMode.RAMP @pytest.mark.ui -def test_focus_dialog_defaults_to_z_axis_when_available(ui_context): +def test_focus_dialog_defaults_to_z_axis_when_available(laser_machine): dialog = FocusTestDialog( None, lambda p: None, [FocusMode.Z_AXIS, FocusMode.MANUAL, FocusMode.RAMP], ) assert dialog.mode == FocusMode.Z_AXIS - assert dialog.step_row.get_visible() + assert _row(dialog, "step").get_visible() From 2e8f3b25eb0fbaecc6c5295250f5267c9962e194 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:58:52 +0200 Subject: [PATCH 10/28] docs: apply site-format to the Interval and Focus Tests pages Refs #519 --- .../operations/interval-and-focus-tests.md | 26 +++++----- .../operations/interval-and-focus-tests.md | 18 +++---- .../operations/interval-and-focus-tests.md | 16 +++--- .../operations/interval-and-focus-tests.md | 21 ++++---- .../operations/interval-and-focus-tests.md | 52 ++++++++++--------- .../operations/interval-and-focus-tests.md | 10 ++-- .../operations/interval-and-focus-tests.md | 13 ++--- .../operations/interval-and-focus-tests.md | 19 ++++--- 8 files changed, 92 insertions(+), 83 deletions(-) diff --git a/website/docs/features/operations/interval-and-focus-tests.md b/website/docs/features/operations/interval-and-focus-tests.md index 3eca053a7..249a8a725 100644 --- a/website/docs/features/operations/interval-and-focus-tests.md +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -8,8 +8,8 @@ description: Next to the [Material Test Grid](material-test-grid.md), the Tools menu offers two more calibration jobs. Both are created as an ordinary layer with workpieces and operations: you can move it, frame -it and preview it like any other content, and change the settings of each cell or line afterwards -in its operation. +it and preview it like any other content, and change the settings of each cell or line afterwards in +its operation. ## Interval Test @@ -19,29 +19,29 @@ interval you enter, while power and speed stay the same for all of them. The lab square show the interval in millimeters and the matching line density in lines per inch (LPI). Pick the square that is filled evenly without visible lines and without burning too deep, and use -its interval for engravings on that material. The labels are cut with a separate low-power -operation before the squares. +its interval for engravings on that material. The labels are cut with a separate low-power operation +before the squares. ## Focus Test **Tools → Create Focus Test** finds the height at which the beam is sharpest. A positive offset means more distance between the head and the material. The thinnest line marks the best focus. -| Method | How the height changes | -| -------------------------------- | ------------------------------------------------------------------------------------------------------- | -| **Z axis steps** | The head moves to each offset with relative Z moves and returns to its starting height at the end | -| **Manual (pause between lines)** | The job pauses (`M0`) before each line; you move the head by hand and press Resume | -| **Ramp (tilted material)** | One long line with distance ticks; you prop up one end of a flat strip so it rises under the line | +| Method | How the height changes | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| **Z axis steps** | The head moves to each offset with relative Z moves and returns to its starting height at the end | +| **Manual (pause between lines)** | The job pauses (`M0`) before each line; you move the head by hand and press Resume | +| **Ramp (tilted material)** | One long line with distance ticks; you prop up one end of a flat strip so it rises under the line | Z axis steps are only offered on machines with a Z axis, and both Z axis steps and the manual method need a G-code controller, because they use [Command](command.md) operations between the lines. The offsets are limited to ±10 mm from the starting height. The dialog starts on the first method the machine supports, in the order of the table above. -For the manual method, focus the laser as usual first. The labels are engraved at that height. -At the first pause, set the head to the first offset; at every following pause, move it by one -step. Check that your controller stops on `M0` and that the Resume button continues the job before -you rely on it, for example with a dry run at 0% power. +For the manual method, focus the laser as usual first. The labels are engraved at that height. At +the first pause, set the head to the first offset; at every following pause, move it by one step. +Check that your controller stops on `M0` and that the Resume button continues the job before you +rely on it, for example with a dry run at 0% power. For the ramp, the height under any point of the line follows from the rise of the strip: at a distance _d_ along a line of length _L_ on a strip that rises _h_, the material is _h_ × _d_ / _L_ diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 90b96adee..ecd076980 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,17 +7,17 @@ description: # Intervall- und Fokustests Neben dem [Materialtest-Raster](material-test-grid.md) bietet das Menü Werkzeuge zwei weitere -Kalibrierungsjobs. Beide werden als normale Ebene mit Werkstücken und Operationen angelegt: Du kannst -sie verschieben, umranden und in der Vorschau ansehen wie jeden anderen Inhalt und die Einstellungen -jedes Feldes oder jeder Linie danach in ihrer Operation ändern. +Kalibrierungsjobs. Beide werden als normale Ebene mit Werkstücken und Operationen angelegt: Du +kannst sie verschieben, umranden und in der Vorschau ansehen wie jeden anderen Inhalt und die +Einstellungen jedes Feldes oder jeder Linie danach in ihrer Operation ändern. ## Intervalltest **Werkzeuge → Intervalltest erstellen** graviert eine Reihe gefüllter Quadrate. Jedes Quadrat hat -eine eigene **Gravieren**-Operation mit eigenem Linienabstand, gleichmäßig verteilt vom kleinsten bis -zum größten eingegebenen Abstand, während Leistung und Geschwindigkeit für alle gleich bleiben. Die -Beschriftungen unter jedem Quadrat zeigen den Abstand in Millimetern und die passende Liniendichte in -Linien pro Zoll (LPI). +eine eigene **Gravieren**-Operation mit eigenem Linienabstand, gleichmäßig verteilt vom kleinsten +bis zum größten eingegebenen Abstand, während Leistung und Geschwindigkeit für alle gleich bleiben. +Die Beschriftungen unter jedem Quadrat zeigen den Abstand in Millimetern und die passende +Liniendichte in Linien pro Zoll (LPI). Wähle das Quadrat, das gleichmäßig ohne sichtbare Linien und ohne zu tiefes Einbrennen gefüllt ist, und verwende seinen Abstand für Gravuren auf diesem Material. Die Beschriftungen werden vor den @@ -31,9 +31,9 @@ besten Fokus. | Methode | Wie sich die Höhe ändert | | ----------------------------------- | ------------------------------------------------------------------------------------------------ | -| **Z-Achsen-Schritte** | Der Kopf fährt mit relativen Z-Bewegungen zu jedem Versatz und am Ende zurück zur Ausgangshöhe | +| **Z-Achsen-Schritte** | Der Kopf fährt mit relativen Z-Bewegungen zu jedem Versatz und am Ende zurück zur Ausgangshöhe | | **Manuell (Pause zwischen Linien)** | Der Job pausiert (`M0`) vor jeder Linie; du verschiebst den Kopf von Hand und drückst Fortsetzen | -| **Rampe (schräges Material)** | Eine lange Linie mit Abstandsstrichen; du stützt ein Ende eines flachen Streifens ab | +| **Rampe (schräges Material)** | Eine lange Linie mit Abstandsstrichen; du stützt ein Ende eines flachen Streifens ab | Z-Achsen-Schritte werden nur bei Maschinen mit Z-Achse angeboten, und sowohl Z-Achsen-Schritte als auch die manuelle Methode benötigen einen G-Code-Controller, weil sie zwischen den Linien diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 23e30d104..415a774a3 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,9 @@ description: # Pruebas de intervalo y de enfoque Junto a la [cuadrícula de prueba de material](material-test-grid.md), el menú Herramientas ofrece -dos trabajos de calibración más. Ambos se crean como una capa normal con piezas y operaciones: puedes -moverla, encuadrarla y previsualizarla como cualquier otro contenido, y cambiar después los ajustes -de cada celda o línea en su operación. +dos trabajos de calibración más. Ambos se crean como una capa normal con piezas y operaciones: +puedes moverla, encuadrarla y previsualizarla como cualquier otro contenido, y cambiar después los +ajustes de cada celda o línea en su operación. ## Prueba de intervalo @@ -29,11 +29,11 @@ una operación propia de baja potencia. desplazamiento positivo significa más distancia entre el cabezal y el material. La línea más fina marca el mejor enfoque. -| Método | Cómo cambia la altura | -| --------------------------------- | -------------------------------------------------------------------------------------------------- | -| **Pasos del eje Z** | El cabezal va a cada desplazamiento con movimientos Z relativos y vuelve a la altura inicial | -| **Manual (pausa entre líneas)** | El trabajo se pausa (`M0`) antes de cada línea; mueves el cabezal a mano y pulsas Reanudar | -| **Rampa (material inclinado)** | Una línea larga con marcas de distancia; levantas un extremo de una tira plana | +| Método | Cómo cambia la altura | +| ------------------------------- | -------------------------------------------------------------------------------------------- | +| **Pasos del eje Z** | El cabezal va a cada desplazamiento con movimientos Z relativos y vuelve a la altura inicial | +| **Manual (pausa entre líneas)** | El trabajo se pausa (`M0`) antes de cada línea; mueves el cabezal a mano y pulsas Reanudar | +| **Rampa (material inclinado)** | Una línea larga con marcas de distancia; levantas un extremo de una tira plana | Los pasos del eje Z solo se ofrecen en máquinas con eje Z, y tanto los pasos del eje Z como el método manual necesitan un controlador G-code, porque usan operaciones de [Comando](command.md) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index f257fa174..68d85faf2 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -14,8 +14,8 @@ modifier les réglages de chaque cellule ou ligne dans son opération. ## Test d'intervalle **Outils → Créer un test d'intervalle** grave une rangée de carrés remplis. Chaque carré a sa propre -opération **Graver** avec son propre intervalle de ligne, réparti régulièrement du plus petit au plus -grand intervalle saisi, tandis que la puissance et la vitesse restent les mêmes pour tous. Les +opération **Graver** avec son propre intervalle de ligne, réparti régulièrement du plus petit au +plus grand intervalle saisi, tandis que la puissance et la vitesse restent les mêmes pour tous. Les étiquettes sous chaque carré indiquent l'intervalle en millimètres et la densité de lignes correspondante en lignes par pouce (LPI). @@ -29,15 +29,16 @@ carrés avec une opération séparée à faible puissance. net. Un décalage positif signifie plus de distance entre la tête et le matériau. La ligne la plus fine indique la meilleure mise au point. -| Méthode | Comment la hauteur change | -| -------------------------------------- | ----------------------------------------------------------------------------------------------- | -| **Pas de l'axe Z** | La tête rejoint chaque décalage par des mouvements Z relatifs et revient à la hauteur de départ | -| **Manuel (pause entre les lignes)** | La tâche se met en pause (`M0`) avant chaque ligne ; vous déplacez la tête et appuyez Reprendre | -| **Rampe (matériau incliné)** | Une longue ligne graduée ; vous surélevez une extrémité d'une bande plate | +| Méthode | Comment la hauteur change | +| ----------------------------------- | ----------------------------------------------------------------------------------------------- | +| **Pas de l'axe Z** | La tête rejoint chaque décalage par des mouvements Z relatifs et revient à la hauteur de départ | +| **Manuel (pause entre les lignes)** | La tâche se met en pause (`M0`) avant chaque ligne ; vous déplacez la tête et appuyez Reprendre | +| **Rampe (matériau incliné)** | Une longue ligne graduée ; vous surélevez une extrémité d'une bande plate | Les pas de l'axe Z ne sont proposés que sur les machines dotées d'un axe Z, et les pas de l'axe Z comme la méthode manuelle nécessitent un contrôleur G-code, car ils utilisent des opérations -[Commande](command.md) entre les lignes. Les décalages sont limités à ±10 mm de la hauteur de départ. +[Commande](command.md) entre les lignes. Les décalages sont limités à ±10 mm de la hauteur de +départ. Avec la méthode manuelle, faites d'abord la mise au point du laser comme d'habitude. Les étiquettes sont gravées à cette hauteur. À la première pause, placez la tête au premier décalage ; à chaque @@ -46,5 +47,5 @@ bouton Reprendre poursuit la tâche avant de vous y fier, par exemple avec un es puissance. Pour la rampe, la hauteur sous chaque point de la ligne découle de la pente de la bande : à une -distance _d_ le long d'une ligne de longueur _L_ sur une bande qui monte de _h_, le matériau est -_h_ × _d_ / _L_ plus haut qu'au départ. +distance _d_ le long d'une ligne de longueur _L_ sur une bande qui monte de _h_, le matériau est _h_ +× _d_ / _L_ plus haut qu'au départ. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 0e008377b..abc052a0d 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -1,42 +1,46 @@ --- description: - "इंटरवल टेस्ट और फ़ोकस टेस्ट से उकेरने के लिए सबसे अच्छा रेखा अंतराल और अपने लेज़र का फ़ोकल बिंदु खोजें।" + "इंटरवल टेस्ट और फ़ोकस टेस्ट से उकेरने के लिए सबसे अच्छा रेखा अंतराल और अपने लेज़र का फ़ोकल बिंदु + खोजें।" --- # इंटरवल और फ़ोकस टेस्ट -[मटेरियल टेस्ट ग्रिड](material-test-grid.md) के अलावा, टूल्स मेनू दो और कैलिब्रेशन जॉब देता है। दोनों -वर्कपीस और ऑपरेशन वाली एक सामान्य लेयर के रूप में बनते हैं: आप इसे किसी भी अन्य सामग्री की तरह खिसका, -फ़्रेम और पूर्वावलोकन कर सकते हैं, और बाद में हर सेल या रेखा की सेटिंग उसके ऑपरेशन में बदल सकते हैं। +[मटेरियल टेस्ट ग्रिड](material-test-grid.md) के अलावा, टूल्स मेनू दो और कैलिब्रेशन जॉब देता है। +दोनों वर्कपीस और ऑपरेशन वाली एक सामान्य लेयर के रूप में बनते हैं: आप इसे किसी भी अन्य सामग्री की तरह +खिसका, फ़्रेम और पूर्वावलोकन कर सकते हैं, और बाद में हर सेल या रेखा की सेटिंग उसके ऑपरेशन में बदल +सकते हैं। ## इंटरवल टेस्ट -**टूल्स → इंटरवल टेस्ट बनाएँ** भरे हुए वर्गों की एक पंक्ति उकेरता है। हर वर्ग का अपना **उकेरें** ऑपरेशन -होता है जिसका अपना रेखा अंतराल होता है, जो आपके दिए सबसे छोटे से सबसे बड़े अंतराल तक समान रूप से फैला होता -है, जबकि पावर और गति सभी के लिए समान रहती है। हर वर्ग के नीचे के लेबल मिलीमीटर में अंतराल और उससे मेल -खाती रेखा घनत्व को लाइन प्रति इंच (LPI) में दिखाते हैं। +**टूल्स → इंटरवल टेस्ट बनाएँ** भरे हुए वर्गों की एक पंक्ति उकेरता है। हर वर्ग का अपना **उकेरें** +ऑपरेशन होता है जिसका अपना रेखा अंतराल होता है, जो आपके दिए सबसे छोटे से सबसे बड़े अंतराल तक समान रूप +से फैला होता है, जबकि पावर और गति सभी के लिए समान रहती है। हर वर्ग के नीचे के लेबल मिलीमीटर में +अंतराल और उससे मेल खाती रेखा घनत्व को लाइन प्रति इंच (LPI) में दिखाते हैं। -वह वर्ग चुनें जो बिना दिखने वाली रेखाओं और बिना ज़्यादा गहरे जलाव के समान रूप से भरा हो, और उस सामग्री -पर उकेरने के लिए उसका अंतराल इस्तेमाल करें। लेबल वर्गों से पहले कम पावर वाले अलग ऑपरेशन से काटे जाते हैं। +वह वर्ग चुनें जो बिना दिखने वाली रेखाओं और बिना ज़्यादा गहरे जलाव के समान रूप से भरा हो, और उस +सामग्री पर उकेरने के लिए उसका अंतराल इस्तेमाल करें। लेबल वर्गों से पहले कम पावर वाले अलग ऑपरेशन से +काटे जाते हैं। ## फ़ोकस टेस्ट -**टूल्स → फ़ोकस टेस्ट बनाएँ** वह ऊँचाई खोजता है जिस पर बीम सबसे तीक्ष्ण होती है। धनात्मक ऑफ़सेट का अर्थ -है हेड और सामग्री के बीच अधिक दूरी। सबसे पतली रेखा सबसे अच्छा फ़ोकस दिखाती है। +**टूल्स → फ़ोकस टेस्ट बनाएँ** वह ऊँचाई खोजता है जिस पर बीम सबसे तीक्ष्ण होती है। धनात्मक ऑफ़सेट का +अर्थ है हेड और सामग्री के बीच अधिक दूरी। सबसे पतली रेखा सबसे अच्छा फ़ोकस दिखाती है। -| विधि | ऊँचाई कैसे बदलती है | -| ----------------------------------- | ------------------------------------------------------------------------------------ | -| **Z अक्ष चरण** | हेड सापेक्ष Z गतियों से हर ऑफ़सेट पर जाता है और अंत में शुरुआती ऊँचाई पर लौटता है | -| **मैनुअल (रेखाओं के बीच विराम)** | हर रेखा से पहले जॉब रुकता है (`M0`); आप हेड को हाथ से खिसकाते हैं और फिर से शुरू दबाते हैं | -| **रैंप (झुकी हुई सामग्री)** | दूरी के निशानों वाली एक लंबी रेखा; आप किसी सपाट पट्टी का एक सिरा ऊपर उठाते हैं | +| विधि | ऊँचाई कैसे बदलती है | +| -------------------------------- | ------------------------------------------------------------------------------------------ | +| **Z अक्ष चरण** | हेड सापेक्ष Z गतियों से हर ऑफ़सेट पर जाता है और अंत में शुरुआती ऊँचाई पर लौटता है | +| **मैनुअल (रेखाओं के बीच विराम)** | हर रेखा से पहले जॉब रुकता है (`M0`); आप हेड को हाथ से खिसकाते हैं और फिर से शुरू दबाते हैं | +| **रैंप (झुकी हुई सामग्री)** | दूरी के निशानों वाली एक लंबी रेखा; आप किसी सपाट पट्टी का एक सिरा ऊपर उठाते हैं | Z अक्ष चरण केवल Z अक्ष वाली मशीनों पर उपलब्ध हैं, और Z अक्ष चरण तथा मैनुअल विधि दोनों के लिए G-code -कंट्रोलर चाहिए, क्योंकि वे रेखाओं के बीच [कमांड](command.md) ऑपरेशन का उपयोग करते हैं। ऑफ़सेट शुरुआती -ऊँचाई से ±10 mm तक सीमित हैं। +कंट्रोलर चाहिए, क्योंकि वे रेखाओं के बीच [कमांड](command.md) ऑपरेशन का उपयोग करते हैं। ऑफ़सेट +शुरुआती ऊँचाई से ±10 mm तक सीमित हैं। -मैनुअल विधि में पहले लेज़र को हमेशा की तरह फ़ोकस करें। लेबल उसी ऊँचाई पर उकेरे जाते हैं। पहले विराम पर -हेड को पहले ऑफ़सेट पर रखें; हर अगले विराम पर उसे एक चरण खिसकाएँ। इस पर भरोसा करने से पहले जाँचें कि आपका -कंट्रोलर `M0` पर रुकता है और फिर से शुरू बटन जॉब को आगे बढ़ाता है, उदाहरण के लिए 0% पावर पर ड्राई रन से। +मैनुअल विधि में पहले लेज़र को हमेशा की तरह फ़ोकस करें। लेबल उसी ऊँचाई पर उकेरे जाते हैं। पहले विराम +पर हेड को पहले ऑफ़सेट पर रखें; हर अगले विराम पर उसे एक चरण खिसकाएँ। इस पर भरोसा करने से पहले जाँचें +कि आपका कंट्रोलर `M0` पर रुकता है और फिर से शुरू बटन जॉब को आगे बढ़ाता है, उदाहरण के लिए 0% पावर पर +ड्राई रन से। -रैंप में, रेखा के किसी भी बिंदु के नीचे की ऊँचाई पट्टी के उठाव से निकलती है: _h_ ऊपर उठने वाली पट्टी पर -_L_ लंबाई की रेखा के साथ _d_ दूरी पर सामग्री शुरुआत से _h_ × _d_ / _L_ ऊँची होती है। +रैंप में, रेखा के किसी भी बिंदु के नीचे की ऊँचाई पट्टी के उठाव से निकलती है: _h_ ऊपर उठने वाली पट्टी +पर _L_ लंबाई की रेखा के साथ _d_ दूरी पर सामग्री शुरुआत से _h_ × _d_ / _L_ ऊँची होती है। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 1c782b073..ae947667d 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -29,11 +29,11 @@ operação separada de baixa potência. deslocamento positivo significa mais distância entre a cabeça e o material. A linha mais fina marca o melhor foco. -| Método | Como a altura muda | -| -------------------------------- | ------------------------------------------------------------------------------------------------- | -| **Passos do eixo Z** | A cabeça vai a cada deslocamento com movimentos Z relativos e volta à altura inicial no final | -| **Manual (pausa entre linhas)** | O trabalho pausa (`M0`) antes de cada linha; você move a cabeça à mão e pressiona Retomar | -| **Rampa (material inclinado)** | Uma linha longa com marcas de distância; você levanta uma extremidade de uma tira plana | +| Método | Como a altura muda | +| ------------------------------- | --------------------------------------------------------------------------------------------- | +| **Passos do eixo Z** | A cabeça vai a cada deslocamento com movimentos Z relativos e volta à altura inicial no final | +| **Manual (pausa entre linhas)** | O trabalho pausa (`M0`) antes de cada linha; você move a cabeça à mão e pressiona Retomar | +| **Rampa (material inclinado)** | Uma linha longa com marcas de distância; você levanta uma extremidade de uma tira plana | Os passos do eixo Z só são oferecidos em máquinas com eixo Z, e tanto os passos do eixo Z quanto o método manual precisam de um controlador G-code, porque usam operações de [Comando](command.md) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 668599346..8697ec7a0 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -16,7 +16,8 @@ description: **Інструменти → Створити тест інтервалу** гравіює ряд заповнених квадратів. Кожен квадрат має власну операцію **Гравіювання** з власним інтервалом ліній, рівномірно розподіленим від найменшого до найбільшого введеного інтервалу, тоді як потужність і швидкість однакові для всіх. Підписи під -кожним квадратом показують інтервал у міліметрах і відповідну щільність ліній у лініях на дюйм (LPI). +кожним квадратом показують інтервал у міліметрах і відповідну щільність ліній у лініях на дюйм +(LPI). Виберіть квадрат, заповнений рівномірно, без видимих ліній і без надто глибокого пропалу, і використовуйте його інтервал для гравіювання на цьому матеріалі. Підписи вирізаються перед @@ -27,11 +28,11 @@ description: **Інструменти → Створити тест фокусування** знаходить висоту, на якій промінь найрізкіший. Додатне зміщення означає більшу відстань між голівкою і матеріалом. Найтонша лінія позначає найкращий фокус. -| Метод | Як змінюється висота | -| ---------------------------------- | -------------------------------------------------------------------------------------------- | -| **Кроки осі Z** | Голівка переходить до кожного зміщення відносними рухами Z і наприкінці повертається назад | -| **Вручну (пауза між лініями)** | Завдання ставиться на паузу (`M0`) перед кожною лінією; ви зсуваєте голівку вручну | -| **Рампа (нахилений матеріал)** | Одна довга лінія з позначками відстані; ви підпираєте один кінець пласкої смуги | +| Метод | Як змінюється висота | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| **Кроки осі Z** | Голівка переходить до кожного зміщення відносними рухами Z і наприкінці повертається назад | +| **Вручну (пауза між лініями)** | Завдання ставиться на паузу (`M0`) перед кожною лінією; ви зсуваєте голівку вручну | +| **Рампа (нахилений матеріал)** | Одна довга лінія з позначками відстані; ви підпираєте один кінець пласкої смуги | Кроки осі Z пропонуються лише для верстатів з віссю Z, а кроки осі Z і ручний метод потребують G-code-контролера, бо між лініями використовують операції [Команда](command.md). Зміщення обмежені diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 2fd9a7456..f508fa2ef 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -16,14 +16,17 @@ description: "使用线间距测试和焦距测试,找到最适合雕刻的线 **工具 → 创建焦距测试**用于找到光束最锐利的高度。正偏移表示激光头与材料之间的距离更大。最细的线即为最佳焦点。 -| 方法 | 高度如何变化 | -| ---------------------- | ------------------------------------------------------------ | -| **Z 轴步进** | 激光头通过相对 Z 移动到达每个偏移,最后回到起始高度 | -| **手动(线之间暂停)** | 每条线之前作业暂停(`M0`);你手动移动激光头后按“继续” | -| **斜坡(倾斜材料)** | 一条带距离刻度的长线;你把平板条的一端垫高 | +| 方法 | 高度如何变化 | +| ---------------------- | ------------------------------------------------------ | +| **Z 轴步进** | 激光头通过相对 Z 移动到达每个偏移,最后回到起始高度 | +| **手动(线之间暂停)** | 每条线之前作业暂停(`M0`);你手动移动激光头后按“继续” | +| **斜坡(倾斜材料)** | 一条带距离刻度的长线;你把平板条的一端垫高 | -Z 轴步进仅在具有 Z 轴的机器上提供;Z 轴步进和手动方法都需要 G-code 控制器,因为它们在线之间使用[命令](command.md)操作。偏移限制在起始高度的 ±10 mm 以内。 +Z 轴步进仅在具有 Z 轴的机器上提供;Z 轴步进和手动方法都需要 G-code 控制器,因为它们在线之间使用[命令](command.md)操作。偏移限制在起始高度的 ±10 +mm 以内。 -使用手动方法时,先像平常一样对焦激光。标签在该高度雕刻。第一次暂停时,把激光头调到起始偏移;之后每次暂停时移动一个步进。在依赖此方法之前,请确认你的控制器会在 `M0` 处停止,并且“继续”按钮可以继续作业,例如以 0% 功率空跑一次。 +使用手动方法时,先像平常一样对焦激光。标签在该高度雕刻。第一次暂停时,把激光头调到起始偏移;之后每次暂停时移动一个步进。在依赖此方法之前,请确认你的控制器会在 +`M0` 处停止,并且“继续”按钮可以继续作业,例如以 0% 功率空跑一次。 -使用斜坡时,线上任一点下方的高度由板条的升高推算:在升高 _h_ 的板条上,沿长度为 _L_ 的线距离起点 _d_ 处,材料比起点高 _h_ × _d_ / _L_。 +使用斜坡时,线上任一点下方的高度由板条的升高推算:在升高 _h_ 的板条上,沿长度为 _L_ 的线距离起点 _d_ +处,材料比起点高 _h_ × _d_ / _L_。 From 61e244381fb7e3624ec9d2ab88212a3ce510183f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 06:00:21 +0200 Subject: [PATCH 11/28] docs: translate the Focus Test's default method note Carry the sentence about the dialog starting on the first supported method into every translated Interval and Focus Tests page. Refs #519 --- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 3 ++- .../current/features/operations/interval-and-focus-tests.md | 2 +- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index ecd076980..977bd0897 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -38,7 +38,8 @@ besten Fokus. Z-Achsen-Schritte werden nur bei Maschinen mit Z-Achse angeboten, und sowohl Z-Achsen-Schritte als auch die manuelle Methode benötigen einen G-Code-Controller, weil sie zwischen den Linien [Befehls](command.md)-Operationen verwenden. Die Versätze sind auf ±10 mm von der Ausgangshöhe -begrenzt. +begrenzt. Der Dialog beginnt mit der ersten Methode, die die Maschine unterstützt, in der +Reihenfolge der Tabelle oben. Fokussiere den Laser bei der manuellen Methode zuerst wie gewohnt. Die Beschriftungen werden in dieser Höhe graviert. Stelle den Kopf bei der ersten Pause auf den ersten Versatz und verschiebe ihn diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 415a774a3..32243e7a4 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -37,7 +37,8 @@ marca el mejor enfoque. Los pasos del eje Z solo se ofrecen en máquinas con eje Z, y tanto los pasos del eje Z como el método manual necesitan un controlador G-code, porque usan operaciones de [Comando](command.md) -entre las líneas. Los desplazamientos están limitados a ±10 mm de la altura inicial. +entre las líneas. Los desplazamientos están limitados a ±10 mm de la altura inicial. El diálogo +empieza con el primer método que admite la máquina, en el orden de la tabla anterior. Con el método manual, enfoca primero el láser como de costumbre. Las etiquetas se graban a esa altura. En la primera pausa, coloca el cabezal en el primer desplazamiento; en cada pausa siguiente, diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 68d85faf2..75eefb6a5 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -38,7 +38,8 @@ fine indique la meilleure mise au point. Les pas de l'axe Z ne sont proposés que sur les machines dotées d'un axe Z, et les pas de l'axe Z comme la méthode manuelle nécessitent un contrôleur G-code, car ils utilisent des opérations [Commande](command.md) entre les lignes. Les décalages sont limités à ±10 mm de la hauteur de -départ. +départ. La boîte de dialogue démarre sur la première méthode prise en charge par la machine, dans +l'ordre du tableau ci-dessus. Avec la méthode manuelle, faites d'abord la mise au point du laser comme d'habitude. Les étiquettes sont gravées à cette hauteur. À la première pause, placez la tête au premier décalage ; à chaque diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index abc052a0d..11da29b72 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -35,7 +35,8 @@ description: Z अक्ष चरण केवल Z अक्ष वाली मशीनों पर उपलब्ध हैं, और Z अक्ष चरण तथा मैनुअल विधि दोनों के लिए G-code कंट्रोलर चाहिए, क्योंकि वे रेखाओं के बीच [कमांड](command.md) ऑपरेशन का उपयोग करते हैं। ऑफ़सेट -शुरुआती ऊँचाई से ±10 mm तक सीमित हैं। +शुरुआती ऊँचाई से ±10 mm तक सीमित हैं। डायलॉग ऊपर दी गई तालिका के क्रम में, मशीन द्वारा समर्थित पहली +विधि से शुरू होता है। मैनुअल विधि में पहले लेज़र को हमेशा की तरह फ़ोकस करें। लेबल उसी ऊँचाई पर उकेरे जाते हैं। पहले विराम पर हेड को पहले ऑफ़सेट पर रखें; हर अगले विराम पर उसे एक चरण खिसकाएँ। इस पर भरोसा करने से पहले जाँचें diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index ae947667d..483a1944c 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -37,7 +37,8 @@ o melhor foco. Os passos do eixo Z só são oferecidos em máquinas com eixo Z, e tanto os passos do eixo Z quanto o método manual precisam de um controlador G-code, porque usam operações de [Comando](command.md) -entre as linhas. Os deslocamentos são limitados a ±10 mm da altura inicial. +entre as linhas. Os deslocamentos são limitados a ±10 mm da altura inicial. O diálogo começa no +primeiro método que a máquina suporta, na ordem da tabela acima. No método manual, primeiro foque o laser como de costume. Os rótulos são gravados nessa altura. Na primeira pausa, coloque a cabeça no primeiro deslocamento; em cada pausa seguinte, mova-a um passo. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 8697ec7a0..b66cfe29d 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -36,7 +36,8 @@ description: Кроки осі Z пропонуються лише для верстатів з віссю Z, а кроки осі Z і ручний метод потребують G-code-контролера, бо між лініями використовують операції [Команда](command.md). Зміщення обмежені -±10 мм від початкової висоти. +±10 мм від початкової висоти. Діалог починається з першого методу, який підтримує машина, у порядку +таблиці вище. Для ручного методу спочатку сфокусуйте лазер як зазвичай. Підписи гравіюються на цій висоті. На першій паузі встановіть голівку на перше зміщення, на кожній наступній — зсуньте її на один крок. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index f508fa2ef..a0da772e6 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -23,7 +23,7 @@ description: "使用线间距测试和焦距测试,找到最适合雕刻的线 | **斜坡(倾斜材料)** | 一条带距离刻度的长线;你把平板条的一端垫高 | Z 轴步进仅在具有 Z 轴的机器上提供;Z 轴步进和手动方法都需要 G-code 控制器,因为它们在线之间使用[命令](command.md)操作。偏移限制在起始高度的 ±10 -mm 以内。 +mm 以内。对话框会按上表的顺序,默认选中机器支持的第一种方法。 使用手动方法时,先像平常一样对焦激光。标签在该高度雕刻。第一次暂停时,把激光头调到起始偏移;之后每次暂停时移动一个步进。在依赖此方法之前,请确认你的控制器会在 `M0` 处停止,并且“继续”按钮可以继续作业,例如以 0% 功率空跑一次。 From 2e5dc9eac3ae086ad3d3d03eeeddcfb6e1567852 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sat, 17 Oct 2026 13:54:58 +0200 Subject: [PATCH 12/28] test(laser): type the Focus Test's mode row as a ComboRow row_for() returns an Adw.PreferencesRow, which has no set_selected, so pyright rejected the call that switches the dialog to RAMP. --- .../tests/ui_gtk/test_calibration_test_dialogs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py index e7f3f196d..9d9cd5086 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py @@ -1,12 +1,15 @@ # flake8: noqa: E402 """UI tests for the Interval Test and Focus Test dialogs.""" +from typing import cast + import gi import pytest gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") +from gi.repository import Adw from laser_essentials.calibration_tests import ( FocusMode, FocusTestParams, @@ -72,7 +75,7 @@ def test_focus_dialog_modes(laser_machine): assert not _row(dialog, "ramp_length").get_visible() manual_help = dialog.steps_widget.get_description() - _row(dialog, "mode").set_selected(1) + cast(Adw.ComboRow, _row(dialog, "mode")).set_selected(1) assert dialog.mode == FocusMode.RAMP assert not _row(dialog, "step").get_visible() assert _row(dialog, "ramp_length").get_visible() From 6b01844acdbc9a0a2ce03afa5eaf0e7df37ae8ca Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Sat, 17 Oct 2026 14:59:05 +0200 Subject: [PATCH 13/28] fix(laser): link generated steps through workpiece_uids Step.generated_workpiece_uid became a legacy serialization key: the grid and calibration steps now link to their workpiece through workpiece_uids, and nothing reads the old attribute anymore. Assigning it left the interval and focus test steps unlinked after a reload. Fixes the pyright error and the failing backend tests on this branch after the rebase onto main. --- .../laser_essentials/commands/calibration_test_cmd.py | 6 +++--- tests/doceditor/test_calibration_tests.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py index 028b57db6..243b5faaf 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py @@ -2,8 +2,8 @@ Creates Interval Tests and Focus Tests as ordinary project content. Each test becomes its own layer. Every cell or line is a workpiece -with its own step, linked through ``Step.generated_workpiece_uid`` the -same way the Material Test Grid links its step, so each one keeps its +with its own step, linked through ``Step.workpiece_uids`` the same +way the Material Test Grid links its step, so each one keeps its own settings and stays editable in the step settings. Labels share one workpiece with one low-power contour step. The Focus Test puts Command steps (``M0`` pauses or relative Z moves) between its lines. @@ -334,7 +334,7 @@ def _update_overscan(self, step: Step): @staticmethod def _link(layer: Layer, step: Step, wp: WorkPiece): - step.generated_workpiece_uid = wp.uid + step.workpiece_uids = [wp.uid] assert layer.workflow is not None layer.workflow.add_step(step) diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index eb26fedb1..2cbc89056 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -120,7 +120,8 @@ async def test_interval_test_layer(doc_editor, machine): workpieces = {wp.uid: wp for wp in layer.all_workpieces} assert len(workpieces) == 4 for step in steps: - assert step.generated_workpiece_uid in workpieces + assert step.workpiece_uids + assert step.workpiece_uids[0] in workpieces intervals = [s.line_interval_mm for s in steps[1:]] assert intervals == pytest.approx([0.05, 0.15, 0.25]) assert [s.power for s in steps[1:]] == pytest.approx([0.3] * 3) From 0f44fb3db2ba6ca611101a0d39c3075e29c5e63a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:21:11 +0200 Subject: [PATCH 14/28] refactor(laser): build all calibration tests with one generator The Material Test Grid, the Interval Test and the Focus Test now share one architecture and behave the same: - A CalibrationGenerator turns its parameters into a layout: an ordered list of cells (one shape plus the step that processes only that shape) and machine commands between them. Generators do not touch the document. - CalibrationBuilder turns a layout into workpieces, steps and source assets; CalibrationCmd adds them on a new layer named after the test and rebuilds them when a parameter changes, each in one undo step. A rebuilt test keeps its place and stays on the work area. Steps the user added to the layer stay. - A hidden CalibrationRecord asset keeps the generator, its parameters and the uids of what it made, so the test can be rebuilt after saving and loading. A Material Test Grid from an older project gets a record on its first change. - Tools -> Create ... adds the test with default settings and selects it. Its settings show under Test Settings in the item properties panel instead of a modal dialog, so the test stays in view. - The Material Test Grid's grid, label and range settings moved from its step settings to Test Settings, with the presets, the mode-dependent row titles, the Speed vs Offset defaults and the machine speed limit. Its G-code is unchanged. Refs #519 --- .../laser_essentials/calibration/__init__.py | 49 ++ .../laser_essentials/calibration/builder.py | 185 ++++++ .../laser_essentials/calibration/common.py | 103 ++++ .../calibration/focus_test.py | 378 ++++++++++++ .../laser_essentials/calibration/generator.py | 122 ++++ .../calibration/interval_test.py | 159 +++++ .../laser_essentials/calibration/layout.py | 125 ++++ .../laser_essentials/calibration/lookup.py | 87 +++ .../calibration/material_test_grid.py | 256 ++++++++ .../laser_essentials/calibration/record.py | 109 ++++ .../laser_essentials/calibration_tests.py | 190 ------ .../laser_essentials/commands/__init__.py | 6 +- .../commands/calibration_cmd.py | 255 ++++++++ .../commands/calibration_test_cmd.py | 400 ------------ .../commands/material_test_cmd.py | 205 ------- .../laser_essentials/frontend.py | 110 ++-- .../widgets/calibration_provider.py | 228 +++++++ .../widgets/calibration_test_dialogs.py | 355 ----------- .../widgets/material_test_grid_page.py | 305 +--------- .../laser_essentials/widgets/tuple_adapter.py | 4 + .../laser_essentials/worker.py | 10 + .../tests/test_calibration_generators.py | 435 ++++++++++++++ .../tests/test_calibration_test_params.py | 110 ---- .../tests/ui_gtk/test_calibration_provider.py | 335 +++++++++++ .../ui_gtk/test_calibration_test_dialogs.py | 102 ---- .../tests/ui_gtk/test_laser_pages.py | 203 +------ tests/doceditor/test_calibration_tests.py | 567 +++++++++++++++--- .../test_calibration_test_settings.py | 140 +++++ 28 files changed, 3536 insertions(+), 1997 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/__init__.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/common.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/interval_test.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/layout.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/material_test_cmd.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py delete mode 100644 rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py create mode 100644 tests/ui_gtk/doceditor/test_calibration_test_settings.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/__init__.py new file mode 100644 index 000000000..b91531733 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/__init__.py @@ -0,0 +1,49 @@ +""" +Calibration tests: the Material Test Grid, the Interval Test and the +Focus Test share one generator architecture. + +Each test is a :class:`CalibrationGenerator` that turns its parameters +into a :class:`CalibrationLayout`. ``CalibrationCmd`` builds the layout +into its own layer and rebuilds it when the parameters change; the +parameters are kept in a hidden :class:`CalibrationRecord` asset. +""" + +from .focus_test import FocusMode, FocusTestGenerator +from .generator import CalibrationGenerator +from .interval_test import IntervalTestGenerator +from .layout import ( + CalibrationLayout, + CellEntry, + CommandEntry, + FilledRect, + ProceduralShape, + VectorShape, +) +from .material_test_grid import MaterialTestGridGenerator +from .record import CalibrationRecord, CalibrationState + +GENERATORS: dict[str, CalibrationGenerator] = { + generator.id: generator + for generator in ( + MaterialTestGridGenerator(), + IntervalTestGenerator(), + FocusTestGenerator(), + ) +} + +__all__ = [ + "GENERATORS", + "CalibrationGenerator", + "CalibrationLayout", + "CalibrationRecord", + "CalibrationState", + "CellEntry", + "CommandEntry", + "FilledRect", + "FocusMode", + "FocusTestGenerator", + "IntervalTestGenerator", + "MaterialTestGridGenerator", + "ProceduralShape", + "VectorShape", +] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py new file mode 100644 index 000000000..8b9820d98 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py @@ -0,0 +1,185 @@ +""" +Turns a :class:`CalibrationLayout` into workpieces, steps and assets. + +The builder creates the items but does not add them to the document; +their source assets are hidden, since they belong to the test and are +replaced when it is rebuilt; +``CalibrationCmd`` does that in one undoable transaction. Items are +created at their test-local position and then moved together, so the +conversion from test-local to world coordinates happens in one place +(:meth:`BuiltTest.move_to`). +""" + +import logging +from dataclasses import dataclass, field +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.source_asset import SourceAsset +from rayforge.core.step import Step +from rayforge.core.step_registry import step_registry +from rayforge.core.vectorization_spec import PassthroughSpec, ProceduralSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image.procedural import ProceduralImporter +from rayforge.image.svg.exporter import GeometrySvgExporter +from rayforge.image.svg.importer import SvgImporter + +from .layout import ( + CalibrationLayout, + CellEntry, + CommandEntry, + FilledRect, + ProceduralShape, + Shape, +) + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + +logger = logging.getLogger(__name__) + +Rect = tuple[float, float, float, float] + + +@dataclass +class BuiltTest: + """The items of one test, before they are added to the document.""" + + workpieces: list[WorkPiece] = field(default_factory=list) + steps: list[Step] = field(default_factory=list) + assets: list[SourceAsset] = field(default_factory=list) + + def bounds(self) -> Rect: + """The world bounding box (min x, min y, max x, max y).""" + return bounds_of(self.workpieces) + + def move_to(self, x: float, y: float) -> None: + """Moves the test so its bottom-left corner is at (x, y).""" + min_x, min_y, _max_x, _max_y = self.bounds() + dx, dy = x - min_x, y - min_y + for wp in self.workpieces: + wp.pos = (wp.pos[0] + dx, wp.pos[1] + dy) + + def keep_inside(self, width: float, height: float) -> None: + """ + Moves the test back onto an area of *width* × *height* mm where + it sticks out, as far as it fits. + """ + min_x, min_y, max_x, max_y = self.bounds() + x = max(0.0, min(min_x, width - (max_x - min_x))) + y = max(0.0, min(min_y, height - (max_y - min_y))) + self.move_to(x, y) + + def center_on(self, width: float, height: float) -> None: + """Centers the test on an area of *width* × *height* mm.""" + min_x, min_y, max_x, max_y = self.bounds() + self.move_to( + (width - (max_x - min_x)) / 2, (height - (max_y - min_y)) / 2 + ) + + +def bounds_of(workpieces: list[WorkPiece]) -> Rect: + rects = [] + for wp in workpieces: + world = wp.get_world_geometry() + if world is not None and not world.is_empty(): + rects.append(world.rect()) + else: + x, y, width, height = wp.bbox + rects.append((x, y, x + width, y + height)) + return ( + min(r[0] for r in rects), + min(r[1] for r in rects), + max(r[2] for r in rects), + max(r[3] for r in rects), + ) + + +def filled_rect_svg(width: float, height: float) -> bytes: + """An SVG of a black rectangle of *width* × *height* mm.""" + return ( + f'' + f'' + f"" + ).encode() + + +class CalibrationBuilder: + """Creates document items for a calibration layout.""" + + def __init__(self, context: "RayforgeContext"): + self._context = context + + def build(self, layout: CalibrationLayout) -> BuiltTest: + built = BuiltTest() + for entry in layout.entries: + if isinstance(entry, CommandEntry): + built.steps.append(self._command_step(entry)) + continue + assert isinstance(entry, CellEntry) + source, wp = self._workpiece(entry.name, entry.shape) + entry.step.workpiece_uids = [wp.uid] + built.workpieces.append(wp) + built.steps.append(entry.step) + built.assets.append(source) + return built + + def _command_step(self, entry: CommandEntry) -> Step: + step_cls: Any = step_registry.get("CommandStep") + if step_cls is None: + raise ValueError(_("Machine commands need the Automation addon.")) + step = step_cls.create(self._context, name=entry.name) + step.set_command_text("\n".join(entry.lines)) + return step + + def _workpiece( + self, name: str, shape: Shape + ) -> tuple[SourceAsset, WorkPiece]: + if isinstance(shape, ProceduralShape): + source, wp, target = self._procedural(name, shape) + elif isinstance(shape, FilledRect): + data = filled_rect_svg(shape.width, shape.height) + source, wp = self._import_svg(name, data) + target = (shape.x, shape.y) + else: + data = GeometrySvgExporter(shape.geometry).export() + source, wp = self._import_svg(name, data) + target = shape.geometry.rect()[:2] + source.name = name + source.hidden = True + wp.name = name + min_x, min_y, _max_x, _max_y = bounds_of([wp]) + wp.pos = ( + wp.pos[0] + target[0] - min_x, + wp.pos[1] + target[1] - min_y, + ) + return source, wp + + @staticmethod + def _import_svg(name: str, data: bytes) -> tuple[SourceAsset, WorkPiece]: + result = SvgImporter(data).get_doc_items(PassthroughSpec()) + if not result or not result.payload or not result.payload.items: + raise RuntimeError(f"Could not create the {name} workpiece.") + wp = result.payload.items[0] + assert isinstance(wp, WorkPiece) + return result.payload.source, wp + + @staticmethod + def _procedural( + name: str, shape: ProceduralShape + ) -> tuple[SourceAsset, WorkPiece, tuple[float, float]]: + draw = shape.draw_function + size = shape.size_function + importer = ProceduralImporter( + drawing_function_path=f"{draw.__module__}.{draw.__name__}", + size_function_path=f"{size.__module__}.{size.__name__}", + params=shape.params, + name=name, + ) + result = importer.get_doc_items(ProceduralSpec()) + if not result or not result.payload or not result.payload.items: + raise RuntimeError(f"Could not create the {name} workpiece.") + wp = result.payload.items[0] + assert isinstance(wp, WorkPiece) + return result.payload.source, wp, shape.origin diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/common.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/common.py new file mode 100644 index 000000000..e83b0bca2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/common.py @@ -0,0 +1,103 @@ +"""Settings and building blocks shared by the Interval and Focus Tests.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry + +from rayforge.core.varset import BoolVar, SliderFloatVar, SpeedVar, Var + +from .generator import contour_step +from .layout import CellEntry, VectorShape + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + +#: Gap between a cell or line and its label, in label heights. +LABEL_GAP = 0.6 +LABEL_HEIGHT = 2.5 + + +def labels_active(values: dict[str, Any]) -> bool: + return bool(values.get("include_labels")) + + +def _speed_limit(context: "RayforgeContext") -> int | None: + machine = context.machine + return int(machine.max_cut_speed) if machine is not None else None + + +def _capped(speed: int, limit: int | None) -> int: + return speed if limit is None else min(speed, limit) + + +def laser_vars( + context: "RayforgeContext", power_percent: float, speed: int +) -> list[Var]: + """Power and speed of the test, capped at the machine's speed.""" + limit = _speed_limit(context) + return [ + SliderFloatVar( + key="power_percent", + label=_("Power"), + default=power_percent, + min_val=0.0, + max_val=100.0, + show_value=True, + format_suffix="%", + ), + SpeedVar( + key="speed", + label=_("Speed"), + default=_capped(speed, limit), + min_val=1, + max_val=limit, + ), + ] + + +def label_vars(context: "RayforgeContext") -> list[Var]: + limit = _speed_limit(context) + return [ + BoolVar( + key="include_labels", + label=_("Include Labels"), + default=True, + ), + SliderFloatVar( + key="label_power_percent", + label=_("Label Engrave Power (%)"), + default=10.0, + min_val=0.0, + max_val=100.0, + show_value=True, + format_suffix="%", + sensitive_when=labels_active, + ), + SpeedVar( + key="label_speed", + label=_("Label Engrave Speed"), + default=_capped(1000, limit), + min_val=1, + max_val=limit, + sensitive_when=labels_active, + ), + ] + + +def label_block_height(params: dict[str, Any], lines: int) -> float: + """Height reserved below the cells for *lines* lines of labels.""" + if not params["include_labels"]: + return 0.0 + return LABEL_HEIGHT * (LABEL_GAP + lines + (lines - 1) * 0.5) + + +def labels_cell( + context: "RayforgeContext", params: dict[str, Any], geometry: Geometry +) -> CellEntry: + """All labels of a test as one workpiece with a low-power step.""" + step = contour_step( + context, params["label_power_percent"], params["label_speed"] + ) + step.name = _("Labels") + return CellEntry(_("Labels"), VectorShape(geometry), step) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py new file mode 100644 index 000000000..1c9fa3b91 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py @@ -0,0 +1,378 @@ +""" +The Focus Test: lines engraved at different focus heights, or one long +line on tilted material. + +Between the lines it runs machine commands: relative Z moves on +machines with a Z axis, or ``M0`` pauses so the head can be moved by +hand. A positive offset means more distance between the head and the +material. +""" + +from enum import Enum +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry + +from rayforge.core.step_registry import step_registry +from rayforge.core.varset import ( + IntVar, + LabeledChoiceVar, + LengthVar, + VarSet, +) +from rayforge.machine.driver import get_driver_cls + +from .common import ( + LABEL_GAP, + LABEL_HEIGHT, + label_block_height, + label_vars, + labels_cell, + laser_vars, +) +from .generator import CalibrationGenerator, contour_step +from .layout import ( + CalibrationLayout, + CellEntry, + CommandEntry, + VectorShape, + line, + row_positions, + text, +) + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + +#: The test never moves the head further than this from the height it +#: starts at, so a typo cannot drive it into the material. +MAX_FOCUS_OFFSET_MM = 10.0 +#: Spacing and length of the distance ticks along the ramp, in mm. +RAMP_TICK_MM = 10.0 +RAMP_TICK_LENGTH = 2.0 + + +class FocusMode(Enum): + """How the focus height changes between the lines.""" + + Z_AXIS = "z_axis" + MANUAL = "manual" + RAMP = "ramp" + + @property + def label(self) -> str: + labels = { + FocusMode.Z_AXIS: _("Z axis steps"), + FocusMode.MANUAL: _("Manual (pause between lines)"), + FocusMode.RAMP: _("Ramp (tilted material)"), + } + return labels[self] + + @property + def help_text(self) -> str: + texts = { + FocusMode.Z_AXIS: _( + "Focus the laser as usual first. The job moves the head " + "to the first offset, engraves one line per offset and " + "returns the head to the starting height. A positive " + "offset means more distance between the head and the " + "material." + ), + FocusMode.MANUAL: _( + "Focus the laser as usual first. The labels are engraved " + "at that height. Before each line the job pauses: set the " + "head to the first offset, then move it by one step at " + "every following pause, and press Resume. A positive " + "offset means more distance between the head and the " + "material." + ), + FocusMode.RAMP: _( + "Prop up one end of a flat strip so it rises evenly under " + "the line. The thinnest part of the line marks the focus; " + "the ticks show the distance along the line, so the " + "height there follows from the rise." + ), + } + return texts[self] + + +def available_modes(context: "RayforgeContext") -> list[FocusMode]: + """ + The modes the current machine supports, in order of preference: Z + axis steps need a Z axis, and both Z and manual mode need a G-code + driver and the Command step (Automation addon). + """ + modes = [FocusMode.RAMP] + machine = context.machine + if machine is None or step_registry.get("CommandStep") is None: + return modes + if not get_driver_cls(machine.driver_name or "").uses_gcode: + return modes + modes.insert(0, FocusMode.MANUAL) + if machine.has_z_axis: + modes.insert(0, FocusMode.Z_AXIS) + return modes + + +def focus_offsets(params: dict[str, Any]) -> list[float]: + """The focus offset of every line.""" + count = int(params["count"]) + if count < 2: + raise ValueError(_("A Focus Test needs at least two lines.")) + if params["step"] == 0: + raise ValueError(_("The focus step must not be zero.")) + offsets = [ + params["start_offset"] + i * params["step"] for i in range(count) + ] + if max(abs(offsets[0]), abs(offsets[-1])) > MAX_FOCUS_OFFSET_MM: + raise ValueError( + _( + "The focus offsets must stay within ±{limit:g} mm of " + "the starting height." + ).format(limit=MAX_FOCUS_OFFSET_MM) + ) + return offsets + + +def format_offset(offset: float) -> str: + """A focus offset as shown in labels, e.g. ``+0.5`` or ``-2.0``.""" + if abs(offset) < 1e-9: + return "0.0" + return f"{offset:+.1f}" + + +def _number(value: float) -> str: + result = f"{value:.4f}".rstrip("0").rstrip(".") + return "0" if result in ("-0", "") else result + + +def _z_move(distance: float, reverse_z: bool) -> list[str]: + """A relative Z move that leaves the controller in G90.""" + if reverse_z: + distance = -distance + return ["G91", f"G0 Z{_number(distance)}", "G90"] + + +def focus_commands( + mode: FocusMode, params: dict[str, Any], reverse_z: bool +) -> dict[int, CommandEntry]: + """ + The commands that run right before line *index* (an index equal to + the line count means after the last line). + """ + offsets = focus_offsets(params) + if mode == FocusMode.Z_AXIS: + commands: dict[int, CommandEntry] = {} + previous = 0.0 + for index, offset in enumerate(offsets): + name = _("Z to {offset} mm").format(offset=format_offset(offset)) + lines = _z_move(offset - previous, reverse_z) + commands[index] = CommandEntry(name, lines) + previous = offset + commands[len(offsets)] = CommandEntry( + _("Z back to the starting height"), _z_move(-previous, reverse_z) + ) + return commands + if mode == FocusMode.MANUAL: + first = _("Pause: set the head to {offset} mm from focus").format( + offset=format_offset(offsets[0]) + ) + later = _("Pause: move the head by {step} mm").format( + step=format_offset(params["step"]) + ) + return { + index: CommandEntry(first if index == 0 else later, ["M0"]) + for index in range(len(offsets)) + } + return {} + + +def _is_ramp(values: dict[str, Any]) -> bool: + return values.get("mode") == FocusMode.RAMP.value + + +def _is_not_ramp(values: dict[str, Any]) -> bool: + return not _is_ramp(values) + + +class FocusTestGenerator(CalibrationGenerator): + id = "focus_test" + + @property + def title(self) -> str: + return _("Focus Test") + + def varset(self, context: "RayforgeContext") -> VarSet: + modes = available_modes(context) + limit = MAX_FOCUS_OFFSET_MM + return VarSet( + vars=[ + LabeledChoiceVar( + key="mode", + label=_("Method"), + choices=[(m.label, m.value) for m in modes], + default=modes[0].value, + allow_none=False, + ), + LengthVar( + key="start_offset", + label=_("First Offset"), + default=-2.0, + min_val=-limit, + max_val=limit, + digits=2, + visible_when=_is_not_ramp, + ), + LengthVar( + key="step", + label=_("Step"), + default=0.5, + min_val=-limit, + max_val=limit, + digits=2, + visible_when=_is_not_ramp, + ), + IntVar( + key="count", + label=_("Number of Lines"), + default=9, + min_val=2, + max_val=41, + visible_when=_is_not_ramp, + ), + LengthVar( + key="line_length", + label=_("Line Length"), + default=15.0, + min_val=1.0, + max_val=200.0, + visible_when=_is_not_ramp, + ), + LengthVar( + key="spacing", + label=_("Spacing"), + default=6.0, + min_val=0.5, + max_val=50.0, + visible_when=_is_not_ramp, + ), + LengthVar( + key="ramp_length", + label=_("Ramp Length"), + default=100.0, + min_val=10.0, + max_val=1000.0, + visible_when=_is_ramp, + ), + *laser_vars(context, 30.0, 1500), + *label_vars(context), + ] + ) + + def is_available(self, context: "RayforgeContext") -> bool: + return context.machine is not None + + def help_text(self, params: dict[str, Any]) -> str: + return FocusMode(params["mode"]).help_text + + def layout( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> CalibrationLayout: + mode = FocusMode(params["mode"]) + if mode not in available_modes(context): + raise ValueError( + _( + "This machine does not support the focus test mode {mode}." + ).format(mode=mode.label) + ) + if mode == FocusMode.RAMP: + return self._ramp(params, context) + return self._lines(mode, params, context) + + def _lines( + self, + mode: FocusMode, + params: dict[str, Any], + context: "RayforgeContext", + ) -> CalibrationLayout: + offsets = focus_offsets(params) + xs = row_positions(len(offsets), 0.0, params["spacing"]) + line_y = label_block_height(params, lines=2) + result = CalibrationLayout() + if params["include_labels"]: + labels = self._line_labels(params, xs, offsets, line_y) + result.entries.append(labels_cell(context, params, labels)) + machine = context.machine + reverse_z = bool(machine and machine.reverse_z_axis) + commands = focus_commands(mode, params, reverse_z) + for index, (x, offset) in enumerate(zip(xs, offsets)): + if index in commands: + result.entries.append(commands[index]) + name = _("Focus {offset} mm").format(offset=format_offset(offset)) + step = contour_step( + context, params["power_percent"], params["speed"] + ) + step.name = name + shape = VectorShape( + line(x, line_y, x, line_y + params["line_length"]) + ) + result.entries.append(CellEntry(name, shape, step)) + if len(offsets) in commands: + result.entries.append(commands[len(offsets)]) + return result + + @staticmethod + def _line_labels( + params: dict[str, Any], + xs: list[float], + offsets: list[float], + line_y: float, + ) -> Geometry: + """ + One label per line. Labels alternate between two rows, so each + may be almost twice as wide as the line spacing. + """ + labels = Geometry() + top = line_y - LABEL_GAP * LABEL_HEIGHT + for index, (x, offset) in enumerate(zip(xs, offsets)): + row_top = top - (index % 2) * LABEL_HEIGHT * 1.5 + labels.extend( + text( + format_offset(offset), + LABEL_HEIGHT, + x, + row_top, + params["spacing"] * 1.8, + ) + ) + return labels + + def _ramp( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> CalibrationLayout: + length = params["ramp_length"] + line_y = label_block_height(params, lines=1) + RAMP_TICK_LENGTH + result = CalibrationLayout() + if params["include_labels"]: + labels = Geometry() + for i in range(int(length // RAMP_TICK_MM) + 1): + x = i * RAMP_TICK_MM + labels.extend( + line(x, line_y - RAMP_TICK_LENGTH, x, line_y - 0.5) + ) + labels.extend( + text( + f"{x:g}", + LABEL_HEIGHT, + x, + line_y - RAMP_TICK_LENGTH - 0.5, + ) + ) + result.entries.append(labels_cell(context, params, labels)) + name = _("Focus ramp") + step = contour_step(context, params["power_percent"], params["speed"]) + step.name = name + shape = VectorShape(line(0.0, line_y, length, line_y)) + result.entries.append(CellEntry(name, shape, step)) + return result diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py new file mode 100644 index 000000000..f50fd6229 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py @@ -0,0 +1,122 @@ +""" +The base class of all calibration test generators. + +A generator is pure: from its parameters and the machine it computes a +:class:`CalibrationLayout`, without touching the document or the UI. +``CalibrationBuilder`` turns that layout into document content and +``CalibrationCmd`` keeps it up to date when the parameters change. +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast + +from rayforge.core.varset import VarSet +from rayforge.pipeline.transformer.registry import transformer_registry + +from ..steps import ContourStep, EngraveStep +from ..steps.laser_step import LaserStep +from .layout import CalibrationLayout + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + + class OverscanTransformerType(Protocol): + @staticmethod + def calculate_auto_distance( + step_speed: int, max_acceleration: int + ) -> float: ... + + +#: Item labels and item subtitles of a two-row tuple setting. +RowLabels = tuple[tuple[str, str], tuple[str, str]] + + +class CalibrationGenerator(ABC): + """Describes one kind of calibration test.""" + + id: ClassVar[str] + + @property + @abstractmethod + def title(self) -> str: + """The name of the test, also used for its layer.""" + + @abstractmethod + def varset(self, context: "RayforgeContext") -> VarSet: + """The settings of the test, with their defaults.""" + + @abstractmethod + def layout( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> CalibrationLayout: + """ + The test for *params*. Raises ``ValueError`` with a message for + the user when the parameters do not make a valid test. + """ + + def defaults(self, context: "RayforgeContext") -> dict[str, Any]: + """The parameters of a new test.""" + return self.varset(context).get_values() + + def is_available(self, context: "RayforgeContext") -> bool: + """Whether the current machine can run this test.""" + return context.machine is not None + + def apply_change( + self, + params: dict[str, Any], + keys: set[str], + context: "RayforgeContext", + ) -> dict[str, Any]: + """ + Follow-up changes after the user edited *keys*, such as filling + in a preset. Returns the parameters to use. + """ + return params + + def row_labels(self, params: dict[str, Any]) -> dict[str, RowLabels]: + """Item labels and subtitles of tuple rows that depend on params.""" + return {} + + def help_text(self, params: dict[str, Any]) -> str: + """Instructions shown with the settings.""" + return "" + + +def contour_step( + context: "RayforgeContext", power_percent: float, speed: float +) -> ContourStep: + step = ContourStep.create(context) + set_laser(step, power_percent, speed) + return step + + +def engrave_step( + context: "RayforgeContext", power_percent: float, speed: float +) -> EngraveStep: + step = EngraveStep.create(context) + set_laser(step, power_percent, speed) + _update_overscan(context, step) + return step + + +def set_laser(step: LaserStep, power_percent: float, speed: float): + step.set_power(power_percent / 100.0) + step.set_cut_speed(int(speed)) + + +def _update_overscan(context: "RayforgeContext", step: LaserStep): + """Recomputes the automatic overscan for the step's speed.""" + machine = context.machine + overscan = cast( + "OverscanTransformerType | None", + transformer_registry.get("OverscanTransformer"), + ) + if machine is None or overscan is None: + return + distance = overscan.calculate_auto_distance( + step.cut_speed, machine.acceleration + ) + for spec in step.per_workpiece_transformers_dicts: + if spec.get("name") == "OverscanTransformer" and spec.get("auto"): + spec["distance_mm"] = distance diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/interval_test.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/interval_test.py new file mode 100644 index 000000000..fdf603c6b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/interval_test.py @@ -0,0 +1,159 @@ +""" +The Interval Test: a row of filled squares, each engraved with its own +line interval at the same power and speed. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from raygeo.geo import Geometry + +from rayforge.core.varset import IntVar, LengthVar, VarSet + +from .common import ( + LABEL_GAP, + LABEL_HEIGHT, + label_block_height, + label_vars, + labels_cell, + laser_vars, +) +from .generator import CalibrationGenerator, engrave_step +from .layout import ( + CalibrationLayout, + CellEntry, + FilledRect, + row_positions, + text, +) + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + +MM_PER_INCH = 25.4 + + +def interval_values(params: dict[str, Any]) -> list[float]: + """The line interval of every cell, evenly spread over the range.""" + count = int(params["count"]) + if count < 2: + raise ValueError(_("An Interval Test needs at least two cells.")) + low, high = params["min_interval"], params["max_interval"] + if low <= 0 or high <= low: + raise ValueError( + _( + "The minimum line interval must be above zero and below " + "the maximum." + ) + ) + step = (high - low) / (count - 1) + return [low + i * step for i in range(count)] + + +def lines_per_inch(interval_mm: float) -> float: + """The line density (LPI) that matches a line interval in mm.""" + return MM_PER_INCH / interval_mm + + +class IntervalTestGenerator(CalibrationGenerator): + id = "interval_test" + + @property + def title(self) -> str: + return _("Interval Test") + + def varset(self, context: "RayforgeContext") -> VarSet: + return VarSet( + vars=[ + IntVar( + key="count", + label=_("Number of Cells"), + default=5, + min_val=2, + max_val=20, + ), + LengthVar( + key="min_interval", + label=_("Smallest Interval"), + default=0.05, + min_val=0.01, + max_val=5.0, + digits=3, + ), + LengthVar( + key="max_interval", + label=_("Largest Interval"), + default=0.25, + min_val=0.01, + max_val=5.0, + digits=3, + ), + LengthVar( + key="cell_size", + label=_("Cell Size"), + default=10.0, + min_val=1.0, + max_val=100.0, + ), + LengthVar( + key="spacing", + label=_("Spacing"), + default=5.0, + min_val=0.0, + max_val=50.0, + ), + *laser_vars(context, 30.0, 3000), + *label_vars(context), + ] + ) + + def help_text(self, params: dict[str, Any]) -> str: + return _( + "Engraves a row of filled squares, each with its own line " + "interval, at the same power and speed." + ) + + def layout( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> CalibrationLayout: + intervals = interval_values(params) + size = params["cell_size"] + xs = row_positions(len(intervals), size, params["spacing"]) + cell_y = label_block_height(params, lines=2) + result = CalibrationLayout() + if params["include_labels"]: + labels = self._labels(params, xs, intervals, cell_y) + result.entries.append(labels_cell(context, params, labels)) + for x, interval in zip(xs, intervals): + name = _("Interval {interval:.2f} mm").format(interval=interval) + step = engrave_step( + context, params["power_percent"], params["speed"] + ) + step.name = name + step.line_interval_mm = interval + step.depth_mode = "CONSTANT_POWER" + shape = FilledRect(x, cell_y, size, size) + result.entries.append(CellEntry(name, shape, step)) + return result + + @staticmethod + def _labels( + params: dict[str, Any], + xs: list[float], + intervals: list[float], + cell_y: float, + ) -> Geometry: + size = params["cell_size"] + top = cell_y - LABEL_GAP * LABEL_HEIGHT + fit = (size + params["spacing"]) * 0.9 + labels = Geometry() + for x, interval in zip(xs, intervals): + center = x + size / 2 + labels.extend( + text(f"{interval:.2f}", LABEL_HEIGHT, center, top, fit) + ) + lpi = _("{lpi:.0f} LPI").format(lpi=lines_per_inch(interval)) + labels.extend( + text(lpi, LABEL_HEIGHT, center, top - LABEL_HEIGHT * 1.5, fit) + ) + return labels diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/layout.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/layout.py new file mode 100644 index 000000000..20c323cdf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/layout.py @@ -0,0 +1,125 @@ +""" +The layout a calibration generator produces, and the geometry helpers +the generators share. + +A layout is an ordered list of entries. The order is the order in which +the job runs them. All geometry is in test-local millimeters, Y up, with +the origin at the bottom-left corner of the test; the builder moves the +whole test to its place on the work area. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from raygeo.geo import Geometry, Matrix +from raygeo.geo.shape.text import FontConfig, text_to_geometry + +from rayforge.core.step import Step + + +@dataclass +class VectorShape: + """Vector outlines of a cell, such as lines or label text.""" + + geometry: Geometry + + +@dataclass +class FilledRect: + """A filled rectangle, engraved as an area.""" + + x: float + y: float + width: float + height: float + + +@dataclass +class ProceduralShape: + """ + Content drawn by a function, such as the Material Test Grid preview. + *origin* is its bottom-left corner in test-local coordinates. + """ + + draw_function: Callable[..., Any] + size_function: Callable[[dict[str, Any]], tuple[float, float]] + params: dict[str, Any] + origin: tuple[float, float] = (0.0, 0.0) + + +Shape = VectorShape | FilledRect | ProceduralShape + + +@dataclass +class CellEntry: + """One workpiece and the step that processes only that workpiece.""" + + name: str + shape: Shape + step: Step + + +@dataclass +class CommandEntry: + """Machine commands that run between two cells.""" + + name: str + lines: list[str] + + +Entry = CellEntry | CommandEntry + + +@dataclass +class CalibrationLayout: + """The entries of a test, in execution order.""" + + entries: list[Entry] = field(default_factory=list) + + @property + def cells(self) -> list[CellEntry]: + return [e for e in self.entries if isinstance(e, CellEntry)] + + @property + def commands(self) -> list[CommandEntry]: + return [e for e in self.entries if isinstance(e, CommandEntry)] + + +def row_positions(count: int, size: float, spacing: float) -> list[float]: + """The offsets of *count* items of *size* placed in a row.""" + return [i * (size + spacing) for i in range(count)] + + +def line(x0: float, y0: float, x1: float, y1: float) -> Geometry: + geo = Geometry() + geo.move_to(x0, y0) + geo.line_to(x1, y1) + return geo + + +def text( + content: str, + height: float, + center_x: float, + top: float, + max_width: float | None = None, +) -> Geometry: + """ + Glyph outlines of *content*, *height* mm tall and centered below + *top*, scaled down further when needed to fit in *max_width*. + """ + geo = text_to_geometry(content, FontConfig(size=10.0)) + min_x, min_y, max_x, max_y = geo.rect() + scale = height / max(max_y - min_y, 1e-9) + if max_width is not None and (max_x - min_x) * scale > max_width: + scale = max_width / max(max_x - min_x, 1e-9) + width = (max_x - min_x) * scale + text_height = (max_y - min_y) * scale + matrix = ( + Matrix.translation(center_x - width / 2, top - text_height) + @ Matrix.scale(scale, scale) + @ Matrix.translation(-min_x, -min_y) + ) + geo.transform(matrix) + return geo diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py new file mode 100644 index 000000000..e9bec15c8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py @@ -0,0 +1,87 @@ +"""Finds the calibration test that selected workpieces belong to.""" + +from typing import TYPE_CHECKING, cast + +from rayforge.core.item import DocItem +from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece + +from .material_test_grid import ( + NO_PRESET, + MaterialTestGridGenerator, + params_from_step, +) +from .record import CalibrationRecord, CalibrationState + +if TYPE_CHECKING: + from rayforge.core.doc import Doc + + +def record_for_items(items: list[DocItem]) -> CalibrationRecord | None: + """ + The record of the test all *items* belong to, or None. + + A Material Test Grid made by an older version has no record. For it + a record is made from its step; it is not in the document yet and + gets added there on the first change of a parameter. + """ + if not items or not all(isinstance(i, WorkPiece) for i in items): + return None + first = items[0] + doc = first.doc + if doc is None: + return None + record = find_record(doc, first.uid) or _legacy_record(first) + if record is None: + return None + if all(record.owns(item.uid) for item in items): + return record + return None + + +def find_record(doc: "Doc", workpiece_uid: str) -> CalibrationRecord | None: + """ + The record that owns *workpiece_uid*. Records are matched by asset + type name, so records loaded before the addon was reloaded match. + """ + for asset in doc.get_all_assets(): + if asset.asset_type_name != CalibrationRecord.asset_type_name: + continue + record = cast(CalibrationRecord, asset) + if record.owns(workpiece_uid): + return record + return None + + +def _legacy_record(wp: DocItem) -> CalibrationRecord | None: + assert isinstance(wp, WorkPiece) + layer = wp.layer + if layer is None or layer.workflow is None: + return None + for step in layer.workflow.steps: + if ( + step.ASSEMBLER_NAME == "material_test_grid" + and step.workpiece_uids == [wp.uid] + ): + return _record_from_step(step, wp, layer.uid) + return None + + +def _record_from_step( + step: Step, wp: WorkPiece, layer_uid: str +) -> CalibrationRecord: + params = params_from_step(step) + params["preset"] = NO_PRESET + source = wp.source + state = CalibrationState( + params=params, + workpiece_uids=(wp.uid,), + step_uids=(step.uid,), + asset_uids=(source.uid,) if source is not None else (), + ) + return CalibrationRecord( + generator_id=MaterialTestGridGenerator.id, + layer_uid=layer_uid, + state=state, + name=step.name, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py new file mode 100644 index 000000000..f769cfe97 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py @@ -0,0 +1,256 @@ +""" +The Material Test Grid: one procedural workpiece with one Material Test +step that runs every cell of the grid. + +The cells, labels and their risk-optimized order are computed by the +raygeo ``material_test_grid`` assembler; this generator only decides +the parameters and the size of the workpiece. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.core.step import Step +from rayforge.core.varset import LabeledChoiceVar, TupleVar, VarSet + +from ..material_test_helpers import ( + GridMode, + draw_material_test_preview, + get_material_test_proportional_size, +) +from ..steps import MaterialTestStep +from .generator import CalibrationGenerator, RowLabels +from .layout import CalibrationLayout, CellEntry, ProceduralShape + +if TYPE_CHECKING: + from rayforge.context import RayforgeContext + +#: The step attributes that make up the test; the laser settings of the +#: step stay in its own settings dialog. +PARAM_KEYS = ( + "test_type", + "grid_mode", + "speed_range", + "power_range", + "passes_range", + "offset_range", + "fixed_speed", + "fixed_power", + "grid_dimensions", + "shape_size", + "spacing", + "include_labels", + "label_power_percent", + "label_speed", + "line_interval_mm", +) + +NO_PRESET = "none" + +PRESETS: dict[str, dict[str, Any]] = { + "Diode Engrave": { + "test_type": "Engrave", + "speed_range": (1000.0, 10000.0), + "power_range": (10.0, 100.0), + }, + "Diode Cut": { + "test_type": "Cut", + "speed_range": (100.0, 5000.0), + "power_range": (50.0, 100.0), + }, + "CO2 Engrave": { + "test_type": "Engrave", + "speed_range": (3000.0, 20000.0), + "power_range": (10.0, 50.0), + }, + "CO2 Cut": { + "test_type": "Cut", + "speed_range": (1000.0, 20000.0), + "power_range": (30.0, 100.0), + }, +} + +#: Line interval that makes row-to-row misalignment of a bidirectional +#: scan visible by eye in Speed vs Offset mode. +OFFSET_TEST_LINE_INTERVAL = 0.5 + + +#: Rows of the settings in the order of the old settings page: grid, +#: labels, then the ranges the grid varies. +_ROW_ORDER = ( + "test_type", + "grid_mode", + "grid_dimensions", + "shape_size", + "spacing", + "line_interval_mm", + "include_labels", + "label_power_percent", + "label_speed", + "fixed_speed", + "fixed_power", + "power_range", + "speed_range", + "passes_range", + "offset_range", +) + + +def _preset_labels() -> dict[str, str]: + return { + "Diode Engrave": _("Diode Engrave"), + "Diode Cut": _("Diode Cut"), + "CO2 Engrave": _("CO2 Engrave"), + "CO2 Cut": _("CO2 Cut"), + } + + +def _dimension_labels() -> dict[str, RowLabels]: + return { + GridMode.POWER_VS_SPEED.value: ( + (_("Columns (Power Steps)"), _("Rows (Speed Steps)")), + (_("Number of power variations"), _("Number of speed variations")), + ), + GridMode.POWER_VS_PASSES.value: ( + (_("Columns (Power Steps)"), _("Rows (Passes Steps)")), + ( + _("Number of power variations"), + _("Number of passes variations"), + ), + ), + GridMode.SPEED_VS_PASSES.value: ( + (_("Columns (Speed Steps)"), _("Rows (Passes Steps)")), + ( + _("Number of speed variations"), + _("Number of passes variations"), + ), + ), + GridMode.SPEED_VS_OFFSET.value: ( + (_("Columns (Speed Steps)"), _("Rows (Offset Steps)")), + ( + _("Number of speed variations"), + _("Number of offset variations"), + ), + ), + } + + +def params_from_step(step: Step) -> dict[str, Any]: + """The test parameters stored on a Material Test step.""" + params = {key: getattr(step, key, None) for key in PARAM_KEYS} + if params["line_interval_mm"] is None: + params["line_interval_mm"] = 0.0 + return params + + +def _max_speed(context: "RayforgeContext") -> float | None: + machine = context.machine + return float(machine.max_cut_speed) if machine is not None else None + + +class MaterialTestGridGenerator(CalibrationGenerator): + id = "material_test_grid" + + @property + def title(self) -> str: + return _("Material Test Grid") + + def varset(self, context: "RayforgeContext") -> VarSet: + step_vars = {var.key: var for var in MaterialTestStep.recipe_varset()} + speed_range = step_vars["speed_range"] + max_speed = _max_speed(context) + if isinstance(speed_range, TupleVar) and max_speed is not None: + speed_range.max_val = max_speed + labels = _preset_labels() + preset = LabeledChoiceVar( + key="preset", + label=_("Presets"), + description=_("Load common test configurations"), + choices=[(_("Select"), NO_PRESET)] + + [(labels[key], key) for key in PRESETS], + default=NO_PRESET, + allow_none=False, + ) + return VarSet(vars=[preset] + [step_vars[k] for k in _ROW_ORDER]) + + def defaults(self, context: "RayforgeContext") -> dict[str, Any]: + params = super().defaults(context) + max_speed = _max_speed(context) + if max_speed is not None: + low, high = params["speed_range"] + params["speed_range"] = (min(low, max_speed), min(high, max_speed)) + return params + + def apply_change( + self, + params: dict[str, Any], + keys: set[str], + context: "RayforgeContext", + ) -> dict[str, Any]: + result = dict(params) + if "preset" in keys and result.get("preset") in PRESETS: + result.update(PRESETS[result["preset"]]) + max_speed = _max_speed(context) + if max_speed is not None: + low, high = result["speed_range"] + result["speed_range"] = ( + min(low, max_speed), + min(high, max_speed), + ) + if ( + "grid_mode" in keys + and result.get("grid_mode") == GridMode.SPEED_VS_OFFSET.value + ): + result["test_type"] = "Engrave" + result["line_interval_mm"] = OFFSET_TEST_LINE_INTERVAL + return result + + def row_labels(self, params: dict[str, Any]) -> dict[str, RowLabels]: + labels = _dimension_labels() + mode = params.get("grid_mode", GridMode.POWER_VS_SPEED.value) + return { + "grid_dimensions": labels.get( + mode, labels[GridMode.POWER_VS_SPEED.value] + ) + } + + def help_text(self, params: dict[str, Any]) -> str: + return _( + "The laser head and the post-processing of the grid stay in " + "the settings of its operation." + ) + + def layout( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> CalibrationLayout: + grid_params = self._grid_params(params) + step = MaterialTestStep.create(context) + step.name = self.title + for key, value in grid_params.items(): + setattr(step, key, value) + if not step.line_interval_mm: + step.line_interval_mm = None + shape = ProceduralShape( + draw_function=draw_material_test_preview, + size_function=get_material_test_proportional_size, + params=grid_params, + ) + return CalibrationLayout([CellEntry(self.title, shape, step)]) + + @staticmethod + def _grid_params(params: dict[str, Any]) -> dict[str, Any]: + """ + The step attributes for *params*. Counts are whole numbers even + when a spin row or a saved project gives them as floats. + """ + result = {key: params[key] for key in PARAM_KEYS} + for key in ("speed_range", "power_range", "offset_range"): + result[key] = tuple(float(v) for v in result[key]) + for key in ("passes_range", "grid_dimensions"): + result[key] = tuple(round(v) for v in result[key]) + cols, rows = result["grid_dimensions"] + if min(cols, rows) < 2: + raise ValueError( + _("A Material Test Grid needs at least 2×2 cells.") + ) + return result diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py new file mode 100644 index 000000000..cac2a99c8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py @@ -0,0 +1,109 @@ +""" +The record of a generated calibration test, saved with the project. + +It keeps the generator, its parameters and the uids of everything the +generator made, so the test can be rebuilt when a parameter changes. The +record is a hidden asset: it is saved in the ``.ryp`` like any asset but +does not show in the asset browser. +""" + +import uuid +from dataclasses import dataclass, field, replace +from typing import Any, ClassVar + +from blinker import Signal + +FORMAT_VERSION = 1 + + +@dataclass(frozen=True) +class CalibrationState: + """The parameters of a test and the items generated from them.""" + + params: dict[str, Any] + workpiece_uids: tuple[str, ...] = () + step_uids: tuple[str, ...] = () + asset_uids: tuple[str, ...] = () + + +@dataclass +class CalibrationRecord: + """A hidden asset that describes one generated calibration test.""" + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "calibration_test" + display_icon_name: ClassVar[str] = "test-symbolic" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = False + type_display_name: ClassVar[str] = "Calibration Test" + can_edit: ClassVar[bool] = False + add_action: ClassVar[str | None] = None + activate_action: ClassVar[str | None] = None + edit_item_action: ClassVar[str | None] = None + + generator_id: str + layer_uid: str + state: CalibrationState + name: str = "" + uid: str = field(default_factory=lambda: str(uuid.uuid4())) + updated: Signal = field( + default_factory=Signal, init=False, repr=False, compare=False + ) + + @property + def hidden(self) -> bool: + return True + + @property + def params(self) -> dict[str, Any]: + return dict(self.state.params) + + def set_state(self, state: CalibrationState) -> None: + self.state = state + self.updated.send(self) + + def with_params(self, params: dict[str, Any]) -> CalibrationState: + return replace(self.state, params=dict(params)) + + def owns(self, uid: str) -> bool: + return uid in self.state.workpiece_uids + + def get_thumbnail(self, size: int) -> bytes | None: + return None + + def to_dict(self) -> dict[str, Any]: + return { + "uid": self.uid, + "type": self.asset_type_name, + "name": self.name, + "version": FORMAT_VERSION, + "generator_id": self.generator_id, + "layer_uid": self.layer_uid, + "params": _jsonable(self.state.params), + "workpiece_uids": list(self.state.workpiece_uids), + "step_uids": list(self.state.step_uids), + "asset_uids": list(self.state.asset_uids), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CalibrationRecord": + state = CalibrationState( + params=dict(data.get("params", {})), + workpiece_uids=tuple(data.get("workpiece_uids", ())), + step_uids=tuple(data.get("step_uids", ())), + asset_uids=tuple(data.get("asset_uids", ())), + ) + return cls( + generator_id=data["generator_id"], + layer_uid=data.get("layer_uid", ""), + state=state, + name=data.get("name", ""), + uid=data.get("uid", str(uuid.uuid4())), + ) + + +def _jsonable(params: dict[str, Any]) -> dict[str, Any]: + return { + key: list(value) if isinstance(value, tuple) else value + for key, value in params.items() + } diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py deleted file mode 100644 index 958d7a379..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration_tests.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Parameters and layout of the Interval Test and the Focus Test. - -Both tests are generated as ordinary project content (a layer with -workpieces and steps, see ``commands/calibration_test_cmd.py``). This -module holds the parts that do not touch the document: the values each -cell or line is made with, their positions, and the machine commands -the Focus Test puts between its lines. -""" - -from dataclasses import dataclass -from enum import Enum -from gettext import gettext as _ - -MM_PER_INCH = 25.4 - -# The Focus Test never moves the head further than this from the -# height it starts at, so a typo cannot drive it into the material. -MAX_FOCUS_OFFSET_MM = 10.0 - - -class FocusMode(Enum): - """How the focus height changes between the lines of a Focus Test.""" - - Z_AXIS = "z_axis" - MANUAL = "manual" - RAMP = "ramp" - - @property - def label(self) -> str: - labels = { - FocusMode.Z_AXIS: _("Z axis steps"), - FocusMode.MANUAL: _("Manual (pause between lines)"), - FocusMode.RAMP: _("Ramp (tilted material)"), - } - return labels[self] - - @property - def needs_commands(self) -> bool: - """True when the mode puts machine commands between lines.""" - return self != FocusMode.RAMP - - -@dataclass -class IntervalTestParams: - """Settings of an Interval Test.""" - - min_interval: float = 0.05 - max_interval: float = 0.25 - count: int = 5 - cell_size: float = 10.0 - spacing: float = 5.0 - power_percent: float = 30.0 - speed: float = 3000.0 - include_labels: bool = True - label_power_percent: float = 10.0 - label_speed: float = 1000.0 - label_height: float = 2.5 - - -@dataclass -class FocusTestParams: - """Settings of a Focus Test.""" - - mode: FocusMode = FocusMode.MANUAL - start_offset: float = -2.0 - step: float = 0.5 - count: int = 9 - line_length: float = 15.0 - spacing: float = 6.0 - ramp_length: float = 100.0 - power_percent: float = 30.0 - speed: float = 1500.0 - include_labels: bool = True - label_power_percent: float = 10.0 - label_speed: float = 1000.0 - label_height: float = 2.5 - - -def interval_values(params: IntervalTestParams) -> list[float]: - """The line interval of every cell, evenly spread over the range.""" - if params.count < 2: - raise ValueError(_("An Interval Test needs at least two cells.")) - low, high = params.min_interval, params.max_interval - if low <= 0 or high <= low: - raise ValueError( - _( - "The minimum line interval must be above zero and below " - "the maximum." - ) - ) - step = (high - low) / (params.count - 1) - return [low + i * step for i in range(params.count)] - - -def lines_per_inch(interval_mm: float) -> float: - """The line density (LPI) that matches a line interval in mm.""" - return MM_PER_INCH / interval_mm - - -def row_positions(count: int, size: float, spacing: float) -> list[float]: - """The offsets of *count* items of *size* placed in a row.""" - return [i * (size + spacing) for i in range(count)] - - -def focus_offsets(params: FocusTestParams) -> list[float]: - """The focus offset of every line of a Focus Test.""" - if params.count < 2: - raise ValueError(_("A Focus Test needs at least two lines.")) - if params.step == 0: - raise ValueError(_("The focus step must not be zero.")) - offsets = [ - params.start_offset + i * params.step for i in range(params.count) - ] - if max(abs(offsets[0]), abs(offsets[-1])) > MAX_FOCUS_OFFSET_MM: - raise ValueError( - _( - "The focus offsets must stay within ±{limit:g} mm of " - "the starting height." - ).format(limit=MAX_FOCUS_OFFSET_MM) - ) - return offsets - - -def format_offset(offset: float) -> str: - """A focus offset as shown in labels, e.g. ``+0.5`` or ``-2.0``.""" - if abs(offset) < 1e-9: - return "0.0" - return f"{offset:+.1f}" - - -def _number(value: float) -> str: - text = f"{value:.4f}".rstrip("0").rstrip(".") - return "0" if text in ("-0", "") else text - - -def _z_move(distance: float, reverse_z: bool) -> list[str]: - """A relative Z move that leaves the controller in G90.""" - if reverse_z: - distance = -distance - return ["G91", f"G0 Z{_number(distance)}", "G90"] - - -def focus_commands( - params: FocusTestParams, reverse_z: bool -) -> list[tuple[int, list[str], str]]: - """ - The machine commands a Focus Test runs between its lines. - - Returns ``(index, lines, name)`` tuples: *lines* run right before - line *index* (an index equal to the line count means after the last - line), and *name* describes the command in the workflow. - - A positive offset means more distance between the head and the - material. In Z axis mode the head is moved relatively with - ``G91``/``G0 Z``/``G90`` and returned to its starting height at the - end. In manual mode the job pauses with ``M0`` before every line so - the head can be moved by hand. - """ - if not params.mode.needs_commands: - return [] - offsets = focus_offsets(params) - commands: list[tuple[int, list[str], str]] = [] - if params.mode == FocusMode.Z_AXIS: - previous = 0.0 - for index, offset in enumerate(offsets): - name = _("Z to {offset} mm").format(offset=format_offset(offset)) - commands.append( - (index, _z_move(offset - previous, reverse_z), name) - ) - previous = offset - commands.append( - ( - len(offsets), - _z_move(-previous, reverse_z), - _("Z back to the starting height"), - ) - ) - return commands - for index, offset in enumerate(offsets): - if index == 0: - name = _("Pause: set the head to {offset} mm from focus").format( - offset=format_offset(offset) - ) - else: - name = _("Pause: move the head by {step} mm").format( - step=format_offset(params.step) - ) - commands.append((index, ["M0"], name)) - return commands diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py index 58d9901f3..f37ce5979 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py @@ -4,10 +4,8 @@ Provides command implementations for laser operations. """ -from .calibration_test_cmd import CalibrationTestCmd -from .material_test_cmd import MaterialTestCmd +from .calibration_cmd import CalibrationCmd __all__ = [ - "CalibrationTestCmd", - "MaterialTestCmd", + "CalibrationCmd", ] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py new file mode 100644 index 000000000..a1f081b9a --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py @@ -0,0 +1,255 @@ +""" +Creates calibration tests in the document and rebuilds them when their +parameters change. + +Every test gets its own layer. Each cell is a workpiece with a step +that processes only that workpiece (``Step.workpiece_uids``); machine +commands between cells are Command steps. Creating a test and every +later change of its parameters are one undo entry each. +""" + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from rayforge.context import get_context +from rayforge.core.asset import IAsset +from rayforge.core.color import pick_unused_color +from rayforge.core.item import DocItem +from rayforge.core.layer import Layer +from rayforge.core.step import Step +from rayforge.core.undo import ChangePropertyCommand, ListItemCommand +from rayforge.core.undo.list_cmd import ReorderListCommand +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand + +from ..calibration import GENERATORS, CalibrationGenerator +from ..calibration.builder import BuiltTest, CalibrationBuilder, bounds_of +from ..calibration.lookup import record_for_items +from ..calibration.record import CalibrationRecord, CalibrationState + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class CalibrationCmd: + """Creates and updates calibration tests.""" + + def __init__(self, editor: "DocEditor"): + self._editor = editor + + @property + def _doc(self): + return self._editor.doc + + @staticmethod + def generator(generator_id: str) -> CalibrationGenerator: + return GENERATORS[generator_id] + + # -- Creating --------------------------------------------------------- + + def create( + self, generator_id: str, params: dict[str, Any] | None = None + ) -> CalibrationRecord: + """ + Adds a new test with the generator's defaults, updated with + *params*, on a new layer. Raises ``ValueError`` when the + parameters do not make a valid test. + """ + generator = self.generator(generator_id) + context = self._editor.context + if not generator.is_available(context): + raise ValueError(_("Select a machine first.")) + values = generator.defaults(context) + values.update(params or {}) + built = self._build(generator, values) + dims = self._editor.machine_dimensions + if dims: + built.center_on(*dims) + + layer = self._new_layer(generator.title) + for wp in built.workpieces: + layer.add_child(wp) + assert layer.workflow is not None + layer.workflow.set_steps(built.steps) + record = CalibrationRecord( + generator_id=generator.id, + layer_uid=layer.uid, + state=_state(values, built), + name=layer.name, + ) + + label = _("Add {test}").format(test=generator.title) + with self._editor.history_manager.transaction(label) as t: + for asset in [*built.assets, record]: + t.execute(self._add_asset_cmd(asset)) + t.execute(AddLayerAndSetActiveCommand(self._editor, layer, label)) + logger.info("Created %s", layer.name) + return record + + def _new_layer(self, title: str) -> Layer: + """A layer named after the test, with an unused color.""" + names = {layer.name for layer in self._doc.layers} + name = title + number = 2 + while name in names: + name = f"{title} {number}" + number += 1 + layer = Layer(name) + material = get_context().material_mgr.get_default_material() + layer.set_stock_material_uid(material.uid) + layer.set_color( + pick_unused_color({layer.color for layer in self._doc.layers}) + ) + return layer + + # -- Finding ---------------------------------------------------------- + + @staticmethod + def record_for_items(items: list[DocItem]) -> CalibrationRecord | None: + """The record of the test all *items* belong to, or None.""" + return record_for_items(items) + + # -- Updating --------------------------------------------------------- + + def update_params( + self, record: CalibrationRecord, params: dict[str, Any] + ) -> list[WorkPiece]: + """ + Rebuilds the test with *params* in one undo entry, at the place + where it is now. Steps the user added to the layer stay. Raises + ``ValueError`` when the parameters do not make a valid test. + """ + if params == record.params and record.uid in self._doc.assets: + return self.workpieces(record) + generator = self.generator(record.generator_id) + layer = self._doc.find_descendant_by_uid(record.layer_uid) + if not isinstance(layer, Layer) or layer.workflow is None: + raise ValueError(_("The layer of this test no longer exists.")) + built = self._build(generator, params) + old_wps = self.workpieces(record) + self._place(built, old_wps) + steps = _merge_steps( + layer.workflow.steps, set(record.state.step_uids), built.steps + ) + label = _("Change {test}").format(test=generator.title) + with self._editor.history_manager.transaction(label) as t: + if record.uid not in self._doc.assets: + t.execute(self._add_asset_cmd(record)) + for wp in old_wps: + t.execute(self._remove_child_cmd(wp)) + for asset in self._unused_assets(record, old_wps): + t.execute(self._remove_asset_cmd(asset)) + for asset in built.assets: + t.execute(self._add_asset_cmd(asset)) + for wp in built.workpieces: + t.execute(self._add_child_cmd(layer, wp)) + t.execute( + ReorderListCommand(layer.workflow, "steps", steps, "set_steps") + ) + t.execute( + ChangePropertyCommand( + record, + "state", + _state(params, built), + setter_method_name="set_state", + coalesce=False, + ) + ) + return built.workpieces + + def workpieces(self, record: CalibrationRecord) -> list[WorkPiece]: + """The workpieces of the test that are still in the document.""" + found = ( + self._doc.find_descendant_by_uid(uid) + for uid in record.state.workpiece_uids + ) + return [wp for wp in found if isinstance(wp, WorkPiece)] + + def _place(self, built: BuiltTest, old_wps: list[WorkPiece]): + """ + Keeps the bottom-left corner where the old test was, but not + beyond the work area when the test grew. + """ + dims = self._editor.machine_dimensions + if not old_wps: + if dims: + built.center_on(*dims) + return + min_x, min_y, _max_x, _max_y = bounds_of(old_wps) + built.move_to(min_x, min_y) + if dims: + built.keep_inside(*dims) + + def _unused_assets( + self, record: CalibrationRecord, removed: list[WorkPiece] + ) -> list[IAsset]: + """Assets of the old test that no remaining workpiece uses.""" + removed_uids = {wp.uid for wp in removed} + in_use = { + wp.source_segment.source_asset_uid + for wp in self._doc.all_workpieces + if wp.uid not in removed_uids and wp.source_segment is not None + } + found = ( + self._doc.get_asset_by_uid(uid) + for uid in record.state.asset_uids + if uid not in in_use + ) + return [asset for asset in found if asset is not None] + + # -- Building blocks -------------------------------------------------- + + def _build( + self, generator: CalibrationGenerator, params: dict[str, Any] + ) -> BuiltTest: + context = self._editor.context + layout = generator.layout(params, context) + return CalibrationBuilder(context).build(layout) + + def _add_asset_cmd(self, asset: IAsset) -> ListItemCommand: + return ListItemCommand(self._doc, asset, "remove_asset", "add_asset") + + def _remove_asset_cmd(self, asset: IAsset) -> ListItemCommand: + return ListItemCommand(self._doc, asset, "add_asset", "remove_asset") + + @staticmethod + def _add_child_cmd(parent: DocItem, wp: WorkPiece) -> ListItemCommand: + return ListItemCommand(parent, wp, "remove_child", "add_child") + + @staticmethod + def _remove_child_cmd(wp: WorkPiece) -> ListItemCommand: + assert wp.parent is not None + return ListItemCommand(wp.parent, wp, "add_child", "remove_child") + + +def _state(params: dict[str, Any], built: BuiltTest) -> CalibrationState: + return CalibrationState( + params=dict(params), + workpiece_uids=tuple(wp.uid for wp in built.workpieces), + step_uids=tuple(step.uid for step in built.steps), + asset_uids=tuple(asset.uid for asset in built.assets), + ) + + +def _merge_steps( + current: list[Step], generated_uids: set[str], new: list[Step] +) -> list[Step]: + """ + The workflow after a rebuild: the new generated steps take the + place of the first old one, and steps the user added keep their + order around them. + """ + result: list[Step] = [] + inserted = False + for step in current: + if step.uid not in generated_uids: + result.append(step) + elif not inserted: + result.extend(new) + inserted = True + if not inserted: + result = new + result + return result diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py deleted file mode 100644 index 243b5faaf..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_test_cmd.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -Creates Interval Tests and Focus Tests as ordinary project content. - -Each test becomes its own layer. Every cell or line is a workpiece -with its own step, linked through ``Step.workpiece_uids`` the same -way the Material Test Grid links its step, so each one keeps its -own settings and stays editable in the step settings. Labels share one -workpiece with one low-power contour step. The Focus Test puts Command -steps (``M0`` pauses or relative Z moves) between its lines. -""" - -import logging -import re -from gettext import gettext as _ -from typing import TYPE_CHECKING, Any, Protocol, cast - -from raygeo.geo import Geometry, Matrix -from raygeo.geo.shape.text import FontConfig, text_to_geometry - -from rayforge.core.layer import Layer -from rayforge.core.source_asset import SourceAsset -from rayforge.core.step import Step -from rayforge.core.step_registry import step_registry -from rayforge.core.vectorization_spec import PassthroughSpec -from rayforge.core.workpiece import WorkPiece -from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand -from rayforge.image.svg.exporter import GeometrySvgExporter -from rayforge.image.svg.importer import SvgImporter -from rayforge.machine.driver import get_driver_cls -from rayforge.pipeline.transformer.registry import transformer_registry - -from ..calibration_tests import ( - FocusMode, - FocusTestParams, - IntervalTestParams, - focus_commands, - focus_offsets, - format_offset, - interval_values, - lines_per_inch, - row_positions, -) -from ..steps import ContourStep, EngraveStep -from ..steps.laser_step import LaserStep - -if TYPE_CHECKING: - from rayforge.doceditor.editor import DocEditor - - class OverscanTransformerType(Protocol): - @staticmethod - def calculate_auto_distance( - step_speed: int, max_acceleration: int - ) -> float: ... - - -logger = logging.getLogger(__name__) - -# Gap between a cell or line and its label, in label heights. -_LABEL_GAP = 0.6 -# Spacing of the distance ticks along the Focus Test ramp, in mm. -_RAMP_TICK_MM = 10.0 -_RAMP_TICK_LENGTH = 2.0 - - -def _rect(x: float, y: float, w: float, h: float) -> Geometry: - geo = Geometry() - geo.move_to(x, y) - geo.line_to(x + w, y) - geo.line_to(x + w, y + h) - geo.line_to(x, y + h) - geo.close_path() - return geo - - -def _line(x0: float, y0: float, x1: float, y1: float) -> Geometry: - geo = Geometry() - geo.move_to(x0, y0) - geo.line_to(x1, y1) - return geo - - -def _text( - text: str, - height: float, - center_x: float, - top: float, - max_width: float | None = None, -) -> Geometry: - """ - Glyph outlines of *text*, *height* mm tall and centered below *top*, - scaled down further when needed to fit in *max_width*. - """ - geo = text_to_geometry(text, FontConfig(size=10.0)) - min_x, min_y, max_x, max_y = geo.rect() - scale = height / max(max_y - min_y, 1e-9) - if max_width is not None and (max_x - min_x) * scale > max_width: - scale = max_width / max(max_x - min_x, 1e-9) - width = (max_x - min_x) * scale - text_height = (max_y - min_y) * scale - matrix = ( - Matrix.translation(center_x - width / 2, top - text_height) - @ Matrix.scale(scale, scale) - @ Matrix.translation(-min_x, -min_y) - ) - geo.transform(matrix) - return geo - - -class CalibrationTestCmd: - """Creates Interval Tests and Focus Tests in the document.""" - - def __init__(self, editor: "DocEditor"): - self._editor = editor - self._pending_assets: list[SourceAsset] = [] - - # -- Availability ----------------------------------------------------- - - def available_focus_modes(self) -> list[FocusMode]: - """ - The Focus Test modes the current machine supports: Z axis steps - need a Z axis, and both Z and manual mode need a G-code driver - and the Command step (Automation addon). - """ - modes = [FocusMode.RAMP] - machine = self._editor.context.machine - if machine is None or step_registry.get("CommandStep") is None: - return modes - if not get_driver_cls(machine.driver_name or "").uses_gcode: - return modes - modes.insert(0, FocusMode.MANUAL) - if machine.has_z_axis: - modes.insert(0, FocusMode.Z_AXIS) - return modes - - # -- Interval Test ---------------------------------------------------- - - def create_interval_test(self, params: IntervalTestParams) -> Layer: - """Adds an Interval Test layer and returns it.""" - intervals = interval_values(params) - layer = Layer(_("Interval Test")) - size = params.cell_size - xs = row_positions(len(intervals), size, params.spacing) - cell_y = self._label_block_height(params, lines=2) - if params.include_labels: - labels = Geometry() - for x, interval in zip(xs, intervals): - center = x + size / 2 - top = cell_y - _LABEL_GAP * params.label_height - fit = (size + params.spacing) * 0.9 - labels.extend( - _text( - f"{interval:.2f}", - params.label_height, - center, - top, - fit, - ) - ) - labels.extend( - _text( - _("{lpi:.0f} LPI").format( - lpi=lines_per_inch(interval) - ), - params.label_height, - center, - top - params.label_height * 1.5, - fit, - ) - ) - self._add_labels(layer, labels, params) - for x, interval in zip(xs, intervals): - name = _("Interval {interval:.2f} mm").format(interval=interval) - wp = self._add_workpiece( - layer, _rect(x, cell_y, size, size), name, filled=True - ) - step = self._engrave_step(params.power_percent, params.speed) - step.name = name - step.line_interval_mm = interval - step.depth_mode = "CONSTANT_POWER" - self._link(layer, step, wp) - self._insert(layer, _("Add Interval Test")) - return layer - - # -- Focus Test ------------------------------------------------------- - - def create_focus_test(self, params: FocusTestParams) -> Layer: - """Adds a Focus Test layer and returns it.""" - if params.mode not in self.available_focus_modes(): - raise ValueError( - _( - "This machine does not support the focus test mode {mode}." - ).format(mode=params.mode.label) - ) - layer = Layer(_("Focus Test")) - if params.mode == FocusMode.RAMP: - self._build_ramp(layer, params) - else: - self._build_focus_lines(layer, params) - self._insert(layer, _("Add Focus Test")) - return layer - - def _build_focus_lines(self, layer: Layer, params: FocusTestParams): - offsets = focus_offsets(params) - xs = row_positions(len(offsets), 0.0, params.spacing) - line_y = self._label_block_height(params, lines=2) - if params.include_labels: - # Labels alternate between two rows, so each may be almost - # twice as wide as the line spacing. - labels = Geometry() - top = line_y - _LABEL_GAP * params.label_height - for index, (x, offset) in enumerate(zip(xs, offsets)): - row_top = top - (index % 2) * params.label_height * 1.5 - labels.extend( - _text( - format_offset(offset), - params.label_height, - x, - row_top, - params.spacing * 1.8, - ) - ) - self._add_labels(layer, labels, params) - machine = self._editor.context.machine - reverse_z = bool(machine and machine.reverse_z_axis) - commands: dict[int, tuple[list[str], str]] = { - index: (lines, name) - for index, lines, name in focus_commands(params, reverse_z) - } - for index, (x, offset) in enumerate(zip(xs, offsets)): - self._add_command(layer, commands.get(index)) - name = _("Focus {offset} mm").format(offset=format_offset(offset)) - wp = self._add_workpiece( - layer, - _line(x, line_y, x, line_y + params.line_length), - name, - filled=False, - ) - step = self._contour_step(params.power_percent, params.speed) - step.name = name - self._link(layer, step, wp) - self._add_command(layer, commands.get(len(offsets))) - - def _build_ramp(self, layer: Layer, params: FocusTestParams): - length = params.ramp_length - line_y = self._label_block_height(params, lines=1) - line_y += _RAMP_TICK_LENGTH - if params.include_labels: - labels = Geometry() - ticks = int(length // _RAMP_TICK_MM) + 1 - for i in range(ticks): - x = i * _RAMP_TICK_MM - labels.extend( - _line(x, line_y - _RAMP_TICK_LENGTH, x, line_y - 0.5) - ) - labels.extend( - _text( - f"{x:g}", - params.label_height, - x, - line_y - _RAMP_TICK_LENGTH - 0.5, - ) - ) - self._add_labels(layer, labels, params) - name = _("Focus ramp") - wp = self._add_workpiece( - layer, _line(0.0, line_y, length, line_y), name, filled=False - ) - step = self._contour_step(params.power_percent, params.speed) - step.name = name - self._link(layer, step, wp) - - # -- Building blocks -------------------------------------------------- - - @staticmethod - def _label_block_height(params, lines: int) -> float: - """Height reserved below the cells for *lines* lines of labels.""" - if not params.include_labels: - return 0.0 - height = params.label_height - return height * (_LABEL_GAP + lines + (lines - 1) * 0.5) - - def _add_labels(self, layer: Layer, labels: Geometry, params): - wp = self._add_workpiece(layer, labels, _("Labels"), filled=False) - step = self._contour_step( - params.label_power_percent, params.label_speed - ) - step.name = _("Labels") - self._link(layer, step, wp) - - def _add_command( - self, layer: Layer, command: tuple[list[str], str] | None - ): - if command is None: - return - lines, name = command - step_cls: Any = step_registry.get("CommandStep") - assert step_cls is not None - step = step_cls.create(self._editor.context, name=name) - step.set_command_text("\n".join(lines)) - assert layer.workflow is not None - layer.workflow.add_step(step) - - def _contour_step(self, power_percent: float, speed: float) -> Step: - step = ContourStep.create(self._editor.context) - self._set_laser(step, power_percent, speed) - return step - - def _engrave_step(self, power_percent: float, speed: float) -> EngraveStep: - step = EngraveStep.create(self._editor.context) - self._set_laser(step, power_percent, speed) - self._update_overscan(step) - return step - - @staticmethod - def _set_laser(step: LaserStep, power_percent: float, speed: float): - step.set_power(power_percent / 100.0) - step.set_cut_speed(int(speed)) - - def _update_overscan(self, step: Step): - """Recomputes the automatic overscan for the step's speed.""" - machine = self._editor.context.machine - overscan = cast( - "OverscanTransformerType | None", - transformer_registry.get("OverscanTransformer"), - ) - if machine is None or overscan is None: - return - distance = overscan.calculate_auto_distance( - step.cut_speed, machine.acceleration - ) - for spec in step.per_workpiece_transformers_dicts: - if spec.get("name") == "OverscanTransformer" and spec.get("auto"): - spec["distance_mm"] = distance - - @staticmethod - def _link(layer: Layer, step: Step, wp: WorkPiece): - step.workpiece_uids = [wp.uid] - assert layer.workflow is not None - layer.workflow.add_step(step) - - def _add_workpiece( - self, layer: Layer, geometry: Geometry, name: str, filled: bool - ) -> WorkPiece: - """ - Imports *geometry* as a vector workpiece at its own coordinates - (relative to the test's bottom-left corner). - """ - svg = GeometrySvgExporter(geometry).export().decode("utf-8") - if filled: - svg = re.sub(r'fill="none"', 'fill="black"', svg) - svg = re.sub(r'stroke="black"', 'stroke="none"', svg) - result = SvgImporter(svg.encode("utf-8")).get_doc_items( - PassthroughSpec() - ) - if not result or not result.payload or not result.payload.items: - raise RuntimeError(f"Could not create the {name} workpiece.") - source: SourceAsset = result.payload.source - source.name = name - wp = result.payload.items[0] - assert isinstance(wp, WorkPiece) - wp.name = name - world = wp.get_world_geometry() - assert world is not None - min_x, min_y, _max_x, _max_y = world.rect() - target_x, target_y, _tx, _ty = geometry.rect() - wp.pos = ( - wp.pos[0] + target_x - min_x, - wp.pos[1] + target_y - min_y, - ) - layer.add_child(wp) - self._pending_assets.append(source) - return wp - - def _insert(self, layer: Layer, undo_name: str): - """Centers the test on the work area and adds it undoably.""" - rects = [ - wp.get_world_geometry().rect() # type: ignore[union-attr] - for wp in layer.all_workpieces - ] - min_x = min(r[0] for r in rects) - min_y = min(r[1] for r in rects) - max_x = max(r[2] for r in rects) - max_y = max(r[3] for r in rects) - dims = self._editor.machine_dimensions - if dims: - dx = dims[0] / 2 - (min_x + max_x) / 2 - dy = dims[1] / 2 - (min_y + max_y) / 2 - for wp in layer.all_workpieces: - wp.pos = (wp.pos[0] + dx, wp.pos[1] + dy) - doc = self._editor.doc - for asset in self._pending_assets: - doc.add_asset(asset) - self._pending_assets = [] - with self._editor.history_manager.transaction(undo_name) as t: - t.execute( - AddLayerAndSetActiveCommand( - self._editor, layer, name=undo_name - ) - ) - logger.info("Created %s", layer.name) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/material_test_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/material_test_cmd.py deleted file mode 100644 index 1b446178e..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/material_test_cmd.py +++ /dev/null @@ -1,205 +0,0 @@ -import json -import logging -from gettext import gettext as _ -from typing import TYPE_CHECKING, Any - -from rayforge.core.item import DocItem -from rayforge.core.step import Step -from rayforge.core.step_registry import step_registry -from rayforge.core.undo import ListItemCommand -from rayforge.core.vectorization_spec import ProceduralSpec -from rayforge.core.workpiece import WorkPiece -from rayforge.image.procedural import ProceduralImporter - -from ..material_test_helpers import ( - draw_material_test_preview, - get_material_test_proportional_size, -) - -if TYPE_CHECKING: - from rayforge.doceditor.editor import DocEditor - -logger = logging.getLogger(__name__) - - -_PARAM_KEYS = ( - "test_type", - "grid_mode", - "speed_range", - "power_range", - "passes_range", - "offset_range", - "fixed_speed", - "fixed_power", - "grid_dimensions", - "shape_size", - "spacing", - "include_labels", - "label_power_percent", - "label_speed", - "line_interval_mm", -) - - -def _extract_params(step: Step) -> dict[str, Any]: - """Extract material test params from step attributes.""" - return {k: getattr(step, k, None) for k in _PARAM_KEYS} - - -class MaterialTestCmd: - """Handles creation and updates for material test grids. - - Registered with the command registry so it persists for the - editor's lifetime. This is essential because the instance holds - a blinker signal connection to ``doc.descendant_updated``; if the - instance were garbage-collected the connection would be lost and - preview updates would stop working. - """ - - def __init__(self, editor: "DocEditor"): - self._editor = editor - self._connect_doc_signals() - editor.document_changed.connect(self._on_document_changed) - - @property - def _doc(self): - return self._editor.doc - - @property - def _history_manager(self): - return self._editor.history_manager - - def _connect_doc_signals(self): - self._doc.descendant_updated.connect(self._on_step_updated) - - def _disconnect_doc_signals(self): - try: - self._doc.descendant_updated.disconnect(self._on_step_updated) - except KeyError: - pass - - def _on_document_changed(self, sender): - self._disconnect_doc_signals() - self._connect_doc_signals() - - def create_test_grid(self): - """ - Creates a new material test grid, including its Step and WorkPiece, - and adds them to the document. - """ - with self._history_manager.transaction(_("Add Material Test")) as t: - name = _("Material Test Grid") - - MaterialTestClass = step_registry.get("MaterialTestStep") - assert MaterialTestClass is not None - step = MaterialTestClass.create(self._editor.context) - step.name = name - - params = _extract_params(step) - - # Get function paths programmatically for type safety. - draw_func_path = ( - f"{draw_material_test_preview.__module__}." - f"{draw_material_test_preview.__name__}" - ) - size_func_path = ( - f"{get_material_test_proportional_size.__module__}." - f"{get_material_test_proportional_size.__name__}" - ) - - # Use the generic importer to create the procedural content. - importer = ProceduralImporter( - drawing_function_path=draw_func_path, - size_function_path=size_func_path, - params=params, - name=name, - ) - payload = importer.get_doc_items(ProceduralSpec()) - if not payload or not payload.payload: - logger.error("Failed to create material test grid.") - return - - source = payload.payload.source - workpiece = payload.payload.items[0] - assert isinstance(workpiece, WorkPiece) - - self._doc.add_asset(source) - step.workpiece_uids = [workpiece.uid] # Link step to WP - width_mm, height_mm = workpiece.size - - machine_dims = self._editor.machine_dimensions - if machine_dims: - ws_width, ws_height = machine_dims - workpiece.pos = ( - ws_width / 2 - width_mm / 2, - ws_height / 2 - height_mm / 2, - ) - - active_layer = self._doc.active_layer - if active_layer.workflow: - t.execute( - ListItemCommand( - owner_obj=active_layer.workflow, - item=step, - undo_command="remove_step", - redo_command="add_step", - ) - ) - t.execute( - ListItemCommand( - owner_obj=active_layer, - item=workpiece, - undo_command="remove_child", - redo_command="add_child", - ) - ) - logger.info( - f"Created material test grid ({width_mm:.1f}x{height_mm:.1f} mm)" - ) - - def _on_step_updated( - self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem - ): - if not isinstance(origin, Step): - return - if origin.ASSEMBLER_NAME != "material_test_grid": - return - self.sync_preview_from_step(origin) - - def sync_preview_from_step(self, step: Step): - if not step.workpiece_uids: - return - - item = self._doc.find_descendant_by_uid(step.workpiece_uids[0]) - if not isinstance(item, WorkPiece): - logger.warning("Could not find workpiece owned by step.") - return - - workpiece_to_update = item - if not workpiece_to_update.source: - return - - source = workpiece_to_update.source - params = _extract_params(step) - - # Re-create the recipe with the updated geometric parameters. - try: - old_recipe = json.loads(source.original_data) - new_recipe_dict = { - "drawing_function_path": old_recipe["drawing_function_path"], - "size_function_path": old_recipe["size_function_path"], - "params": params, - } - new_recipe_data = json.dumps(new_recipe_dict).encode("utf-8") - source.original_data = new_recipe_data - except (json.JSONDecodeError, KeyError) as e: - logger.error(f"Could not update procedural source data: {e}") - return - - # Recalculate size first so the re-render uses correct dimensions. - new_width_mm, new_height_mm = get_material_test_proportional_size( - params - ) - workpiece_to_update.clear_render_cache() - workpiece_to_update.set_size(new_width_mm, new_height_mm) - workpiece_to_update.updated.send(workpiece_to_update) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py index 28f7eb0f6..811aa6dfa 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -7,23 +7,27 @@ from gettext import gettext as _ from pathlib import Path -from gi.repository import Gio +from gi.repository import Gio, GLib from rayforge.core.hooks import hookimpl from rayforge.ui_gtk.action_registry import MenuPlacement +from rayforge.ui_gtk.doceditor.property_providers import ( + property_provider_registry, +) from rayforge.ui_gtk.icons import register_icon_path -from .commands import CalibrationTestCmd, MaterialTestCmd +from .commands import CalibrationCmd from .widgets import ASSEMBLER_WIDGETS -from .widgets.calibration_test_dialogs import ( - FocusTestDialog, - IntervalTestDialog, +from .widgets.calibration_provider import ( + CalibrationPropertyProvider, + show_new_test, ) ADDON_NAME = "laser_essentials" _ICONS_DIR = Path(__file__).parent / "resources" / "icons" register_icon_path(_ICONS_DIR) +property_provider_registry.register(CalibrationPropertyProvider, ADDON_NAME) @hookimpl @@ -38,67 +42,47 @@ def register_step_settings_pages(step_settings_page_registry): @hookimpl def register_commands(command_registry): """Register editor command handlers.""" - command_registry.register("material_test", MaterialTestCmd, ADDON_NAME) - command_registry.register( - "calibration_tests", CalibrationTestCmd, ADDON_NAME - ) + command_registry.register("calibration", CalibrationCmd, ADDON_NAME) + + +def _create_test(action_registry, generator_id: str): + window = action_registry.window + editor = window.doc_editor + cmd = editor.calibration + try: + record = cmd.create(generator_id) + except ValueError as e: + editor.notification_requested.send(editor, message=str(e)) + return + GLib.idle_add(show_new_test, window, cmd.workpieces(record)) @hookimpl def register_actions(action_registry): """Register actions with menu placement.""" - action = Gio.SimpleAction.new("material_test", None) - - def on_activate(action, param): - window = action_registry.window - editor = window.doc_editor - editor.material_test.create_test_grid() - - action.connect("activate", on_activate) - action_registry.register( - action_name="material_test", - action=action, - addon_name=ADDON_NAME, - label=_("Create Material Test Grid"), - menu=MenuPlacement(menu_id="tools", priority=100), - ) - - interval_action = Gio.SimpleAction.new("interval_test", None) - - def on_interval_test(action, param): - window = action_registry.window - editor = window.doc_editor - IntervalTestDialog( - window, - editor.calibration_tests.create_interval_test, - ).present() - - interval_action.connect("activate", on_interval_test) - action_registry.register( - action_name="interval_test", - action=interval_action, - addon_name=ADDON_NAME, - label=_("Create Interval Test"), - menu=MenuPlacement(menu_id="tools", priority=101), - ) - - focus_action = Gio.SimpleAction.new("focus_test", None) - - def on_focus_test(action, param): - window = action_registry.window - editor = window.doc_editor - cmd = editor.calibration_tests - FocusTestDialog( - window, - cmd.create_focus_test, - cmd.available_focus_modes(), - ).present() - - focus_action.connect("activate", on_focus_test) - action_registry.register( - action_name="focus_test", - action=focus_action, - addon_name=ADDON_NAME, - label=_("Create Focus Test"), - menu=MenuPlacement(menu_id="tools", priority=102), + tests = ( + ( + "material_test", + "material_test_grid", + _("Create Material Test Grid"), + ), + ("interval_test", "interval_test", _("Create Interval Test")), + ("focus_test", "focus_test", _("Create Focus Test")), ) + for priority, (action_name, generator_id, label) in enumerate( + tests, start=100 + ): + action = Gio.SimpleAction.new(action_name, None) + action.connect( + "activate", + lambda _action, _param, gid=generator_id: _create_test( + action_registry, gid + ), + ) + action_registry.register( + action_name=action_name, + action=action, + addon_name=ADDON_NAME, + label=label, + menu=MenuPlacement(menu_id="tools", priority=priority), + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py new file mode 100644 index 000000000..d9702e80c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -0,0 +1,228 @@ +""" +The settings of a calibration test in the item properties panel. + +They show whenever the selection is (part of) one calibration test, so +they never cover the test on the canvas. Every change rebuilds the test +after a short pause, as one undo entry. +""" + +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, GLib, Graphene, Gtk + +from rayforge.core.item import DocItem +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.property_providers.base import ( + PropertyProvider, +) +from rayforge.ui_gtk.mainwindow import MainWindow +from rayforge.ui_gtk.shared.expander import Expander +from rayforge.ui_gtk.varset.varsetwidget import VarSetRowList + +from ..calibration import GENERATORS, CalibrationGenerator +from ..calibration.lookup import record_for_items +from ..calibration.record import CalibrationRecord +from ..commands import CalibrationCmd + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +DEBOUNCE_DELAY_MS = 300 +#: Time the item properties need to slide in before they can be scrolled. +REVEAL_DELAY_MS = 400 + + +class CalibrationPropertyProvider(PropertyProvider): + """Shows and applies the parameters of a calibration test.""" + + priority = 90 + separate_group = True + group_title = _("Test Settings") + + def can_handle(self, items: list[DocItem]) -> bool: + return record_for_items(items) is not None + + def create_widgets(self) -> list[Gtk.Widget]: + self.help_row = Adw.ActionRow(activatable=False) + self.help_row.set_title_lines(0) + self.help_row.add_css_class("dim-label") + self.varset_widget = VarSetRowList() + self.varset_widget.data_changed.connect(self._on_data_changed) + self.error_row = Adw.ActionRow(activatable=False) + self.error_row.set_title_lines(0) + self.error_row.add_css_class("error") + self.error_row.set_visible(False) + self.note_row = Adw.ActionRow( + title=_( + "Changing a setting rebuilds the test and replaces changes " + "made to its operations." + ), + activatable=False, + ) + self.note_row.set_title_lines(0) + self.note_row.add_css_class("dim-label") + + self._record: CalibrationRecord | None = None + self._generator_id: str | None = None + self._pending_keys: set[str] = set() + self._timer_id = 0 + return [ + self.help_row, + self.error_row, + self.varset_widget, + self.note_row, + ] + + @property + def generator(self) -> CalibrationGenerator | None: + if self._record is None: + return None + return GENERATORS[self._record.generator_id] + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + self._in_update = True + try: + self.editor = editor + self.items = items + self._record = record_for_items(items) + generator = self.generator + if self._record is None or generator is None: + return + if generator.id != self._generator_id: + self.varset_widget.clear_dynamic_rows() + self._generator_id = generator.id + self.varset_widget.populate(generator.varset(editor.context)) + self._show_params(self._record.params) + self.error_row.set_visible(False) + self.group_subtitle = self._record.name or generator.title + finally: + self._in_update = False + + def scroll_into_view(self) -> bool: + """ + Scrolls the panel so the Test Settings card starts at its top. + The card comes after the workflows and the item properties, so + it is often below the visible part of the panel. + """ + card = self.varset_widget.get_ancestor(Expander) + scrolled = self._scrolled_window() + content = scrolled.get_child() if scrolled else None + if card is None or content is None: + return GLib.SOURCE_REMOVE + ok, point = card.compute_point(content, Graphene.Point()) + if ok: + self._scroll_to(point.y) + return GLib.SOURCE_REMOVE + + def _scrolled_window(self) -> Gtk.ScrolledWindow | None: + """The scrolled right panel the settings are shown in.""" + scrolled = self.varset_widget.get_ancestor(Gtk.ScrolledWindow) + return scrolled if isinstance(scrolled, Gtk.ScrolledWindow) else None + + def _scroll_offset(self) -> float: + scrolled = self._scrolled_window() + return scrolled.get_vadjustment().get_value() if scrolled else 0.0 + + def _scroll_to(self, offset: float) -> bool: + scrolled = self._scrolled_window() + if scrolled is not None: + adjustment = scrolled.get_vadjustment() + top = adjustment.get_upper() - adjustment.get_page_size() + adjustment.set_value(max(0.0, min(offset, top))) + return GLib.SOURCE_REMOVE + + def _show_params(self, params: dict[str, Any]): + generator = self.generator + assert generator is not None + keys = set(self.varset_widget.keys()) + self.varset_widget.set_values( + {k: v for k, v in params.items() if k in keys} + ) + for key, (labels, subtitles) in generator.row_labels(params).items(): + adapter = self.varset_widget.adapter_for(key) + if adapter is not None: + adapter.set_item_labels(labels) + adapter.set_item_subtitles(subtitles) + help_text = generator.help_text(params) + self.help_row.set_title(GLib.markup_escape_text(help_text)) + self.help_row.set_visible(bool(help_text)) + + def _on_data_changed(self, sender: VarSetRowList, key: str): + if self._in_update or self._record is None: + return + self._pending_keys.add(key) + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = GLib.timeout_add(DEBOUNCE_DELAY_MS, self._on_timer) + + def _on_timer(self) -> bool: + self._timer_id = 0 + self.apply_now() + return GLib.SOURCE_REMOVE + + def apply_now(self): + """Rebuilds the test from the values in the rows.""" + if self._timer_id: + GLib.source_remove(self._timer_id) + self._timer_id = 0 + keys = set(self._pending_keys) + self._pending_keys.clear() + record, generator = self._record, self.generator + if not keys or record is None or generator is None: + return + params = record.params + params.update(self.varset_widget.get_values()) + params = generator.apply_change(params, keys, self.editor.context) + self._in_update = True + try: + self._show_params(params) + finally: + self._in_update = False + offset = self._scroll_offset() + try: + workpieces = CalibrationCmd(self.editor).update_params( + record, params + ) + except ValueError as e: + self.error_row.set_title(GLib.markup_escape_text(str(e))) + self.error_row.set_visible(True) + return + self.error_row.set_visible(False) + self._reselect(workpieces, offset) + + def _reselect(self, workpieces: list[WorkPiece], offset: float): + """ + Selects the rebuilt test and puts the panel back where it was: + the old workpieces are gone, so the selection was cleared and the + item properties slid out. + """ + root = self.varset_widget.get_root() + if not isinstance(root, MainWindow): + return + GLib.idle_add(select_items, root, workpieces) + GLib.timeout_add(REVEAL_DELAY_MS, self._scroll_to, offset) + + +def select_items(window: MainWindow, items: list[WorkPiece]) -> bool: + """ + Selects a rebuilt test, so its settings stay in the panel. Runs on + idle, once the canvas has elements for the new workpieces. + """ + window.surface.select_items(items) + return GLib.SOURCE_REMOVE + + +def show_new_test(window: MainWindow, items: list[WorkPiece]) -> bool: + """ + Selects a new test and scrolls its settings into view once the + item properties have slid in. + """ + select_items(window, items) + for provider in window.item_props_widget.providers: + if isinstance(provider, CalibrationPropertyProvider): + GLib.timeout_add(REVEAL_DELAY_MS, provider.scroll_into_view) + return GLib.SOURCE_REMOVE diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py deleted file mode 100644 index 751ff961a..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_test_dialogs.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Dialogs that create an Interval Test or a Focus Test.""" - -from collections.abc import Callable -from gettext import gettext as _ -from typing import Any - -from gi.repository import Adw, Gtk - -from rayforge.core.varset import ( - BoolVar, - IntVar, - LabeledChoiceVar, - LengthVar, - SliderFloatVar, - SpeedVar, - VarSet, -) -from rayforge.ui_gtk.shared.patched_dialog_window import PatchedDialogWindow -from rayforge.ui_gtk.varset.varsetwidget import VarSetWidget - -from ..calibration_tests import ( - MAX_FOCUS_OFFSET_MM, - FocusMode, - FocusTestParams, - IntervalTestParams, -) - -DEFAULT_WIDTH = 600 - -_FOCUS_HELP = { - FocusMode.Z_AXIS: _( - "Focus the laser as usual first. The job moves the head to the " - "first offset, engraves one line per offset and returns the head " - "to the starting height. A positive offset means more distance " - "between the head and the material." - ), - FocusMode.MANUAL: _( - "Focus the laser as usual first. The labels are engraved at that " - "height. Before each line the job pauses: set the head to the " - "first offset, then move it by one step at every following " - "pause, and press Resume. A positive offset means more distance " - "between the head and the material." - ), - FocusMode.RAMP: _( - "Prop up one end of a flat strip so it rises evenly under the " - "line. The thinnest part of the line marks the focus; the ticks " - "show the distance along the line, so the height there follows " - "from the rise." - ), -} - - -def _labels_active(values: dict[str, Any]) -> bool: - return bool(values.get("include_labels")) - - -def _is_ramp(values: dict[str, Any]) -> bool: - return values.get("mode") == FocusMode.RAMP.value - - -def _is_not_ramp(values: dict[str, Any]) -> bool: - return not _is_ramp(values) - - -def _laser_varset(defaults) -> VarSet: - return VarSet( - title=_("Laser"), - vars=[ - SliderFloatVar( - key="power_percent", - label=_("Power"), - default=defaults.power_percent, - min_val=0.0, - max_val=100.0, - show_value=True, - format_suffix="%", - ), - SpeedVar( - key="speed", - label=_("Speed"), - default=int(defaults.speed), - min_val=1, - ), - ], - ) - - -def _labels_varset(defaults) -> VarSet: - return VarSet( - title=_("Labels"), - vars=[ - BoolVar( - key="include_labels", - label=_("Include Labels"), - default=defaults.include_labels, - ), - SliderFloatVar( - key="label_power_percent", - label=_("Label Engrave Power (%)"), - default=defaults.label_power_percent, - min_val=0.0, - max_val=100.0, - show_value=True, - format_suffix="%", - sensitive_when=_labels_active, - ), - SpeedVar( - key="label_speed", - label=_("Label Engrave Speed"), - default=int(defaults.label_speed), - min_val=1, - sensitive_when=_labels_active, - ), - ], - ) - - -def _interval_varset(defaults: IntervalTestParams) -> VarSet: - return VarSet( - title=_("Cells"), - description=_( - "Engraves a row of filled squares, each with its own line " - "interval, at the same power and speed." - ), - vars=[ - IntVar( - key="count", - label=_("Number of Cells"), - default=defaults.count, - min_val=2, - max_val=20, - ), - LengthVar( - key="min_interval", - label=_("Smallest Interval"), - default=defaults.min_interval, - min_val=0.01, - max_val=5.0, - digits=3, - ), - LengthVar( - key="max_interval", - label=_("Largest Interval"), - default=defaults.max_interval, - min_val=0.01, - max_val=5.0, - digits=3, - ), - LengthVar( - key="cell_size", - label=_("Cell Size"), - default=defaults.cell_size, - min_val=1.0, - max_val=100.0, - ), - LengthVar( - key="spacing", - label=_("Spacing"), - default=defaults.spacing, - min_val=0.0, - max_val=50.0, - ), - ], - ) - - -def _focus_varset(defaults: FocusTestParams, modes: list[FocusMode]) -> VarSet: - limit = MAX_FOCUS_OFFSET_MM - return VarSet( - title=_("Focus Steps"), - vars=[ - LabeledChoiceVar( - key="mode", - label=_("Method"), - choices=[(m.label, m.value) for m in modes], - default=modes[0].value, - allow_none=False, - ), - LengthVar( - key="start_offset", - label=_("First Offset"), - default=defaults.start_offset, - min_val=-limit, - max_val=limit, - digits=2, - visible_when=_is_not_ramp, - ), - LengthVar( - key="step", - label=_("Step"), - default=defaults.step, - min_val=-limit, - max_val=limit, - digits=2, - visible_when=_is_not_ramp, - ), - IntVar( - key="count", - label=_("Number of Lines"), - default=defaults.count, - min_val=2, - max_val=41, - visible_when=_is_not_ramp, - ), - LengthVar( - key="line_length", - label=_("Line Length"), - default=defaults.line_length, - min_val=1.0, - max_val=200.0, - visible_when=_is_not_ramp, - ), - LengthVar( - key="spacing", - label=_("Spacing"), - default=defaults.spacing, - min_val=0.5, - max_val=50.0, - visible_when=_is_not_ramp, - ), - LengthVar( - key="ramp_length", - label=_("Ramp Length"), - default=defaults.ramp_length, - min_val=10.0, - max_val=1000.0, - visible_when=_is_ramp, - ), - ], - ) - - -class _CalibrationTestDialog(PatchedDialogWindow): - """Shared frame: a header with Cancel/Create and one page.""" - - def __init__( - self, - parent: Gtk.Window | None, - title: str, - on_create: Callable[[Any], object], - varsets: list[VarSet], - ): - super().__init__(transient_for=parent, modal=True) - self.set_title(title) - self.set_default_size(DEFAULT_WIDTH, -1) - self._on_create = on_create - - toolbar = Adw.ToolbarView() - header = Adw.HeaderBar() - toolbar.add_top_bar(header) - self.set_content(toolbar) - - cancel = Gtk.Button(label=_("Cancel")) - cancel.connect("clicked", lambda _w: self.close()) - header.pack_start(cancel) - self.create_button = Gtk.Button(label=_("Create")) - self.create_button.add_css_class("suggested-action") - self.create_button.connect("clicked", self._on_create_clicked) - header.pack_end(self.create_button) - - self.banner = Adw.Banner() - toolbar.add_top_bar(self.banner) - page = Adw.PreferencesPage() - toolbar.set_content(page) - - self.varset_widgets: list[VarSetWidget] = [] - for var_set in varsets: - widget = VarSetWidget() - widget.populate(var_set) - page.add(widget) - self.varset_widgets.append(widget) - - def values(self) -> dict[str, Any]: - """The values of every row, keyed like the params fields.""" - values: dict[str, Any] = {} - for widget in self.varset_widgets: - values.update(widget.get_values()) - return values - - def params(self) -> object: - raise NotImplementedError - - def _on_create_clicked(self, _button): - try: - self._on_create(self.params()) - except ValueError as e: - self.banner.set_title(str(e)) - self.banner.set_revealed(True) - return - self.close() - - -class IntervalTestDialog(_CalibrationTestDialog): - """Asks for the settings of an Interval Test.""" - - def __init__( - self, - parent: Gtk.Window | None, - on_create: Callable[[IntervalTestParams], object], - ): - defaults = IntervalTestParams() - super().__init__( - parent, - _("Create Interval Test"), - on_create, - [ - _interval_varset(defaults), - _laser_varset(defaults), - _labels_varset(defaults), - ], - ) - - def params(self) -> IntervalTestParams: - return IntervalTestParams(**self.values()) - - -class FocusTestDialog(_CalibrationTestDialog): - """Asks for the settings of a Focus Test.""" - - def __init__( - self, - parent: Gtk.Window | None, - on_create: Callable[[FocusTestParams], object], - modes: list[FocusMode], - ): - defaults = FocusTestParams() - super().__init__( - parent, - _("Create Focus Test"), - on_create, - [ - _focus_varset(defaults, modes), - _laser_varset(defaults), - _labels_varset(defaults), - ], - ) - self.steps_widget = self.varset_widgets[0] - self.steps_widget.data_changed.connect(self._on_steps_changed) - self._update_help() - - @property - def mode(self) -> FocusMode: - return FocusMode(self.steps_widget.get_values()["mode"]) - - def _on_steps_changed(self, sender, key: str): - if key == "mode": - self._update_help() - - def _update_help(self): - self.steps_widget.set_description(_FOCUS_HELP[self.mode]) - - def params(self) -> FocusTestParams: - values = self.values() - values["mode"] = FocusMode(values["mode"]) - return FocusTestParams(**values) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py index 6512273bd..dec57a7b5 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/material_test_grid_page.py @@ -1,310 +1,15 @@ """ -Material Test Grid Settings Widget +Material Test Grid step settings. -Provides UI for configuring material test array parameters. +The grid itself (presets, grid, labels and ranges) is set in the Test +Settings of the item properties panel, like the other calibration +tests; the step settings keep the laser head and the post-processing. """ -import logging -from gettext import gettext as _ -from typing import TYPE_CHECKING, Any - -from gi.repository import Adw, GLib, Gtk - -from rayforge.machine.models.laser import LaserHead -from rayforge.ui_gtk.mainwindow import MainWindow - -from ..material_test_helpers import GridMode from .laser_step_page import LaserStepSettingsPage -if TYPE_CHECKING: - from rayforge.doceditor.editor import DocEditor - - -logger = logging.getLogger(__name__) - -PRESET_KEYS = [ - "Diode Engrave", - "Diode Cut", - "CO2 Engrave", - "CO2 Cut", -] - -PRESETS = { - "Diode Engrave": { - "test_type": "Engrave", - "speed_range": (1000.0, 10000.0), - "power_range": (10.0, 100.0), - }, - "Diode Cut": { - "test_type": "Cut", - "speed_range": (100.0, 5000.0), - "power_range": (50.0, 100.0), - }, - "CO2 Engrave": { - "test_type": "Engrave", - "speed_range": (3000.0, 20000.0), - "power_range": (10.0, 50.0), - }, - "CO2 Cut": { - "test_type": "Cut", - "speed_range": (1000.0, 20000.0), - "power_range": (30.0, 100.0), - }, -} - -_GRID_KEYS = { - "test_type", - "grid_mode", - "grid_dimensions", - "shape_size", - "spacing", - "line_interval_mm", -} - -_LABEL_KEYS = { - "include_labels", - "label_power_percent", - "label_speed", -} - -_PARAM_KEYS = { - "fixed_speed", - "fixed_power", - "power_range", - "speed_range", - "passes_range", - "offset_range", -} - -#: Dimension row titles and subtitles per grid mode. -_DIMENSION_LABELS = { - "Power vs Speed": ( - (_("Columns (Power Steps)"), _("Rows (Speed Steps)")), - (_("Number of power variations"), _("Number of speed variations")), - ), - "Power vs Passes": ( - (_("Columns (Power Steps)"), _("Rows (Passes Steps)")), - (_("Number of power variations"), _("Number of passes variations")), - ), - "Speed vs Passes": ( - (_("Columns (Speed Steps)"), _("Rows (Passes Steps)")), - (_("Number of speed variations"), _("Number of passes variations")), - ), - "Speed vs Offset": ( - (_("Columns (Speed Steps)"), _("Rows (Offset Steps)")), - (_("Number of speed variations"), _("Number of offset variations")), - ), -} - class MaterialTestGridSettingsPage(LaserStepSettingsPage): - """Material Test Grid settings widget.""" + """Material Test Grid step settings.""" include_process = False - - def __init__( - self, - editor: "DocEditor", - step: Any, - ): - super().__init__(editor, step) - self._build_preset_section() - groups = self.step.recipe_varset_groups() - step_vars = groups[-1][1] if len(groups) > 1 else None - if step_vars is None: - return - self.grid_widget = self.add_varset_section( - _("Grid"), - self._varset_for_keys(step_vars, _GRID_KEYS), - description=_("Test cell dimensions, shape, and spacing."), - ) - self.labels_widget = self.add_varset_section( - _("Labels"), - self._varset_for_keys(step_vars, _LABEL_KEYS), - description=_("Speed/power annotations on the grid."), - ) - self.params_widget = self.add_varset_section( - _("Parameters"), - self._varset_for_keys(step_vars, _PARAM_KEYS), - description=_("Define the parameter ranges for the test grid."), - ) - - # The Parameters section's visible_when predicates key off - # grid_mode, whose row lives in the Grid section. Feed it in - # as context and refresh whenever the mode changes. - self._sync_grid_context() - mode_adapter = self.grid_widget.adapter_for("grid_mode") - if mode_adapter is not None: - mode_adapter.changed.connect( - lambda sender: self._sync_grid_context(), weak=False - ) - self._update_dimension_labels() - self._update_machine_bounds() - self._push_head_defaults() - - def _push_head_defaults(self): - """Show the laser spot-width default for the auto line interval. - - The step keeps ``None`` (auto) until the user edits the row; - the dialog displays the laser spot-width default instead. - """ - head = self.get_selected_head() - if not isinstance(head, LaserHead): - return - if self.step.line_interval_mm is None: - self.grid_widget.set_values( - {"line_interval_mm": head.spot_size_mm[1]} - ) - - def _build_preset_section(self): - """Builds the preset dropdown (page chrome: a UI action, not a - setting).""" - _PRESET_LABELS = { - "Diode Engrave": _("Diode Engrave"), - "Diode Cut": _("Diode Cut"), - "CO2 Engrave": _("CO2 Engrave"), - "CO2 Cut": _("CO2 Cut"), - } - string_list = Gtk.StringList() - string_list.append(_("Select")) - for key in PRESET_KEYS: - string_list.append(_PRESET_LABELS[key]) - - group = self.add_section( - _("Preset"), - description=_("Load common test configurations."), - ) - self.preset_row = Adw.ComboRow( - title=_("Presets"), - subtitle=_("Load common test configurations"), - model=string_list, - ) - self.preset_row.set_selected(0) - group.add(self.preset_row) - self.preset_row.connect("notify::selected", self._on_preset_changed) - - def _sync_grid_context(self): - """Feed the current grid_mode into the Parameters section.""" - mode = self.grid_widget.get_values().get("grid_mode") - if mode is None: - mode = self.step.grid_mode - self.params_widget.set_context_values({"grid_mode": mode}) - - def _update_dimension_labels(self): - """Retitle the cols/rows rows for the current grid mode.""" - adapter = self.grid_widget.adapter_for("grid_dimensions") - if adapter is None: - return - mode = self.grid_widget.get_values().get("grid_mode") - if mode is None: - mode = self.step.grid_mode - labels, subtitles = _DIMENSION_LABELS.get( - mode, _DIMENSION_LABELS[GridMode.POWER_VS_SPEED.value] - ) - adapter.set_item_labels(labels) - adapter.set_item_subtitles(subtitles) - - def _max_speed_ceiling(self) -> float: - """Highest selectable speed, from the active machine. - - Falls back to the step's creation-time snapshot when no - machine is active, so projects edited without one stay usable. - """ - machine = self.get_machine() - if machine is not None: - return float(machine.max_cut_speed) - return float(self.step.max_cut_speed) - - def _update_machine_bounds(self): - """Cap the speed range at the active machine's limit.""" - adapter = self.params_widget.adapter_for("speed_range") - if adapter is not None: - adapter.set_bounds(1.0, self._max_speed_ceiling()) - - def _on_machine_changed(self): - """Re-apply mode-dependent state after a config/machine change. - - The base rebuilds every varset (``populate`` resets the tuple - row labels to the var defaults), so the mode-specific dimension - labels and the Parameters grid context must be restored here. - """ - super()._on_machine_changed() - self._sync_grid_context() - self._update_dimension_labels() - - def _sync_widgets_to_model(self, *args): - super()._sync_widgets_to_model(*args) - self._sync_grid_context() - self._update_dimension_labels() - self._update_machine_bounds() - - def _on_varset_data_changed(self, widget, key): - if key == "grid_mode": - super()._on_varset_data_changed(widget, key) - self._sync_grid_context() - self._update_dimension_labels() - self._apply_speed_vs_offset_defaults() - return - if key in ( - "power_range", - "speed_range", - "passes_range", - "offset_range", - ): - self._exit_preview_mode_if_active() - super()._on_varset_data_changed(widget, key) - - # Signal handlers - def _on_preset_changed(self, row: Adw.ComboRow, _pspec): - """Loads preset values.""" - selected_idx = row.get_selected() - if selected_idx == Gtk.INVALID_LIST_POSITION or selected_idx == 0: - return - preset_key = PRESET_KEYS[selected_idx - 1] - preset = PRESETS[preset_key] - speed_range = preset["speed_range"] - power_range = preset["power_range"] - test_type = preset.get("test_type", "Cut") - - machine_max_speed = self._max_speed_ceiling() - min_speed = min(speed_range[0], machine_max_speed) - max_speed = min(speed_range[1], machine_max_speed) - - self.set_step_property("speed_range", (min_speed, max_speed)) - self.set_step_property("power_range", power_range) - self.set_step_property("test_type", test_type) - self.params_widget.set_values( - {"speed_range": (min_speed, max_speed), "power_range": power_range} - ) - self.grid_widget.set_values({"test_type": test_type}) - - def _apply_speed_vs_offset_defaults(self): - """Bidir scan offset calibration only makes sense for raster - engraving (Cut has no bidirectional scanning to calibrate), and - needs wide line spacing to make row-to-row misalignment clearly - visible by eye. Can't default the preset dropdown too, since - there are multiple Engrave presets (Diode/CO2) with different - ranges.""" - mode = self.grid_widget.get_values().get("grid_mode") - if mode != GridMode.SPEED_VS_OFFSET.value: - return - self.set_step_property("test_type", "Engrave") - self.grid_widget.set_values({"test_type": "Engrave"}) - self.set_step_property("line_interval_mm", 0.5) - self.grid_widget.set_values({"line_interval_mm": 0.5}) - - def _exit_preview_mode_if_active(self): - """Exits execution preview mode if currently active.""" - if not self.step.doc: - return - root = self.get_root() - if not isinstance(root, MainWindow): - return - - action = root.action_manager.get_action("view_mode") - if not action: - return - - state = action.get_state() - if state and state.get_string() == "preview": - action.change_state(GLib.Variant.new_string("2d")) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/tuple_adapter.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/tuple_adapter.py index 0073640d6..d936c4378 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/tuple_adapter.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/tuple_adapter.py @@ -170,6 +170,10 @@ def update_from_var(self, var: Var): cast(Adw.ActionRow, self._extra).set_subtitle( escape_title(subtitles[1]) ) + self.set_bounds( + var.min_val if var.min_val is not None else 0.0, + var.max_val if var.max_val is not None else 1e9, + ) def set_bounds(self, lower: float, upper: float) -> None: """Apply machine-dependent bounds to every component spin.""" diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py index 537f082ab..ffbb64f71 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/worker.py @@ -29,3 +29,13 @@ def register_steps(step_registry): step_registry.register(OffsetFillStep, addon_name=ADDON_NAME) step_registry.register(ShrinkWrapStep, addon_name=ADDON_NAME) step_registry.register(WavefrontStep, addon_name=ADDON_NAME) + + +@hookimpl +def register_asset_types(asset_type_registry): + """Register the record that keeps a calibration test's settings.""" + from .calibration.record import CalibrationRecord + + asset_type_registry.register( + CalibrationRecord, CalibrationRecord.asset_type_name, ADDON_NAME + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py new file mode 100644 index 000000000..1d36c2c4f --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py @@ -0,0 +1,435 @@ +"""Unit tests for the pure parts of the calibration test generators.""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from laser_essentials.calibration import ( + GENERATORS, + CalibrationLayout, + CalibrationRecord, + CalibrationState, + CellEntry, + CommandEntry, + FocusMode, + VectorShape, +) +from laser_essentials.calibration.builder import ( + CalibrationBuilder, + bounds_of, +) +from laser_essentials.calibration.focus_test import ( + available_modes, + focus_commands, + focus_offsets, + format_offset, +) +from laser_essentials.calibration.generator import ( + CalibrationGenerator, + _update_overscan, +) +from laser_essentials.calibration.interval_test import ( + interval_values, + lines_per_inch, +) +from laser_essentials.calibration.layout import ( + FilledRect, + ProceduralShape, + line, + row_positions, + text, +) +from laser_essentials.calibration.lookup import ( + _legacy_record, + record_for_items, +) +from laser_essentials.calibration.material_test_grid import ( + NO_PRESET, + OFFSET_TEST_LINE_INTERVAL, + MaterialTestGridGenerator, + params_from_step, +) +from laser_essentials.commands.calibration_cmd import _merge_steps +from laser_essentials.material_test_helpers import ( + draw_material_test_preview, + get_material_test_proportional_size, +) +from laser_essentials.steps import MaterialTestStep + +from rayforge.core.step_registry import step_registry +from rayforge.core.varset import TupleVar +from rayforge.core.workpiece import WorkPiece +from rayforge.image.procedural import ProceduralImporter +from rayforge.image.svg.importer import SvgImporter + + +def _interval(**kwargs): + params = {"min_interval": 0.05, "max_interval": 0.25, "count": 5} + params.update(kwargs) + return params + + +def _focus(**kwargs): + params = {"start_offset": -2.0, "step": 0.5, "count": 9} + params.update(kwargs) + return params + + +class TestIntervalValues: + def test_linear_including_both_ends(self): + assert interval_values(_interval()) == pytest.approx( + [0.05, 0.10, 0.15, 0.20, 0.25] + ) + + def test_rejects_fewer_than_two_cells(self): + with pytest.raises(ValueError): + interval_values(_interval(count=1)) + + @pytest.mark.parametrize("low,high", [(0.2, 0.1), (0.0, 0.1)]) + def test_rejects_bad_range(self, low, high): + with pytest.raises(ValueError): + interval_values(_interval(min_interval=low, max_interval=high)) + + def test_lines_per_inch(self): + assert lines_per_inch(0.1) == pytest.approx(254.0) + + +class TestRowPositions: + def test_cells_are_spaced_left_to_right(self): + assert row_positions(3, 10.0, 2.0) == pytest.approx([0.0, 12.0, 24.0]) + + +class TestFocusOffsets: + def test_offsets(self): + params = _focus(start_offset=-1.0, step=0.5, count=5) + assert focus_offsets(params) == pytest.approx( + [-1.0, -0.5, 0.0, 0.5, 1.0] + ) + + def test_rejects_zero_step(self): + with pytest.raises(ValueError): + focus_offsets(_focus(step=0.0)) + + def test_rejects_fewer_than_two_lines(self): + with pytest.raises(ValueError): + focus_offsets(_focus(count=1)) + + def test_rejects_unreasonable_range(self): + with pytest.raises(ValueError): + focus_offsets(_focus(start_offset=-8.0, step=1.0, count=20)) + + @pytest.mark.parametrize( + "value,text", [(-2.0, "-2.0"), (0.0, "0.0"), (0.5, "+0.5")] + ) + def test_format_offset(self, value, text): + assert format_offset(value) == text + + +class TestFocusCommands: + def test_ramp_needs_no_commands(self): + assert focus_commands(FocusMode.RAMP, _focus(), False) == {} + + def test_manual_pauses_before_every_line(self): + params = _focus(start_offset=-0.5, step=0.5, count=3) + commands = focus_commands(FocusMode.MANUAL, params, False) + assert sorted(commands) == [0, 1, 2] + assert all(c.lines == ["M0"] for c in commands.values()) + assert "-0.5" in commands[0].name + assert "0.5" in commands[1].name + + def test_z_axis_moves_relatively_and_returns(self): + params = _focus(start_offset=-1.0, step=0.5, count=3) + commands = focus_commands(FocusMode.Z_AXIS, params, False) + assert sorted(commands) == [0, 1, 2, 3] + assert commands[0].lines == ["G91", "G0 Z-1", "G90"] + assert commands[1].lines == ["G91", "G0 Z0.5", "G90"] + assert commands[2].lines == ["G91", "G0 Z0.5", "G90"] + assert commands[3].lines == ["G91", "G0 Z0", "G90"] + + def test_z_axis_return_balances_moves(self): + params = _focus(start_offset=0.5, step=0.25, count=4) + commands = focus_commands(FocusMode.Z_AXIS, params, False) + total = sum(float(c.lines[1].split("Z")[1]) for c in commands.values()) + assert total == pytest.approx(0.0) + + def test_z_axis_respects_reversed_axis(self): + params = _focus(start_offset=-1.0, step=0.5, count=2) + commands = focus_commands(FocusMode.Z_AXIS, params, True) + assert commands[0].lines == ["G91", "G0 Z1", "G90"] + assert commands[1].lines == ["G91", "G0 Z-0.5", "G90"] + + def test_help_text_per_mode(self): + texts = {mode.help_text for mode in FocusMode} + assert len(texts) == 3 + + +class TestLayout: + def test_cells_and_commands_keep_their_order(self): + step = MaterialTestStep() + cell = CellEntry("a", VectorShape(line(0, 0, 1, 0)), step) + command = CommandEntry("pause", ["M0"]) + layout = CalibrationLayout([command, cell]) + assert layout.cells == [cell] + assert layout.commands == [command] + assert layout.entries == [command, cell] + + +class TestRegistry: + def test_all_three_tests_are_registered(self): + assert sorted(GENERATORS) == [ + "focus_test", + "interval_test", + "material_test_grid", + ] + for generator_id, generator in GENERATORS.items(): + assert generator.id == generator_id + assert generator.title + + def test_not_available_without_machine(self): + context: Any = SimpleNamespace(machine=None) + for generator in GENERATORS.values(): + assert not generator.is_available(context) + + +class TestRecord: + def test_round_trip(self): + record = CalibrationRecord( + generator_id="material_test_grid", + layer_uid="layer", + state=CalibrationState( + params={"grid_dimensions": (3, 4), "spacing": 1.0}, + workpiece_uids=("wp",), + step_uids=("s1", "s2"), + asset_uids=("a",), + ), + name="Material Test Grid", + ) + data = record.to_dict() + assert data["type"] == "calibration_test" + assert data["params"]["grid_dimensions"] == [3, 4] + copy = CalibrationRecord.from_dict(data) + assert copy.uid == record.uid + assert copy.name == record.name + assert copy.layer_uid == "layer" + assert copy.state.workpiece_uids == ("wp",) + assert copy.state.step_uids == ("s1", "s2") + assert copy.state.asset_uids == ("a",) + assert copy.params == {"grid_dimensions": [3, 4], "spacing": 1.0} + + def test_is_hidden_and_owns_its_workpieces(self): + record = CalibrationRecord( + "interval_test", + "layer", + CalibrationState({}, workpiece_uids=("a", "b")), + ) + assert record.hidden + assert record.owns("a") + assert not record.owns("c") + assert record.get_thumbnail(64) is None + + def test_set_state_notifies(self): + record = CalibrationRecord( + "interval_test", "layer", CalibrationState({"count": 2}) + ) + seen = [] + record.updated.connect(lambda sender: seen.append(sender), weak=False) + record.set_state(record.with_params({"count": 3})) + assert record.params == {"count": 3} + assert seen == [record] + + def test_params_are_a_copy(self): + record = CalibrationRecord( + "interval_test", "layer", CalibrationState({"count": 2}) + ) + record.params["count"] = 9 + assert record.params == {"count": 2} + + +class TestMaterialTestGrid: + generator = MaterialTestGridGenerator() + + def _context(self, max_speed=5000.0) -> Any: + return SimpleNamespace( + machine=SimpleNamespace(max_cut_speed=max_speed) + ) + + def test_defaults_clamp_speed_to_machine(self): + defaults = self.generator.defaults(self._context(300.0)) + assert defaults["speed_range"] == (100.0, 300.0) + assert defaults["preset"] == NO_PRESET + + def test_speed_range_bound_follows_machine(self): + varset = self.generator.varset(self._context(8000.0)) + speed_range = varset["speed_range"] + assert isinstance(speed_range, TupleVar) + assert speed_range.max_val == 8000.0 + + def test_preset_fills_in_values_and_clamps(self): + params = self.generator.defaults(self._context()) + params["preset"] = "Diode Engrave" + result = self.generator.apply_change( + params, + {"preset"}, + self._context(), + ) + assert result["test_type"] == "Engrave" + assert result["power_range"] == (10.0, 100.0) + assert result["speed_range"] == (1000.0, 5000.0) + + def test_preset_only_applies_when_changed(self): + params = self.generator.defaults(self._context()) + params["preset"] = "CO2 Cut" + params["power_range"] = (1.0, 2.0) + result = self.generator.apply_change( + params, + {"spacing"}, + self._context(), + ) + assert result["power_range"] == (1.0, 2.0) + + def test_speed_vs_offset_switches_to_engrave(self): + params = self.generator.defaults(self._context()) + params["grid_mode"] = "Speed vs Offset" + result = self.generator.apply_change( + params, + {"grid_mode"}, + self._context(), + ) + assert result["test_type"] == "Engrave" + assert result["line_interval_mm"] == OFFSET_TEST_LINE_INTERVAL + + def test_dimension_labels_follow_mode(self): + labels, _subtitles = self.generator.row_labels( + {"grid_mode": "Speed vs Passes"} + )["grid_dimensions"] + assert labels == ("Columns (Speed Steps)", "Rows (Passes Steps)") + + def test_params_from_step(self): + step = MaterialTestStep() + step.grid_dimensions = (7, 3) + params = params_from_step(step) + assert params["grid_dimensions"] == (7, 3) + assert params["line_interval_mm"] == 0.0 + + def test_help_text(self): + assert self.generator.help_text({}) + + def test_counts_are_whole_numbers(self): + params = self.generator.defaults(self._context()) + params["grid_dimensions"] = [3.0, 4.0] + params["passes_range"] = (1.0, 5.0) + grid = self.generator._grid_params(params) + assert grid["grid_dimensions"] == (3, 4) + assert grid["passes_range"] == (1, 5) + assert all(isinstance(v, int) for v in grid["passes_range"]) + + def test_rejects_too_small_grid(self): + params = self.generator.defaults(self._context()) + params["grid_dimensions"] = (1, 4) + with pytest.raises(ValueError): + self.generator._grid_params(params) + + +class TestMergeSteps: + def _steps(self, *names): + steps = [] + for name in names: + step = MaterialTestStep() + step.name = name + steps.append(step) + return steps + + def test_new_steps_take_the_place_of_the_old(self): + user_a, old1, old2, user_b = self._steps("ua", "o1", "o2", "ub") + new = self._steps("n1", "n2", "n3") + result = _merge_steps( + [user_a, old1, old2, user_b], {old1.uid, old2.uid}, new + ) + assert result == [user_a, *new, user_b] + + def test_new_steps_go_first_when_none_are_left(self): + (user,) = self._steps("u") + new = self._steps("n") + assert _merge_steps([user], {"gone"}, new) == [*new, user] + + +class TestHelpers: + def test_text_shrinks_to_fit(self): + wide = text("0.25 mm", 2.5, 0.0, 0.0) + narrow = text("0.25 mm", 2.5, 0.0, 0.0, max_width=2.0) + min_x, _min_y, max_x, _max_y = narrow.rect() + assert max_x - min_x == pytest.approx(2.0) + assert wide.rect()[2] - wide.rect()[0] > 2.0 + + def test_base_generator_has_no_help(self): + generator = GENERATORS["interval_test"] + assert CalibrationGenerator.help_text(generator, {}) == "" + + def test_engrave_step_without_machine_keeps_overscan(self): + step = MaterialTestStep() + step.per_workpiece_transformers_dicts = [ + {"name": "OverscanTransformer", "auto": True, "distance_mm": 1.0} + ] + context: Any = SimpleNamespace(machine=None) + _update_overscan(context, step) + assert step.per_workpiece_transformers_dicts[0]["distance_mm"] == 1.0 + + def test_focus_modes_without_machine(self): + context: Any = SimpleNamespace(machine=None) + assert available_modes(context) == [FocusMode.RAMP] + + +class TestBuilder: + def test_bounds_fall_back_to_the_box_without_geometry(self): + wp = WorkPiece("empty") + wp.set_size(4.0, 3.0) + wp.pos = (10.0, 20.0) + assert bounds_of([wp]) == pytest.approx((10.0, 20.0, 14.0, 23.0)) + + def test_command_needs_the_automation_addon(self, monkeypatch): + monkeypatch.setattr(step_registry, "get", lambda name: None) + context: Any = SimpleNamespace(machine=None) + builder = CalibrationBuilder(context) + layout = CalibrationLayout([CommandEntry("pause", ["M0"])]) + with pytest.raises(ValueError): + builder.build(layout) + + @pytest.mark.parametrize( + "shape", + [ + VectorShape(line(0, 0, 1, 0)), + FilledRect(0, 0, 1, 1), + ], + ) + def test_failed_svg_import_is_an_error(self, monkeypatch, shape): + monkeypatch.setattr( + SvgImporter, "get_doc_items", lambda self, spec: None + ) + context: Any = SimpleNamespace(machine=None) + builder = CalibrationBuilder(context) + layout = CalibrationLayout([CellEntry("a", shape, MaterialTestStep())]) + with pytest.raises(RuntimeError): + builder.build(layout) + + def test_failed_procedural_import_is_an_error(self, monkeypatch): + monkeypatch.setattr( + ProceduralImporter, "get_doc_items", lambda self, spec: None + ) + shape = ProceduralShape( + draw_function=draw_material_test_preview, + size_function=get_material_test_proportional_size, + params={}, + ) + context: Any = SimpleNamespace(machine=None) + builder = CalibrationBuilder(context) + layout = CalibrationLayout([CellEntry("a", shape, MaterialTestStep())]) + with pytest.raises(RuntimeError): + builder.build(layout) + + +class TestLookup: + def test_items_outside_a_document(self): + assert record_for_items([WorkPiece("loose")]) is None + + def test_workpiece_without_layer(self): + assert _legacy_record(WorkPiece("loose")) is None diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py deleted file mode 100644 index 5554530fc..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_test_params.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest -from laser_essentials.calibration_tests import ( - FocusMode, - FocusTestParams, - IntervalTestParams, - focus_commands, - focus_offsets, - format_offset, - interval_values, - lines_per_inch, - row_positions, -) - - -class TestIntervalValues: - def test_linear_including_both_ends(self): - params = IntervalTestParams( - min_interval=0.05, max_interval=0.25, count=5 - ) - assert interval_values(params) == pytest.approx( - [0.05, 0.10, 0.15, 0.20, 0.25] - ) - - def test_rejects_fewer_than_two_cells(self): - with pytest.raises(ValueError): - interval_values(IntervalTestParams(count=1)) - - @pytest.mark.parametrize("low,high", [(0.2, 0.1), (0.0, 0.1)]) - def test_rejects_bad_range(self, low, high): - params = IntervalTestParams(min_interval=low, max_interval=high) - with pytest.raises(ValueError): - interval_values(params) - - def test_lines_per_inch(self): - assert lines_per_inch(0.1) == pytest.approx(254.0) - - -class TestRowPositions: - def test_cells_are_spaced_left_to_right(self): - assert row_positions(3, 10.0, 2.0) == pytest.approx([0.0, 12.0, 24.0]) - - -class TestFocusOffsets: - def test_offsets(self): - params = FocusTestParams(start_offset=-1.0, step=0.5, count=5) - assert focus_offsets(params) == pytest.approx( - [-1.0, -0.5, 0.0, 0.5, 1.0] - ) - - def test_rejects_zero_step(self): - with pytest.raises(ValueError): - focus_offsets(FocusTestParams(step=0.0)) - - def test_rejects_unreasonable_range(self): - with pytest.raises(ValueError): - focus_offsets( - FocusTestParams(start_offset=-8.0, step=1.0, count=20) - ) - - @pytest.mark.parametrize( - "value,text", [(-2.0, "-2.0"), (0.0, "0.0"), (0.5, "+0.5")] - ) - def test_format_offset(self, value, text): - assert format_offset(value) == text - - -class TestFocusCommands: - def test_ramp_needs_no_commands(self): - params = FocusTestParams(mode=FocusMode.RAMP) - assert focus_commands(params, reverse_z=False) == [] - - def test_manual_pauses_before_every_line(self): - params = FocusTestParams( - mode=FocusMode.MANUAL, start_offset=-0.5, step=0.5, count=3 - ) - commands = focus_commands(params, reverse_z=False) - # (index of the line the command precedes, G-code lines) - assert [index for index, _code, _name in commands] == [0, 1, 2] - assert all(code == ["M0"] for _i, code, _n in commands) - names = [name for _i, _code, name in commands] - assert "-0.5" in names[0] - assert "0.5" in names[1] - - def test_z_axis_moves_relatively_and_returns(self): - params = FocusTestParams( - mode=FocusMode.Z_AXIS, start_offset=-1.0, step=0.5, count=3 - ) - commands = focus_commands(params, reverse_z=False) - assert [index for index, _code, _name in commands] == [0, 1, 2, 3] - assert commands[0][1] == ["G91", "G0 Z-1", "G90"] - assert commands[1][1] == ["G91", "G0 Z0.5", "G90"] - assert commands[2][1] == ["G91", "G0 Z0.5", "G90"] - # Back to the starting height after the last line. - assert commands[3][1] == ["G91", "G0 Z0", "G90"] - - def test_z_axis_return_balances_moves(self): - params = FocusTestParams( - mode=FocusMode.Z_AXIS, start_offset=0.5, step=0.25, count=4 - ) - commands = focus_commands(params, reverse_z=False) - total = sum(float(code[1].split("Z")[1]) for _i, code, _n in commands) - assert total == pytest.approx(0.0) - - def test_z_axis_respects_reversed_axis(self): - params = FocusTestParams( - mode=FocusMode.Z_AXIS, start_offset=-1.0, step=0.5, count=2 - ) - commands = focus_commands(params, reverse_z=True) - assert commands[0][1] == ["G91", "G0 Z1", "G90"] - assert commands[1][1] == ["G91", "G0 Z-0.5", "G90"] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py new file mode 100644 index 000000000..d67e98286 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py @@ -0,0 +1,335 @@ +# flake8: noqa: E402 +"""UI tests for the Test Settings of the calibration tests.""" + +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock + +import gi +import pytest + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from laser_essentials.commands import CalibrationCmd +from laser_essentials.frontend import _create_test +from laser_essentials.widgets.calibration_provider import ( + CalibrationPropertyProvider, +) + +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.shared.pref_rows import SpeedSpinRow + + +@pytest.fixture +def cmd(editor, laser_machine): + return CalibrationCmd(editor) + + +@pytest.fixture +def provider(): + provider = CalibrationPropertyProvider() + provider.create_widgets() + return provider + + +def _show(provider, editor, cmd, generator_id, params=None): + record = cmd.create(generator_id, params) + workpieces = cmd.workpieces(record) + assert provider.can_handle(workpieces) + provider.update_widgets(editor, workpieces) + return record + + +def _row(provider, key): + row = provider.varset_widget.row_for(key) + assert row is not None, key + return row + + +def _set(provider, key, value): + """Changes a row the way a user does, so the row emits a change.""" + row = provider.varset_widget.row_for(key) + adapter = provider.varset_widget.adapter_for(key) + assert row is not None and adapter is not None, key + if isinstance(value, tuple): + rows = [row, *adapter.extra_rows()] + for spin_row, item in zip(rows, value): + spin_row.get_spin_button().set_value(item) + elif hasattr(row, "get_spin_button"): + row.get_spin_button().set_value(value) + else: + adapter.set_value(value) + + +def _step(editor, record): + layer = editor.doc.find_descendant_by_uid(record.layer_uid) + (step,) = layer.workflow.steps + return step + + +@pytest.mark.ui +def test_only_handles_workpieces_of_one_test(provider, editor, cmd): + interval = cmd.create("interval_test") + grid = cmd.create("material_test_grid") + other = WorkPiece("Other") + editor.doc.active_layer.add_child(other) + + assert provider.can_handle(cmd.workpieces(interval)) + assert provider.can_handle(cmd.workpieces(grid)) + assert not provider.can_handle([other]) + assert not provider.can_handle( + cmd.workpieces(interval) + cmd.workpieces(grid) + ) + assert not provider.can_handle([]) + + +@pytest.mark.ui +def test_shows_the_parameters_of_the_test(provider, editor, cmd): + record = _show(provider, editor, cmd, "interval_test", {"count": 7}) + assert provider.varset_widget.get_values()["count"] == 7 + assert provider.group_subtitle == record.name + assert provider.help_row.get_visible() + assert not provider.error_row.get_visible() + + +@pytest.mark.ui +def test_change_rebuilds_the_test_in_one_undo_step(provider, editor, cmd): + record = _show(provider, editor, cmd, "interval_test", {"count": 3}) + depth = len(editor.history_manager.undo_stack) + + _set(provider, "count", 5) + provider.apply_now() + + assert record.params["count"] == 5 + assert len(cmd.workpieces(record)) == 6 + assert len(editor.history_manager.undo_stack) == depth + 1 + + +@pytest.mark.ui +def test_invalid_values_show_an_error(provider, editor, cmd): + record = _show(provider, editor, cmd, "interval_test") + state = record.state + + _set(provider, "min_interval", 0.3) + provider.apply_now() + + assert provider.error_row.get_visible() + assert "minimum line interval" in provider.error_row.get_title() + assert record.state == state + + _set(provider, "min_interval", 0.1) + provider.apply_now() + assert not provider.error_row.get_visible() + assert record.params["min_interval"] == pytest.approx(0.1) + + +@pytest.mark.ui +def test_rows_follow_the_selected_test(provider, editor, cmd): + _show(provider, editor, cmd, "interval_test") + assert provider.varset_widget.row_for("cell_size") is not None + + _show(provider, editor, cmd, "focus_test") + assert provider.varset_widget.row_for("cell_size") is None + assert provider.varset_widget.row_for("mode") is not None + + +@pytest.mark.ui +def test_focus_help_follows_the_method(provider, editor, cmd): + _show(provider, editor, cmd, "focus_test") + help_before = provider.help_row.get_title() + assert _row(provider, "ramp_length").get_visible() is False + + _set(provider, "mode", "ramp") + provider.apply_now() + + assert provider.help_row.get_title() != help_before + assert "Prop up" in provider.help_row.get_title() + assert _row(provider, "ramp_length").get_visible() is True + assert _row(provider, "count").get_visible() is False + + +@pytest.mark.ui +def test_label_rows_follow_include_labels(provider, editor, cmd): + _show(provider, editor, cmd, "interval_test") + assert _row(provider, "label_speed").get_sensitive() is True + _set(provider, "include_labels", False) + assert _row(provider, "label_power_percent").get_sensitive() is False + assert _row(provider, "label_speed").get_sensitive() is False + + +@pytest.mark.ui +def test_grid_mode_visibility(provider, editor, cmd): + _show(provider, editor, cmd, "material_test_grid") + power = _row(provider, "power_range") + passes = _row(provider, "passes_range") + offset = _row(provider, "offset_range") + assert power.get_visible() is True + assert passes.get_visible() is False + assert offset.get_visible() is False + + _set(provider, "grid_mode", "Speed vs Offset") + assert power.get_visible() is False + assert offset.get_visible() is True + assert _row(provider, "fixed_power").get_visible() is True + + +@pytest.mark.ui +def test_speed_vs_offset_defaults(provider, editor, cmd): + record = _show(provider, editor, cmd, "material_test_grid") + _set(provider, "grid_mode", "Speed vs Offset") + provider.apply_now() + + values = provider.varset_widget.get_values() + assert values["test_type"] == "Engrave" + assert values["line_interval_mm"] == pytest.approx(0.5) + step = _step(editor, record) + assert step.test_type == "Engrave" + assert step.line_interval_mm == pytest.approx(0.5) + + +@pytest.mark.ui +def test_dimension_labels_follow_grid_mode(provider, editor, cmd): + _show(provider, editor, cmd, "material_test_grid") + row = _row(provider, "grid_dimensions") + assert row.get_title() == "Columns (Power Steps)" + + _set(provider, "grid_mode", "Speed vs Passes") + provider.apply_now() + row = _row(provider, "grid_dimensions") + assert row.get_title() == "Columns (Speed Steps)" + + +@pytest.mark.ui +def test_preset_applies_values_and_clamps(provider, editor, cmd): + record = _show(provider, editor, cmd, "material_test_grid") + _set(provider, "preset", "Diode Engrave") + provider.apply_now() + + step = _step(editor, record) + assert step.test_type == "Engrave" + assert step.power_range == (10.0, 100.0) + assert step.speed_range == (1000.0, 5000.0) + values = provider.varset_widget.get_values() + assert values["speed_range"] == (1000.0, 5000.0) + + +@pytest.mark.ui +def test_speed_bound_follows_the_machine(provider, editor, cmd, laser_machine): + record = _show(provider, editor, cmd, "material_test_grid") + adapter = provider.varset_widget.adapter_for("speed_range") + assert adapter is not None + max_row = cast(SpeedSpinRow, adapter.extra_rows()[0]) + assert max_row._upper == pytest.approx(5000.0) + + laser_machine.set_max_cut_speed(8000) + provider.update_widgets(editor, cmd.workpieces(record)) + adapter = provider.varset_widget.adapter_for("speed_range") + assert adapter is not None + max_row = cast(SpeedSpinRow, adapter.extra_rows()[0]) + assert max_row._upper == pytest.approx(8000.0) + + +@pytest.mark.ui +def test_speed_rows_are_unit_aware(provider, editor, cmd, ui_context): + _show(provider, editor, cmd, "material_test_grid") + label = cast(SpeedSpinRow, _row(provider, "label_speed")) + assert isinstance(label, SpeedSpinRow) + + ui_context.config.set_unit_preference("speed", "mm/s") + assert label.get_spin_button().get_text() == "16.7" + + +@pytest.mark.ui +def test_tuple_labels_survive_a_refresh(provider, editor, cmd, ui_context): + record = _show(provider, editor, cmd, "material_test_grid") + primary = _row(provider, "power_range") + adapter = provider.varset_widget.adapter_for("power_range") + assert adapter is not None + extra = adapter.extra_rows()[0] + + ui_context.config.set_unit_preference("speed", "mm/s") + provider.update_widgets(editor, cmd.workpieces(record)) + + assert primary.get_title() == "Minimum Power (%)" + assert extra.get_title() == "Maximum Power (%)" + + +@pytest.mark.ui +def test_legacy_grid_can_be_changed(provider, editor, cmd): + record = cmd.create("material_test_grid") + editor.doc.remove_asset(record) + workpieces = cmd.workpieces(record) + assert provider.can_handle(workpieces) + provider.update_widgets(editor, workpieces) + + _set(provider, "grid_dimensions", (3, 3)) + provider.apply_now() + + (new_wp,) = [ + wp + for wp in editor.doc.all_workpieces + if wp.uid not in {w.uid for w in workpieces} + ] + adopted = cmd.record_for_items([new_wp]) + assert adopted is not None + assert adopted.uid in editor.doc.assets + assert adopted.params["grid_dimensions"] == (3, 3) + + +@pytest.mark.ui +def test_debounced_change(provider, editor, cmd): + record = _show(provider, editor, cmd, "interval_test", {"count": 3}) + _set(provider, "count", 4) + assert provider._timer_id + assert record.params["count"] == 3 + provider.apply_now() + assert provider._timer_id == 0 + assert record.params["count"] == 4 + + +@pytest.mark.ui +def test_without_a_test_selected(provider, editor, cmd): + other = WorkPiece("Other") + editor.doc.active_layer.add_child(other) + provider.update_widgets(editor, [other]) + assert provider.generator is None + provider.apply_now() + assert provider.scroll_into_view() is False + + +@pytest.mark.ui +def test_apply_without_changes_does_nothing(provider, editor, cmd): + record = _show(provider, editor, cmd, "interval_test") + depth = len(editor.history_manager.undo_stack) + provider.apply_now() + assert len(editor.history_manager.undo_stack) == depth + assert record.params["count"] == 5 + + +@pytest.mark.ui +def test_create_failure_is_reported(editor): + sent = [] + editor.notification_requested.connect( + lambda sender, message: sent.append(message), weak=False + ) + calibration = MagicMock() + calibration.create.side_effect = ValueError("Select a machine first.") + window = SimpleNamespace( + doc_editor=SimpleNamespace( + calibration=calibration, + notification_requested=editor.notification_requested, + ) + ) + _create_test(SimpleNamespace(window=window), "interval_test") + assert sent == ["Select a machine first."] + + +@pytest.mark.ui +def test_changes_while_updating_are_ignored(provider, editor, cmd): + _show(provider, editor, cmd, "interval_test") + provider._in_update = True + _set(provider, "count", 9) + provider._in_update = False + assert provider._timer_id == 0 + assert not provider._pending_keys diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py deleted file mode 100644 index 9d9cd5086..000000000 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_test_dialogs.py +++ /dev/null @@ -1,102 +0,0 @@ -# flake8: noqa: E402 -"""UI tests for the Interval Test and Focus Test dialogs.""" - -from typing import cast - -import gi -import pytest - -gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") - -from gi.repository import Adw -from laser_essentials.calibration_tests import ( - FocusMode, - FocusTestParams, - IntervalTestParams, -) -from laser_essentials.widgets.calibration_test_dialogs import ( - DEFAULT_WIDTH, - FocusTestDialog, - IntervalTestDialog, -) - - -def _row(dialog, key): - for widget in dialog.varset_widgets: - row = widget.row_for(key) - if row is not None: - return row - raise KeyError(key) - - -@pytest.mark.ui -def test_interval_dialog_defaults_and_create(laser_machine): - created: list[IntervalTestParams] = [] - dialog = IntervalTestDialog(None, created.append) - assert dialog.params() == IntervalTestParams() - assert dialog.get_default_size()[0] == DEFAULT_WIDTH - - dialog.varset_widgets[0].set_values({"count": 7}) - dialog.create_button.emit("clicked") - assert len(created) == 1 - assert created[0].count == 7 - - -@pytest.mark.ui -def test_label_rows_follow_include_labels(laser_machine): - dialog = IntervalTestDialog(None, lambda p: None) - labels = dialog.varset_widgets[2] - labels.set_values({"include_labels": False}) - assert not _row(dialog, "label_power_percent").get_sensitive() - assert not _row(dialog, "label_speed").get_sensitive() - assert dialog.params().include_labels is False - - -@pytest.mark.ui -def test_interval_dialog_shows_errors(laser_machine): - def refuse(params): - raise ValueError("bad range") - - dialog = IntervalTestDialog(None, refuse) - dialog.create_button.emit("clicked") - assert dialog.banner.get_revealed() - assert dialog.banner.get_title() == "bad range" - - -@pytest.mark.ui -def test_focus_dialog_modes(laser_machine): - created: list[FocusTestParams] = [] - dialog = FocusTestDialog( - None, created.append, [FocusMode.MANUAL, FocusMode.RAMP] - ) - assert dialog.mode == FocusMode.MANUAL - assert _row(dialog, "step").get_visible() - assert not _row(dialog, "ramp_length").get_visible() - manual_help = dialog.steps_widget.get_description() - - cast(Adw.ComboRow, _row(dialog, "mode")).set_selected(1) - assert dialog.mode == FocusMode.RAMP - assert not _row(dialog, "step").get_visible() - assert _row(dialog, "ramp_length").get_visible() - assert dialog.steps_widget.get_description() != manual_help - - dialog.create_button.emit("clicked") - assert created[0].mode == FocusMode.RAMP - - -@pytest.mark.ui -def test_focus_dialog_without_manual_mode(laser_machine): - dialog = FocusTestDialog(None, lambda p: None, [FocusMode.RAMP]) - assert dialog.mode == FocusMode.RAMP - - -@pytest.mark.ui -def test_focus_dialog_defaults_to_z_axis_when_available(laser_machine): - dialog = FocusTestDialog( - None, - lambda p: None, - [FocusMode.Z_AXIS, FocusMode.MANUAL, FocusMode.RAMP], - ) - assert dialog.mode == FocusMode.Z_AXIS - assert _row(dialog, "step").get_visible() diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py index cfaf83312..48bd559ca 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py @@ -188,204 +188,15 @@ def test_material_test_laser_page_shows_travel_speed_with_support( @pytest.mark.ui -def test_material_test_tuple_rows_round_trip( +def test_material_test_page_leaves_the_grid_to_the_test_settings( editor, laser_machine, ui_context ): - """Tuple rows (ranges, grid dimensions) read and write tuples.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - for key in ("power_range", "speed_range", "passes_range", "offset_range"): - row = _row(page, key) - assert row is not None, f"{key} row missing" - _row(page, "grid_dimensions") - - # Programmatic push lands in the widgets. - page.params_widget.set_values( - { - "power_range": (20.0, 80.0), - "speed_range": (200.0, 900.0), - } - ) - values = page.params_widget.get_values() - assert values["power_range"] == (20.0, 80.0) - assert values["speed_range"] == (200.0, 900.0) - - -@pytest.mark.ui -def test_material_test_speed_rows_are_unit_aware(editor, ui_context): - """Material-test speed rows adapt to the user's unit preference.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - label = cast(SpeedSpinRow, _row(page, "label_speed")) - fixed = cast(SpeedSpinRow, _row(page, "fixed_speed")) - speed = cast(SpeedSpinRow, _row(page, "speed_range")) - assert isinstance(label, SpeedSpinRow) - assert isinstance(fixed, SpeedSpinRow) - assert isinstance(speed, SpeedSpinRow) - - # mm/min -> mm/s: divide by 60 (mm/s precision is one digit). - ui_context.config.set_unit_preference("speed", "mm/s") - assert label.get_spin_button().get_text() == "16.7" - assert fixed.get_spin_button().get_text() == "16.7" - assert speed.get_spin_button().get_text() == "1.7" - - -@pytest.mark.ui -def test_material_test_offset_rows_are_unit_aware(editor, ui_context): - """Material-test offset rows adapt to the user's unit preference.""" - step = MaterialTestStep() - step.grid_mode = "Speed vs Offset" - page = MaterialTestGridSettingsPage(editor, step) - - offset = cast(LengthSpinRow, _row(page, "offset_range")) - assert isinstance(offset, LengthSpinRow) - - # -0.5 mm -> -0.0197 in, rounded to three digits. - ui_context.config.set_unit_preference("length", "in") - assert offset.get_spin_button().get_text() == "-0.020" - - -@pytest.mark.ui -def test_material_test_tuple_labels_survive_config_change(editor, ui_context): - """A unit-preference change must not clobber tuple row labels. - - Rebuilding the varsets (as a config change does) must keep the - component titles, not replace the first row's title with the var's - own label. - """ - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - primary = _row(page, "power_range") - adapter = page.params_widget.adapter_for("power_range") - assert adapter is not None - extra = adapter.extra_rows()[0] - assert primary.get_title() == "Minimum Power (%)" - assert extra.get_title() == "Maximum Power (%)" - - # Changing the unit preference fires config.changed -> _on_config_changed - # -> populate -> adapter.update_from_var. - ui_context.config.set_unit_preference("speed", "mm/s") - - assert primary.get_title() == "Minimum Power (%)" - assert extra.get_title() == "Maximum Power (%)" - - -@pytest.mark.ui -def test_material_test_grid_mode_visibility(editor, laser_machine, ui_context): - """Parameters rows follow the grid-mode visible_when predicates.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - power = _row(page, "power_range") - speed = _row(page, "speed_range") - passes = _row(page, "passes_range") - offset = _row(page, "offset_range") - fixed_speed = _row(page, "fixed_speed") - fixed_power = _row(page, "fixed_power") - - assert power.get_visible() is True # Power vs Speed default - assert speed.get_visible() is True - assert passes.get_visible() is False - assert offset.get_visible() is False - - step.grid_mode = "Speed vs Offset" - step.updated.send(step) - assert power.get_visible() is False - assert speed.get_visible() is True - assert passes.get_visible() is False - assert offset.get_visible() is True - assert fixed_speed.get_visible() is False - assert fixed_power.get_visible() is True - - -@pytest.mark.ui -def test_material_test_labels_sensitivity(editor, laser_machine, ui_context): - """Label rows are insensitive while labels are disabled.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - label_power = _row(page, "label_power_percent") - label_speed = _row(page, "label_speed") - assert label_power.get_sensitive() is True - - step.include_labels = False - step.updated.send(step) - assert label_power.get_sensitive() is False - assert label_speed.get_sensitive() is False - - -@pytest.mark.ui -def test_material_test_preset_applies_values( - editor, laser_machine, ui_context -): - """Selecting a preset commits speed/power ranges and test type.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - page.preset_row.set_selected(2) # Diode Cut - - assert step.test_type == "Cut" - assert step.power_range == (50.0, 100.0) - assert step.speed_range[0] >= 100.0 - - -@pytest.mark.ui -def test_material_test_speed_bounds_follow_machine( - editor, laser_machine, ui_context -): - """Speed range rows cap at the live machine's limit, not the - step's creation-time snapshot (#439).""" - step_cls = step_registry.get("MaterialTestStep") - assert step_cls is not None - step = step_cls.create(ui_context) - step.max_cut_speed = 1000 # stale snapshot from an older machine - page = MaterialTestGridSettingsPage(editor, step) - - adapter = page.params_widget.adapter_for("speed_range") - assert adapter is not None - max_row = cast(SpeedSpinRow, adapter.extra_rows()[0]) - assert max_row._upper == pytest.approx(5000.0) - - # Raising the machine's limit while the page is open lifts the cap. - laser_machine.set_max_cut_speed(8000) - adapter = page.params_widget.adapter_for("speed_range") - assert adapter is not None - max_row = cast(SpeedSpinRow, adapter.extra_rows()[0]) - assert max_row._upper == pytest.approx(8000.0) - - -@pytest.mark.ui -def test_material_test_preset_clamps_to_machine_limit( - editor, laser_machine, ui_context -): - """Preset speed ranges clamp to the live machine's limit, not a - stale step snapshot.""" - step = MaterialTestStep() - step.max_cut_speed = 1000 # stale snapshot from an older machine - page = MaterialTestGridSettingsPage(editor, step) - page.preset_row.set_selected(1) # Diode Engrave: (1000, 10000) - - assert step.speed_range == (1000.0, 5000.0) - - -@pytest.mark.ui -def test_material_test_speed_vs_offset_defaults( - editor, laser_machine, ui_context -): - """Switching to Speed vs Offset applies engrave defaults.""" - step = MaterialTestStep() - page = MaterialTestGridSettingsPage(editor, step) - - mode_adapter = page.grid_widget.adapter_for("grid_mode") - assert mode_adapter is not None - mode_adapter.set_value("Speed vs Offset") - page._sync_grid_context() - page._apply_speed_vs_offset_defaults() - - assert step.test_type == "Engrave" - assert step.line_interval_mm == 0.5 + """The grid is set in the Test Settings panel, so the step page + has no grid, label or range rows.""" + page = MaterialTestGridSettingsPage(editor, MaterialTestStep()) + for key in ("grid_mode", "grid_dimensions", "speed_range"): + with pytest.raises(AssertionError): + _row(page, key) @pytest.mark.ui diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index 2cbc89056..29dc6db65 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -1,6 +1,7 @@ """ -End-to-end tests for the Interval Test and Focus Test generators: -the generated layer, its steps, and the G-code it produces on GRBL. +End-to-end tests for the calibration tests (Material Test Grid, +Interval Test, Focus Test): the generated layer, its steps, rebuilding, +undo, saving, and the G-code it produces on GRBL. """ import importlib @@ -12,7 +13,9 @@ import pytest_asyncio from rayforge.context import get_context +from rayforge.core.vectorization_spec import ProceduralSpec from rayforge.core.workpiece import WorkPiece +from rayforge.image.procedural import ProceduralImporter from rayforge.machine.models.machine import Origin from rayforge.pipeline.artifact import JobArtifact @@ -20,14 +23,10 @@ # The laser addon is importable through the sys.path setup of the root # conftest; static analysis does not see that path. -_calibration = importlib.import_module("laser_essentials.calibration_tests") -_command = importlib.import_module( - "laser_essentials.commands.calibration_test_cmd" -) +_calibration = importlib.import_module("laser_essentials.calibration") +_command = importlib.import_module("laser_essentials.commands.calibration_cmd") FocusMode = _calibration.FocusMode -FocusTestParams = _calibration.FocusTestParams -IntervalTestParams = _calibration.IntervalTestParams -CalibrationTestCmd = _command.CalibrationTestCmd +CalibrationCmd = _command.CalibrationCmd @pytest.fixture @@ -85,30 +84,444 @@ def _cut_moves(gcode: str) -> list[tuple[float, float]]: return points +def _layer(editor, record): + layer = editor.doc.find_descendant_by_uid(record.layer_uid) + assert layer is not None + return layer + + +def _bounds(workpieces): + rects = [ + wp.get_world_geometry().rect() # type: ignore[union-attr] + for wp in workpieces + ] + return ( + min(r[0] for r in rects), + min(r[1] for r in rects), + max(r[2] for r in rects), + max(r[3] for r in rects), + ) + + +def _records(editor): + return [ + a + for a in editor.doc.get_all_assets() + if a.asset_type_name == "calibration_test" + ] + + +# ---------------------------------------------------------------------- +# Shared behaviour of all three tests +# ---------------------------------------------------------------------- + + +ALL_TESTS = ["material_test_grid", "interval_test", "focus_test"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("generator_id", ALL_TESTS) +async def test_each_test_gets_its_own_active_layer( + doc_editor, machine, generator_id +): + cmd = CalibrationCmd(doc_editor) + before = list(doc_editor.doc.layers) + record = cmd.create(generator_id) + + layer = _layer(doc_editor, record) + assert layer not in before + assert doc_editor.doc.active_layer is layer + assert layer.name == cmd.generator(generator_id).title + assert layer.stock_material_uid is not None + assert layer.color not in {other.color for other in before} + assert record in _records(doc_editor) + assert record.hidden + + +@pytest.mark.asyncio +@pytest.mark.parametrize("generator_id", ALL_TESTS) +async def test_every_step_is_linked_to_its_own_workpiece( + doc_editor, machine, generator_id +): + cmd = CalibrationCmd(doc_editor) + record = cmd.create(generator_id) + layer = _layer(doc_editor, record) + + workpieces = {wp.uid for wp in layer.all_workpieces} + assert set(record.state.workpiece_uids) == workpieces + linked = [s for s in _steps(layer) if s.workpiece_uids] + assert len(linked) == len(workpieces) + assert {s.workpiece_uids[0] for s in linked} == workpieces + assert [s.uid for s in _steps(layer)] == list(record.state.step_uids) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("generator_id", ALL_TESTS) +async def test_create_is_centered_and_one_undo_step( + doc_editor, machine, generator_id +): + cmd = CalibrationCmd(doc_editor) + layers_before = list(doc_editor.doc.layers) + assets_before = set(doc_editor.doc.assets) + active_before = doc_editor.doc.active_layer + record = cmd.create(generator_id) + layer = _layer(doc_editor, record) + + min_x, min_y, max_x, max_y = _bounds(cmd.workpieces(record)) + center = ((min_x + max_x) / 2, (min_y + max_y) / 2) + assert center == pytest.approx((200.0, 200.0), abs=0.01) + + doc_editor.history_manager.undo() + assert list(doc_editor.doc.layers) == layers_before + assert set(doc_editor.doc.assets) == assets_before + assert doc_editor.doc.active_layer is active_before + + doc_editor.history_manager.redo() + assert layer in doc_editor.doc.layers + assert record in _records(doc_editor) + + +@pytest.mark.asyncio +async def test_layer_names_stay_unique(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + first = _layer(doc_editor, cmd.create("interval_test")) + second = _layer(doc_editor, cmd.create("interval_test")) + assert first.name == "Interval Test" + assert second.name == "Interval Test 2" + + +@pytest.mark.asyncio +async def test_update_keeps_position_and_is_one_undo_step(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 3}) + for wp in cmd.workpieces(record): + wp.pos = (wp.pos[0] + 30.0, wp.pos[1] - 20.0) + corner = _bounds(cmd.workpieces(record))[:2] + old_uids = set(record.state.workpiece_uids) + undo_depth = len(doc_editor.history_manager.undo_stack) + + params = record.params + params["count"] = 6 + new_wps = cmd.update_params(record, params) + + layer = _layer(doc_editor, record) + assert len(doc_editor.history_manager.undo_stack) == undo_depth + 1 + assert record.params["count"] == 6 + assert len(new_wps) == 7 + assert set(record.state.workpiece_uids).isdisjoint(old_uids) + assert {wp.uid for wp in layer.all_workpieces} == { + wp.uid for wp in new_wps + } + assert _bounds(new_wps)[:2] == pytest.approx(corner, abs=0.01) + + doc_editor.history_manager.undo() + assert record.params["count"] == 3 + assert {wp.uid for wp in layer.all_workpieces} == old_uids + assert len(_steps(layer)) == 4 + + +@pytest.mark.asyncio +async def test_update_stays_on_the_work_area(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test", {"mode": "ramp", "ramp_length": 50.0}) + for wp in cmd.workpieces(record): + wp.pos = (wp.pos[0] + 120.0, wp.pos[1]) + params = record.params + params["ramp_length"] = 300.0 + workpieces = cmd.update_params(record, params) + min_x, _min_y, max_x, _max_y = _bounds(workpieces) + assert max_x == pytest.approx(400.0, abs=0.01) + assert min_x > 0 + + +@pytest.mark.asyncio +async def test_create_needs_a_machine(doc_editor, machine, mocker): + mocker.patch.object( + type(doc_editor.context), "machine", new_callable=mocker.PropertyMock + ).return_value = None + layers = list(doc_editor.doc.layers) + with pytest.raises(ValueError): + CalibrationCmd(doc_editor).create("interval_test") + assert list(doc_editor.doc.layers) == layers + + +@pytest.mark.asyncio +async def test_update_after_the_layer_was_deleted(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test") + doc_editor.doc.remove_child(_layer(doc_editor, record)) + with pytest.raises(ValueError): + cmd.update_params(record, {**record.params, "count": 3}) + + +@pytest.mark.asyncio +async def test_update_after_all_cells_were_deleted(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + layer = _layer(doc_editor, record) + for wp in cmd.workpieces(record): + layer.remove_child(wp) + workpieces = cmd.update_params(record, {**record.params, "count": 3}) + min_x, min_y, max_x, max_y = _bounds(workpieces) + center = ((min_x + max_x) / 2, (min_y + max_y) / 2) + assert center == pytest.approx((200.0, 200.0), abs=0.01) + assert len(layer.all_workpieces) == 4 + + +@pytest.mark.asyncio +async def test_update_keeps_steps_the_user_added(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + layer = _layer(doc_editor, record) + extra = _steps(layer)[0].__class__.create(doc_editor.context) + extra.name = "Cut out" + layer.workflow.add_step(extra) + + params = record.params + params["count"] = 4 + cmd.update_params(record, params) + + steps = _steps(layer) + assert steps[-1] is extra + assert len(steps) == 1 + 4 + 1 + + +@pytest.mark.asyncio +async def test_invalid_params_leave_the_test_alone(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test") + state = record.state + undo_depth = len(doc_editor.history_manager.undo_stack) + + params = record.params + params["min_interval"] = params["max_interval"] + with pytest.raises(ValueError): + cmd.update_params(record, params) + assert record.state == state + assert len(doc_editor.history_manager.undo_stack) == undo_depth + + +@pytest.mark.asyncio +async def test_update_without_change_does_nothing(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test") + undo_depth = len(doc_editor.history_manager.undo_stack) + workpieces = cmd.update_params(record, record.params) + assert len(doc_editor.history_manager.undo_stack) == undo_depth + assert [wp.uid for wp in workpieces] == list(record.state.workpiece_uids) + + +@pytest.mark.asyncio +async def test_update_removes_old_assets_unless_still_used( + doc_editor, machine +): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + layer = _layer(doc_editor, record) + cells = cmd.workpieces(record) + copy = cells[-1].duplicate() + layer.add_child(copy) + kept = copy.source_segment.source_asset_uid + old_assets = set(record.state.asset_uids) + + params = record.params + params["count"] = 3 + cmd.update_params(record, params) + + assert kept in doc_editor.doc.assets + gone = old_assets - {kept} + assert gone + assert gone.isdisjoint(doc_editor.doc.assets) + assert set(record.state.asset_uids) <= set(doc_editor.doc.assets) + + +@pytest.mark.asyncio +async def test_record_survives_save_and_load(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test", {"count": 4}) + data = doc_editor.doc.to_dict() + + loaded = type(doc_editor.doc).from_dict(data) + records = [ + a + for a in loaded.get_all_assets() + if a.asset_type_name == "calibration_test" + ] + assert len(records) == 1 + copy = records[0] + assert copy.uid == record.uid + assert copy.generator_id == "focus_test" + assert copy.params == record.params + for name in ("params", "workpiece_uids", "step_uids", "asset_uids"): + assert getattr(copy.state, name) == getattr(record.state, name) + items = [ + loaded.find_descendant_by_uid(u) for u in copy.state.workpiece_uids + ] + assert CalibrationCmd.record_for_items(items) is copy + + +@pytest.mark.asyncio +async def test_record_for_items(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + interval = cmd.create("interval_test") + focus = cmd.create("focus_test") + interval_wps = cmd.workpieces(interval) + focus_wps = cmd.workpieces(focus) + + assert cmd.record_for_items(interval_wps) is interval + assert cmd.record_for_items(focus_wps[:1]) is focus + assert cmd.record_for_items(interval_wps + focus_wps) is None + assert cmd.record_for_items([]) is None + assert cmd.record_for_items([_layer(doc_editor, focus)]) is None + + +# ---------------------------------------------------------------------- +# Material Test Grid +# ---------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_material_test_grid_layer(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create( + "material_test_grid", + {"grid_dimensions": (3, 4), "shape_size": 5.0, "spacing": 1.0}, + ) + layer = _layer(doc_editor, record) + (step,) = _steps(layer) + (wp,) = layer.all_workpieces + + assert step.typelabel == "Material Test Grid" + assert step.workpiece_uids == [wp.uid] + assert step.grid_dimensions == (3, 4) + assert step.line_interval_mm is None + width = 3 * 5.0 + 2 * 1.0 + 7.5 + height = 4 * 5.0 + 3 * 1.0 + 7.5 + assert wp.size == pytest.approx((width, height)) + + +@pytest.mark.asyncio +async def test_material_test_grid_update_resizes(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + params = record.params + params["grid_dimensions"] = (2, 2) + params["include_labels"] = False + (wp,) = cmd.update_params(record, params) + (step,) = _steps(_layer(doc_editor, record)) + assert step.grid_dimensions == (2, 2) + assert step.include_labels is False + assert wp.size == pytest.approx((2 * 10.0 + 2.0, 2 * 10.0 + 2.0)) + + +def _old_style_grid(editor, grid_params): + """A grid built the way Tools → Create Material Test Grid did it + before it got its own layer: on the active layer, centered.""" + steps = importlib.import_module("laser_essentials.steps") + helpers = importlib.import_module("laser_essentials.material_test_helpers") + step = steps.MaterialTestStep.create(editor.context) + step.name = "Material Test Grid" + for key, value in grid_params.items(): + setattr(step, key, value) + draw = helpers.draw_material_test_preview + size = helpers.get_material_test_proportional_size + importer = ProceduralImporter( + drawing_function_path=f"{draw.__module__}.{draw.__name__}", + size_function_path=f"{size.__module__}.{size.__name__}", + params={ + k: getattr(step, k) + for k in importlib.import_module( + "laser_essentials.calibration.material_test_grid" + ).PARAM_KEYS + }, + name=step.name, + ) + result = importer.get_doc_items(ProceduralSpec()) + assert result is not None and result.payload is not None + payload = result.payload + editor.doc.add_asset(payload.source) + wp = payload.items[0] + step.workpiece_uids = [wp.uid] + width, height = wp.size + wp.pos = (200 - width / 2, 200 - height / 2) + layer = editor.doc.active_layer + layer.workflow.add_step(step) + layer.add_child(wp) + + +@pytest.mark.asyncio +async def test_material_test_grid_gcode_matches_the_old_grid( + doc_editor, machine +): + """The grid on its own layer runs exactly like a grid made the old + way on the active layer.""" + params = {"grid_dimensions": (3, 2), "test_type": "Engrave"} + cmd = CalibrationCmd(doc_editor) + cmd.create("material_test_grid", params) + generated = await _gcode(doc_editor) + doc_editor.history_manager.undo() + + _old_style_grid(doc_editor, params) + old = await _gcode(doc_editor) + assert generated.count("M4") > 6 + assert generated.splitlines() == old.splitlines() + + +@pytest.mark.asyncio +async def test_legacy_material_test_grid_gets_a_record(doc_editor, machine): + """A grid from an older version (step and workpiece on an ordinary + layer, no record) can be changed like a new one.""" + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + layer = _layer(doc_editor, record) + doc_editor.doc.remove_asset(record) + (wp,) = layer.all_workpieces + (step,) = _steps(layer) + step.grid_dimensions = (3, 3) + + legacy = cmd.record_for_items([wp]) + assert legacy is not None + assert legacy not in _records(doc_editor) + assert legacy.params["grid_dimensions"] == (3, 3) + assert legacy.params["line_interval_mm"] == 0.0 + + params = legacy.params + params["grid_dimensions"] = (4, 2) + (new_wp,) = cmd.update_params(legacy, params) + assert legacy in _records(doc_editor) + (new_step,) = _steps(layer) + assert new_step.grid_dimensions == (4, 2) + assert new_step.workpiece_uids == [new_wp.uid] + assert cmd.record_for_items([new_wp]) is legacy + + doc_editor.history_manager.undo() + assert legacy not in _records(doc_editor) + assert _steps(layer) == [step] + + # ---------------------------------------------------------------------- # Interval Test # ---------------------------------------------------------------------- -INTERVAL_PARAMS = IntervalTestParams( - min_interval=0.5, - max_interval=1.0, - count=2, - cell_size=4.0, - spacing=2.0, - power_percent=40.0, - speed=2000.0, - include_labels=False, -) +INTERVAL_PARAMS = { + "min_interval": 0.5, + "max_interval": 1.0, + "count": 2, + "cell_size": 4.0, + "spacing": 2.0, + "power_percent": 40.0, + "speed": 2000, + "include_labels": False, +} @pytest.mark.asyncio async def test_interval_test_layer(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - params = IntervalTestParams(count=3, include_labels=True) - layer = cmd.create_interval_test(params) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 3}) + layer = _layer(doc_editor, record) - assert layer in doc_editor.doc.layers steps = _steps(layer) # One label step, then one engrave step per cell. assert [s.typelabel for s in steps] == [ @@ -117,39 +530,23 @@ async def test_interval_test_layer(doc_editor, machine): "Engrave", "Engrave", ] - workpieces = {wp.uid: wp for wp in layer.all_workpieces} - assert len(workpieces) == 4 - for step in steps: - assert step.workpiece_uids - assert step.workpiece_uids[0] in workpieces + assert len(layer.all_workpieces) == 4 intervals = [s.line_interval_mm for s in steps[1:]] assert intervals == pytest.approx([0.05, 0.15, 0.25]) assert [s.power for s in steps[1:]] == pytest.approx([0.3] * 3) assert steps[0].power == pytest.approx(0.1) -@pytest.mark.asyncio -async def test_interval_test_is_centered_and_undoable(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - layer = cmd.create_interval_test(IntervalTestParams(count=4)) - rects = [ - wp.get_world_geometry().rect() # type: ignore[union-attr] - for wp in layer.all_workpieces - ] - center_x = (min(r[0] for r in rects) + max(r[2] for r in rects)) / 2 - center_y = (min(r[1] for r in rects) + max(r[3] for r in rects)) / 2 - assert (center_x, center_y) == pytest.approx((200.0, 200.0), abs=0.01) - - doc_editor.history_manager.undo() - assert layer not in doc_editor.doc.layers - - @pytest.mark.asyncio async def test_interval_test_scan_line_pitch(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - layer = cmd.create_interval_test(INTERVAL_PARAMS) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", INTERVAL_PARAMS) cells = sorted( - (wp for wp in layer.all_workpieces if isinstance(wp, WorkPiece)), + ( + wp + for wp in _layer(doc_editor, record).all_workpieces + if isinstance(wp, WorkPiece) + ), key=lambda wp: wp.pos[0], ) gcode = await _gcode(doc_editor) @@ -177,46 +574,54 @@ async def test_interval_test_scan_line_pitch(doc_editor, machine): # ---------------------------------------------------------------------- -def _focus(mode, **kwargs) -> FocusTestParams: +def _focus(mode, **kwargs) -> dict: values = { - "mode": mode, + "mode": mode.value, "start_offset": -0.5, "step": 0.5, "count": 3, "line_length": 5.0, "spacing": 3.0, "power_percent": 20.0, - "speed": 1200.0, + "speed": 1200, "include_labels": False, } values.update(kwargs) - return FocusTestParams(**values) + return values + + +def _modes(doc_editor): + module = importlib.import_module("laser_essentials.calibration.focus_test") + return module.available_modes(doc_editor.context) @pytest.mark.asyncio -async def test_focus_modes_without_z_axis(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - modes = cmd.available_focus_modes() - assert FocusMode.MANUAL in modes - assert FocusMode.RAMP in modes - if not machine.has_z_axis: - assert FocusMode.Z_AXIS not in modes - with pytest.raises(ValueError): - cmd.create_focus_test(_focus(FocusMode.Z_AXIS)) +async def test_focus_modes_without_z_axis(doc_editor, machine, mocker): + mocker.patch.object( + type(machine), "has_z_axis", new_callable=mocker.PropertyMock + ).return_value = False + cmd = CalibrationCmd(doc_editor) + modes = _modes(doc_editor) + assert modes == [FocusMode.MANUAL, FocusMode.RAMP] + record = cmd.create("focus_test") + assert record.params["mode"] == FocusMode.MANUAL.value + with pytest.raises(ValueError): + cmd.create("focus_test", _focus(FocusMode.Z_AXIS)) @pytest.mark.asyncio async def test_focus_modes_without_gcode_driver(doc_editor, machine, mocker): mocker.patch.object(machine, "driver_name", "RuidaRPAAdapter") - cmd = CalibrationTestCmd(doc_editor) - assert cmd.available_focus_modes() == [FocusMode.RAMP] + assert _modes(doc_editor) == [FocusMode.RAMP] + generator = CalibrationCmd.generator("focus_test") + assert generator.defaults(doc_editor.context)["mode"] == "ramp" @pytest.mark.asyncio async def test_focus_manual_pauses_between_lines(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - layer = cmd.create_focus_test(_focus(FocusMode.MANUAL)) - names = [s.name for s in _steps(layer)] + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test", _focus(FocusMode.MANUAL)) + names = [s.name for s in _steps(_layer(doc_editor, record))] assert len(names) == 6 assert names[0].startswith("Pause") assert names[1] == "Focus -0.5 mm" @@ -239,9 +644,12 @@ async def test_focus_z_axis_moves_and_returns(doc_editor, machine, mocker): mocker.patch.object( type(machine), "has_z_axis", new_callable=mocker.PropertyMock ).return_value = True - cmd = CalibrationTestCmd(doc_editor) - assert FocusMode.Z_AXIS in cmd.available_focus_modes() - cmd.create_focus_test(_focus(FocusMode.Z_AXIS)) + cmd = CalibrationCmd(doc_editor) + assert _modes(doc_editor)[0] == FocusMode.Z_AXIS + record = cmd.create("focus_test") + assert record.params["mode"] == FocusMode.Z_AXIS.value + doc_editor.history_manager.undo() + cmd.create("focus_test", _focus(FocusMode.Z_AXIS)) gcode = await _gcode(doc_editor) z_moves = [ @@ -256,11 +664,26 @@ async def test_focus_z_axis_moves_and_returns(doc_editor, machine, mocker): @pytest.mark.asyncio async def test_focus_ramp_has_no_commands(doc_editor, machine): - cmd = CalibrationTestCmd(doc_editor) - layer = cmd.create_focus_test( - _focus(FocusMode.RAMP, ramp_length=30.0, include_labels=True) + cmd = CalibrationCmd(doc_editor) + record = cmd.create( + "focus_test", + _focus(FocusMode.RAMP, ramp_length=30.0, include_labels=True), ) - assert [s.typelabel for s in _steps(layer)] == ["Contour", "Contour"] + steps = _steps(_layer(doc_editor, record)) + assert [s.typelabel for s in steps] == ["Contour", "Contour"] gcode = await _gcode(doc_editor) assert "M0" not in gcode assert "G91" not in gcode + + +@pytest.mark.asyncio +async def test_focus_switch_mode_rebuilds_commands(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test", _focus(FocusMode.MANUAL)) + layer = _layer(doc_editor, record) + assert len(_steps(layer)) == 6 + + params = record.params + params["mode"] = FocusMode.RAMP.value + cmd.update_params(record, params) + assert [s.name for s in _steps(layer)] == ["Focus ramp"] diff --git a/tests/ui_gtk/doceditor/test_calibration_test_settings.py b/tests/ui_gtk/doceditor/test_calibration_test_settings.py new file mode 100644 index 000000000..6abd88a64 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_calibration_test_settings.py @@ -0,0 +1,140 @@ +# flake8: noqa: E402 +""" +The calibration tests in the real main window: Tools → Create … adds +the test on its own layer, selects it and shows its Test Settings in +the item properties panel without opening a window; a change there +rebuilds the test and keeps it selected. +""" + +import os +import sys +import time + +import pytest + +if sys.platform.startswith("linux"): + if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip( + "No display on Linux, skipping UI tests. Run with xvfb-run.", + allow_module_level=True, + ) + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +gi.require_version("Gdk", "4.0") +from gi.repository import Adw, GLib + +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.mainwindow import MainWindow + +ACTIONS = ["material_test", "interval_test", "focus_test"] + + +def process_events_for_duration(duration_sec: float): + end_time = time.monotonic() + duration_sec + context = GLib.main_context_default() + while time.monotonic() < end_time: + while context.pending(): + context.iteration(False) + time.sleep(0.01) + + +@pytest.fixture +def app_and_window(ui_context_initializer, request): + from rayforge.ui_gtk import sim3d + + sim3d.initialize() + + win = None + + class TestApp(Adw.Application): + def do_activate(self): + nonlocal win + win = MainWindow(application=self) + win.set_default_size(1280, 800) + self.win = win + + test_name = request.node.name.replace("_", "-").replace("[", "-") + app_id = f"org.rayforge.rayforge.test.{test_name.rstrip(']')}" + app = TestApp(application_id=app_id) + app.register(None) + app.activate() + process_events_for_duration(0.5) + + win = app.win + win.present() + process_events_for_duration(0.5) + + yield app, win + + if win: + win.doc_editor.cleanup() + win.close() + app.quit() + process_events_for_duration(0.2) + + +def _provider(win): + for provider, _widgets, expander in win.item_props_widget._separate_groups: + if type(provider).__name__ == "CalibrationPropertyProvider": + return provider, expander + raise AssertionError("Test Settings provider not registered") + + +def _selected(win) -> list[WorkPiece]: + return [ + item + for item in win.surface.get_selected_items() + if isinstance(item, WorkPiece) + ] + + +def _windows(app, win): + return [w for w in app.get_windows() if w is not win and w.get_visible()] + + +@pytest.mark.ui +@pytest.mark.parametrize("action", ACTIONS) +def test_create_shows_settings_in_the_panel(app_and_window, action): + app, win = app_and_window + layers_before = len(win.doc_editor.doc.layers) + + win.lookup_action(action).activate(None) + process_events_for_duration(1.5) + + doc = win.doc_editor.doc + assert len(doc.layers) == layers_before + 1 + layer = doc.active_layer + assert set(_selected(win)) == set(layer.all_workpieces) + assert win.item_revealer.get_reveal_child() + provider, expander = _provider(win) + assert expander.get_visible() + assert provider.varset_widget.keys() + assert _windows(app, win) == [] + + +@pytest.mark.ui +def test_change_rebuilds_and_keeps_the_selection(app_and_window): + _app, win = app_and_window + win.lookup_action("interval_test").activate(None) + process_events_for_duration(1.5) + provider, expander = _provider(win) + old = set(_selected(win)) + + row = provider.varset_widget.row_for("count") + row.get_spin_button().set_value(3) + process_events_for_duration(1.5) + + layer = win.doc_editor.doc.active_layer + new = set(_selected(win)) + assert len(layer.all_workpieces) == 4 + assert new == set(layer.all_workpieces) + assert new.isdisjoint(old) + assert expander.get_visible() + assert win.item_revealer.get_reveal_child() + + win.doc_editor.history_manager.undo() + process_events_for_duration(0.5) + assert len(layer.all_workpieces) == 6 From 9dee9ee91b7b3679c29d96c55cb1b3b1fba39df2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:21:20 +0200 Subject: [PATCH 15/28] feat(screenshot): capture the calibration tests with their Test Settings The material-test target now creates the grid through Tools -> Create Material Test Grid and captures the main window with the Test Settings in the item properties panel, instead of the step settings dialog. New interval-test and focus-test targets do the same for the other two tests, and the Workpieces tab target creates the grid through the new command. Refs #519 --- scripts/screenshot/calibration_test.py | 45 +++++++++++++++++++ scripts/screenshot/cli.py | 4 +- scripts/screenshot/material_test.py | 29 ------------ .../screenshot/step_settings_workpieces.py | 2 +- scripts/screenshot/utils.py | 29 +++--------- 5 files changed, 56 insertions(+), 53 deletions(-) create mode 100644 scripts/screenshot/calibration_test.py delete mode 100644 scripts/screenshot/material_test.py diff --git a/scripts/screenshot/calibration_test.py b/scripts/screenshot/calibration_test.py new file mode 100644 index 000000000..9a577f8ab --- /dev/null +++ b/scripts/screenshot/calibration_test.py @@ -0,0 +1,45 @@ +""" +Screenshot: a calibration test right after Tools → Create …, with its +Test Settings in the item properties panel. + +Usage: pixi run screenshot material-test + pixi run screenshot interval-test + pixi run screenshot focus-test +""" + +import logging +import time + +from utils import ( + clear_window_subtitle, + create_calibration_test, + get_target, + restore_config, + set_window_size, + take_screenshot, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + +ACTIONS = { + "material-test": "material_test", + "interval-test": "interval_test", + "focus-test": "focus_test", +} + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + target = get_target("material-test") + create_calibration_test(win, ACTIONS[target]) + time.sleep(1.5) + clear_window_subtitle(win) + take_screenshot() + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 54259aa98..b9a152937 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -126,7 +126,9 @@ "addons:sketcher:tool:text-box:font-properties": "sketcher_tools", "addons:sketcher:array:circular": "sketcher_array", "addons:sketcher:array:curve-along": "sketcher_array", - "material-test": "material_test", + "material-test": "calibration_test", + "interval-test": "calibration_test", + "focus-test": "calibration_test", "operations:offset-fill": "offset_fill", "operations:perforation": "perforation", "operations:wavefront": "wavefront", diff --git a/scripts/screenshot/material_test.py b/scripts/screenshot/material_test.py deleted file mode 100644 index 1f5bb4936..000000000 --- a/scripts/screenshot/material_test.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Screenshot: Material test grid dialog.""" - -import logging -import time - -from utils import ( - open_material_test, - restore_config, - set_window_size, - take_screenshot, -) - -from rayforge.uiscript import app, win - -logger = logging.getLogger(__name__) - - -@restore_config -def main(): - set_window_size(win, 2400, 1650) - - open_material_test(win) - time.sleep(0.25) - take_screenshot() - time.sleep(0.25) - app.quit_idle() - - -main() diff --git a/scripts/screenshot/step_settings_workpieces.py b/scripts/screenshot/step_settings_workpieces.py index 322e4868b..6ad642527 100644 --- a/scripts/screenshot/step_settings_workpieces.py +++ b/scripts/screenshot/step_settings_workpieces.py @@ -24,7 +24,7 @@ def _create_material_test() -> None: """Create the grid through the editor's own command so the step is linked to its generated workpiece like in the real flow.""" - win.doc_editor.material_test.create_test_grid() + win.doc_editor.calibration.create("material_test_grid") @restore_config diff --git a/scripts/screenshot/utils.py b/scripts/screenshot/utils.py index bbf677ae0..eb2d9bb6e 100644 --- a/scripts/screenshot/utils.py +++ b/scripts/screenshot/utils.py @@ -35,7 +35,6 @@ from rayforge.config import CONFIG_DIR from rayforge.context import get_context from rayforge.core.recipe import Recipe -from rayforge.core.step_registry import step_registry from rayforge.doceditor.array import ArrayMode from rayforge.ui_gtk.array_dialog import ( CircularArrayDialog, @@ -1433,28 +1432,14 @@ def _open() -> "AddEditRecipeDialog": return dialog -def open_material_test(win: "MainWindow") -> "StepSettingsDialog": - """Open material test grid dialog.""" +def create_calibration_test(win: "MainWindow", action_name: str) -> None: + """Create a calibration test through its Tools menu action. - def _open() -> "StepSettingsDialog": - context = win.doc_editor.context - step_cls = step_registry.get("MaterialTestStep") - assert step_cls is not None - step = step_cls.create(context) - step.name = "Material Test Grid" - dialog = StepSettingsDialog( - editor=win.doc_editor, - step=step, - transient_for=win, - ) - dialog.set_initial_page("step-settings") - dialog.set_default_size(600, 900) - dialog.present() - return dialog - - dialog = run_on_main_thread(_open) - logger.info("Opened material test grid dialog") - return dialog + The test gets its own layer and is selected, so its Test Settings + show in the item properties panel. + """ + run_on_main_thread(lambda: win.lookup_action(action_name).activate(None)) + logger.info(f"Created calibration test via {action_name}") def open_array_dialog( From 8a1fd062deb384687de91b6673bb58271c6cbc6d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:21:20 +0200 Subject: [PATCH 16/28] chore: leave the changelog to the release update The Interval and Focus Test entry sat under 1.13.0-beta1, which is already released. The changelog is written for each release. Refs #519 --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252f957e1..754f744ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,15 +155,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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) -- Tools: Interval Test and Focus Test generators next to the Material - Test Grid. The Interval Test engraves a row of squares, each with its - own line interval (labelled in mm and LPI); the Focus Test engraves - one line per focus offset, either with relative Z moves (machines - with a Z axis), with an M0 pause before each line to move the head - by hand, or as one long line over a tilted strip. Both are created as - an editable layer with one operation per cell or line. The Focus - Test opens on Z axis steps when the machine has a Z axis (#519, - #550) - 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 From a1206c5c7154ae3c9e323d281a534a432b3ccd73 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:21:20 +0200 Subject: [PATCH 17/28] docs: describe the shared Test Settings of the calibration tests All three calibration tests are created on their own layer and set up under Test Settings in the item properties panel, with every change rebuilt as one undo step. Update the Material Test Grid and the Interval and Focus Tests pages, and their translations, to match. Refs #519 --- .../operations/interval-and-focus-tests.md | 11 ++-- .../features/operations/material-test-grid.md | 26 ++++---- .../operations/interval-and-focus-tests.md | 13 ++-- .../features/operations/material-test-grid.md | 30 ++++++---- .../operations/interval-and-focus-tests.md | 14 +++-- .../features/operations/material-test-grid.md | 34 +++++++---- .../operations/interval-and-focus-tests.md | 15 +++-- .../features/operations/material-test-grid.md | 56 +++++++++-------- .../operations/interval-and-focus-tests.md | 14 +++-- .../features/operations/material-test-grid.md | 24 ++++++-- .../operations/interval-and-focus-tests.md | 14 +++-- .../features/operations/material-test-grid.md | 60 ++++++++++--------- .../operations/interval-and-focus-tests.md | 14 +++-- .../features/operations/material-test-grid.md | 36 ++++++----- .../operations/interval-and-focus-tests.md | 4 +- .../features/operations/material-test-grid.md | 18 +++--- 16 files changed, 231 insertions(+), 152 deletions(-) diff --git a/website/docs/features/operations/interval-and-focus-tests.md b/website/docs/features/operations/interval-and-focus-tests.md index 249a8a725..dae852bf4 100644 --- a/website/docs/features/operations/interval-and-focus-tests.md +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -7,9 +7,12 @@ description: # Interval and Focus Tests Next to the [Material Test Grid](material-test-grid.md), the Tools menu offers two more calibration -jobs. Both are created as an ordinary layer with workpieces and operations: you can move it, frame -it and preview it like any other content, and change the settings of each cell or line afterwards in -its operation. +jobs. All three work the same way: **Tools → Create …** adds the test with default settings on a new +layer and selects it, and its settings appear under **Test Settings** in the item properties panel +on the right. Every change there rebuilds the test right away, as one step you can undo, so you see +the result while you adjust it. The test is ordinary content: you can move it, frame it and preview +it like anything else. Each cell or line has its own operation, which you can also fine-tune by +hand, but a later change in the Test Settings rebuilds those operations. ## Interval Test @@ -35,7 +38,7 @@ means more distance between the head and the material. The thinnest line marks t Z axis steps are only offered on machines with a Z axis, and both Z axis steps and the manual method need a G-code controller, because they use [Command](command.md) operations between the lines. The -offsets are limited to ±10 mm from the starting height. The dialog starts on the first method the +offsets are limited to ±10 mm from the starting height. A new Focus Test uses the first method the machine supports, in the order of the table above. For the manual method, focus the laser as usual first. The labels are engraved at that height. At diff --git a/website/docs/features/operations/material-test-grid.md b/website/docs/features/operations/material-test-grid.md index 869bc8c25..327d0aa93 100644 --- a/website/docs/features/operations/material-test-grid.md +++ b/website/docs/features/operations/material-test-grid.md @@ -12,14 +12,18 @@ combination: it generates a pattern of test cells in which each cell is engraved slightly different setting, all in a single job. After one run you can see at a glance which combination produces the result you want. -Create one via **Tools → Create Material Test Grid**. Rayforge adds a special workpiece to the -canvas along with a matching operation, and you configure the grid in its settings dialog. +Create one via **Tools → Create Material Test Grid**. Rayforge adds the grid on a new layer named +after the test, with one workpiece and a matching operation, and selects it. The grid's settings +appear under **Test Settings** in the item properties panel on the right, so the canvas and the +grid stay in view while you change them. Every change rebuilds the grid right away, as one step you +can undo. Select the grid again later to bring its settings back. The +[Interval and Focus Tests](interval-and-focus-tests.md) work the same way. ![Material Test Grid Settings](/screenshots/material-test.webp) ## Presets -The settings dialog offers presets for common laser types. They fill in a sensible speed range, +The Test Settings offer presets for common laser types. They fill in a sensible speed range, power range, and test type so you can start with a reasonable baseline: | Preset | Speed Range | Power Range | Test Type | @@ -47,9 +51,10 @@ power is scaled along with the speed, so all cells stay visually comparable. ## Configuring the Grid -The settings dialog groups the parameters into three sections. +The Test Settings list the grid settings first, then the labels, then the parameter ranges. The +laser head and the post-processing stay in the settings of the operation. -The **Grid** section controls the test itself. The test type determines whether each cell cuts the +The **grid** settings control the test itself. The test type determines whether each cell cuts the outline of a square or fills it with raster lines. The grid dimensions set how many columns and rows to test — each column represents one step of the mode's first parameter and each row one step of the second, from the minimum to the maximum of the range you enter. Between 2 and 20 steps are allowed @@ -58,12 +63,12 @@ determine how large the grid becomes. For the Engrave test type, the line interv distance between scan lines — smaller values fill more densely but take longer. Leave it at zero to use your laser's spot size, which is a good match for most engraving. -The **Labels** section controls the annotations engraved next to the grid. Labels are on by default +The **label** settings control the annotations engraved next to the grid. Labels are on by default and are engraved first, so the test pattern cannot obscure them. They get their own power (10% by default) and speed (1000 mm/min by default), and speed values are shown in your preferred display unit. -The **Parameters** section holds the ranges the grid varies — speed, power, passes, or offset, +The **parameter ranges** are what the grid varies — speed, power, passes, or offset, depending on the selected mode. Modes that keep a parameter fixed (for example the speed in Power vs Passes) let you set that constant here as well. @@ -72,8 +77,8 @@ Passes) let you set that constant here as well. Every operation normally processes all workpieces on its layer, and the **Workpieces** tab of its settings dialog lists them all as included. The Material Test Grid is the exception: its operation is linked to the workpiece it generated and processes that workpiece only. On the Workpieces tab the -generated grid appears as the only included workpiece, while everything else on the layer is marked -as excluded. +generated grid appears as the only included workpiece, and anything you add to the test's layer +later is marked as excluded. ![Workpieces tab of the Material Test Grid settings](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -130,7 +135,8 @@ those settings the next time you cut the same material. Material test grids are ordinary workpieces, so they combine freely with other operations. A common pattern is to add a contour operation around the finished grid and cut the test piece free from the -stock after engraving completes. +stock after engraving completes. Operations you add to the test's layer stay when the grid is +rebuilt. Running the same grid configuration on different materials is a quick way to build up a library of known-good settings — and recipes make that library searchable by material and thickness later. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 977bd0897..8d053ffaa 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,14 @@ description: # Intervall- und Fokustests Neben dem [Materialtest-Raster](material-test-grid.md) bietet das Menü Werkzeuge zwei weitere -Kalibrierungsjobs. Beide werden als normale Ebene mit Werkstücken und Operationen angelegt: Du -kannst sie verschieben, umranden und in der Vorschau ansehen wie jeden anderen Inhalt und die -Einstellungen jedes Feldes oder jeder Linie danach in ihrer Operation ändern. +Kalibrierungsjobs. Alle drei funktionieren gleich: **Werkzeuge → … erstellen** fügt den Test mit +Standardeinstellungen auf einer neuen Ebene hinzu und wählt ihn aus, und seine Einstellungen +erscheinen unter **Test Settings** im Eigenschaften-Panel rechts. Jede Änderung dort baut den Test +sofort neu auf, als ein Schritt, den du rückgängig machen kannst, sodass du das Ergebnis siehst, +während du ihn anpasst. Der Test ist normaler Inhalt: Du kannst ihn verschieben, umranden und in der +Vorschau ansehen wie alles andere. Jedes Feld und jede Linie hat eine eigene Operation, die du auch +von Hand feinjustieren kannst, aber eine spätere Änderung in den Test Settings baut diese +Operationen neu auf. ## Intervalltest @@ -38,7 +43,7 @@ besten Fokus. Z-Achsen-Schritte werden nur bei Maschinen mit Z-Achse angeboten, und sowohl Z-Achsen-Schritte als auch die manuelle Methode benötigen einen G-Code-Controller, weil sie zwischen den Linien [Befehls](command.md)-Operationen verwenden. Die Versätze sind auf ±10 mm von der Ausgangshöhe -begrenzt. Der Dialog beginnt mit der ersten Methode, die die Maschine unterstützt, in der +begrenzt. Ein neuer Fokustest verwendet die erste Methode, die die Maschine unterstützt, in der Reihenfolge der Tabelle oben. Fokussiere den Laser bei der manuellen Methode zuerst wie gewohnt. Die Beschriftungen werden in diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index 658658e8e..3437175f2 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -12,15 +12,19 @@ Kombination das Rätselraten: Es erzeugt ein Muster aus Testzellen, in denen jed leicht anderen Einstellung graviert oder geschnitten wird — alles in einem einzigen Job. Nach einem Durchlauf siehst du auf einen Blick, welche Kombination das gewünschte Ergebnis liefert. -Erstelle eines über **Werkzeuge → Materialtest-Raster erstellen**. Rayforge fügt der Arbeitsfläche -ein spezielles Werkstück zusammen mit einer passenden Operation hinzu, und du konfigurierst das -Raster in dessen Einstellungsdialog. +Erstelle eines über **Werkzeuge → Materialtest-Raster erstellen**. Rayforge fügt das Raster auf +einer neuen Ebene hinzu, die nach dem Test benannt ist, mit einem Werkstück und einer passenden +Operation, und wählt es aus. Die Einstellungen des Rasters erscheinen unter **Test Settings** im +Eigenschaften-Panel rechts, sodass Arbeitsfläche und Raster sichtbar bleiben, während du sie +änderst. Jede Änderung baut das Raster sofort neu auf, als ein Schritt, den du rückgängig machen +kannst. Wähle das Raster später erneut aus, um seine Einstellungen wieder anzuzeigen. Die +[Intervall- und Fokustests](interval-and-focus-tests.md) funktionieren genauso. ![Materialtest-Raster-Einstellungen](/screenshots/material-test.webp) ## Presets -Der Einstellungsdialog bietet Presets für gängige Lasertypen. Sie füllen einen sinnvollen +Die Test Settings bieten Presets für gängige Lasertypen. Sie füllen einen sinnvollen Geschwindigkeitsbereich, Leistungsbereich und Testtyp aus, damit du mit einer vernünftigen Basis starten kannst: @@ -52,10 +56,11 @@ damit alle Zellen visuell vergleichbar bleiben. ## Raster konfigurieren -Der Einstellungsdialog gruppiert die Parameter in drei Bereiche. +Die Test Settings zeigen zuerst die Raster-Einstellungen, dann die Beschriftungen, dann die +Parameterbereiche. Laserkopf und Nachbearbeitung bleiben in den Einstellungen der Operation. -Der Bereich **Raster** steuert den Test selbst. Der Testtyp bestimmt, ob jede Zelle den Umriss eines -Quadrats schneidet oder es mit Rasterlinien füllt. Die Rasterabmessungen legen fest, wie viele +Die **Raster**-Einstellungen steuern den Test selbst. Der Testtyp bestimmt, ob jede Zelle den Umriss +eines Quadrats schneidet oder es mit Rasterlinien füllt. Die Rasterabmessungen legen fest, wie viele Spalten und Zeilen getestet werden — jede Spalte steht für einen Schritt des ersten Parameters des Modus und jede Zeile für einen Schritt des zweiten, vom Minimum bis zum Maximum des eingegebenen Bereichs. Erlaubt sind 2 bis 20 Schritte pro Achse; 5×5 ist ein guter Standard. Formgröße (Standard @@ -64,12 +69,12 @@ der Zeilenabstand den Abstand zwischen den Scanlinien — kleinere Werte füllen länger. Lass ihn auf null stehen, um die Spotgröße deines Lasers zu verwenden, was für die meisten Gravurarbeiten gut passt. -Der Bereich **Beschriftungen** steuert die Anmerkungen, die neben dem Raster graviert werden. +Die **Beschriftungs**-Einstellungen steuern die Anmerkungen, die neben dem Raster graviert werden. Beschriftungen sind standardmäßig aktiviert und werden zuerst graviert, damit das Testmuster sie nicht verdecken kann. Sie haben ihre eigene Leistung (Standard 10%) und Geschwindigkeit (Standard 1000 mm/min), und Geschwindigkeitswerte werden in deiner bevorzugten Anzeigeeinheit angezeigt. -Der Bereich **Parameter** enthält die Bereiche, die das Raster variiert — Geschwindigkeit, Leistung, +Die **Parameterbereiche** sind die Werte, die das Raster variiert — Geschwindigkeit, Leistung, Durchgänge oder Versatz, je nach gewähltem Modus. Modi, die einen Parameter festhalten (zum Beispiel die Geschwindigkeit bei Leistung vs. Durchgänge), lassen diese Konstante hier ebenfalls einstellen. @@ -78,8 +83,8 @@ die Geschwindigkeit bei Leistung vs. Durchgänge), lassen diese Konstante hier e Normalerweise verarbeitet eine Operation alle Werkstücke ihrer Ebene, und der **Werkstücke**-Reiter ihres Einstellungs-Dialogs listet alle als einbezogen auf. Das Materialtest-Raster ist die Ausnahme: Seine Operation ist mit dem Werkstück verknüpft, das sie erzeugt hat, und verarbeitet ausschließlich -dieses. Im Werkstücke-Reiter erscheint das erzeugte Raster als einziges einbezogenes Werkstück, -alles Übrige auf der Ebene als ausgeschlossen. +dieses. Im Werkstücke-Reiter erscheint das erzeugte Raster als einziges einbezogenes Werkstück, und +alles, was du später auf der Ebene des Tests hinzufügst, als ausgeschlossen. ![Werkstücke-Reiter der Materialtest-Raster-Einstellungen](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -142,7 +147,8 @@ schneidest. Materialtest-Raster sind gewöhnliche Werkstücke, daher lassen sie sich frei mit anderen Operationen kombinieren. Ein häufiges Muster ist, eine Kontur-Operation um das fertige Raster zu legen und das -Teststück nach Abschluss der Gravur aus dem Rohmaterial zu schneiden. +Teststück nach Abschluss der Gravur aus dem Rohmaterial zu schneiden. Operationen, die du der Ebene +des Tests hinzufügst, bleiben erhalten, wenn das Raster neu aufgebaut wird. Dieselbe Rasterkonfiguration auf verschiedenen Materialien auszuführen, ist ein schneller Weg, um eine Bibliothek erprobter Einstellungen aufzubauen — und Rezepte machen diese Bibliothek später nach diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 32243e7a4..8a7f85862 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,13 @@ description: # Pruebas de intervalo y de enfoque Junto a la [cuadrícula de prueba de material](material-test-grid.md), el menú Herramientas ofrece -dos trabajos de calibración más. Ambos se crean como una capa normal con piezas y operaciones: -puedes moverla, encuadrarla y previsualizarla como cualquier otro contenido, y cambiar después los -ajustes de cada celda o línea en su operación. +dos trabajos de calibración más. Los tres funcionan igual: **Herramientas → Crear …** añade la +prueba con los ajustes predeterminados en una capa nueva y la selecciona, y sus ajustes aparecen en +**Test Settings** en el panel de propiedades a la derecha. Cada cambio allí reconstruye la prueba al +instante, como un solo paso que puedes deshacer, así que ves el resultado mientras la ajustas. La +prueba es contenido normal: puedes moverla, encuadrarla y previsualizarla como cualquier otra cosa. +Cada celda o línea tiene su propia operación, que también puedes afinar a mano, pero un cambio +posterior en Test Settings reconstruye esas operaciones. ## Prueba de intervalo @@ -37,8 +41,8 @@ marca el mejor enfoque. Los pasos del eje Z solo se ofrecen en máquinas con eje Z, y tanto los pasos del eje Z como el método manual necesitan un controlador G-code, porque usan operaciones de [Comando](command.md) -entre las líneas. Los desplazamientos están limitados a ±10 mm de la altura inicial. El diálogo -empieza con el primer método que admite la máquina, en el orden de la tabla anterior. +entre las líneas. Los desplazamientos están limitados a ±10 mm de la altura inicial. Una nueva +prueba de enfoque usa el primer método que admite la máquina, en el orden de la tabla anterior. Con el método manual, enfoca primero el láser como de costumbre. Las etiquetas se graban a esa altura. En la primera pausa, coloca el cabezal en el primer desplazamiento; en cada pausa siguiente, diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index ca45ee637..87d55154b 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -12,16 +12,21 @@ buscar la combinación adecuada: genera un patrón de celdas de prueba en el que corta con un ajuste ligeramente diferente, todo en un solo trabajo. Después de una ejecución podrás ver de un vistazo qué combinación produce el resultado que deseas. -Crea una mediante **Herramientas → Crear Cuadrícula de Prueba de Material**. Rayforge añade una -pieza de trabajo especial al lienzo junto con una operación correspondiente, y configuras la -cuadrícula en su diálogo de ajustes. +Crea una mediante **Herramientas → Crear Cuadrícula de Prueba de Material**. Rayforge añade la +cuadrícula en una capa nueva con el nombre de la prueba, con una pieza de trabajo y una operación +correspondiente, y la selecciona. Los ajustes de la cuadrícula aparecen en **Test Settings** en el +panel de propiedades a la derecha, de modo que el lienzo y la cuadrícula siguen a la vista mientras +los cambias. Cada cambio reconstruye la cuadrícula al instante, como un solo paso que puedes +deshacer. Vuelve a seleccionar la cuadrícula más tarde para recuperar sus ajustes. Las [pruebas de +intervalo y de enfoque](interval-and-focus-tests.md) funcionan igual. ![Ajustes de Cuadrícula de Prueba de Material](/screenshots/material-test.webp) ## Preajustes -El diálogo de ajustes ofrece preajustes para tipos comunes de láser. Rellenan un rango de velocidad -sensato, un rango de potencia y el tipo de prueba para que puedas empezar con una base razonable: +La sección Test Settings ofrece preajustes para tipos comunes de láser. Rellenan un rango de +velocidad sensato, un rango de potencia y el tipo de prueba para que puedas empezar con una base +razonable: | Preajuste | Rango de Velocidad | Rango de Potencia | Tipo de Prueba | | ----------------- | ------------------ | ----------------- | -------------- | @@ -49,10 +54,12 @@ modo que todas las celdas permanezcan visualmente comparables. ## Configurar la Cuadrícula -El diálogo de ajustes agrupa los parámetros en tres secciones. +La sección Test Settings muestra primero los ajustes de la cuadrícula, luego las etiquetas y +después los rangos de parámetros. El cabezal láser y el posprocesado se quedan en los ajustes de la +operación. -La sección **Cuadrícula** controla la prueba en sí. El tipo de prueba determina si cada celda corta -el contorno de un cuadrado o lo rellena con líneas raster. Las dimensiones de la cuadrícula +Los ajustes de **cuadrícula** controlan la prueba en sí. El tipo de prueba determina si cada celda +corta el contorno de un cuadrado o lo rellena con líneas raster. Las dimensiones de la cuadrícula establecen cuántas columnas y filas probar — cada columna representa un paso del primer parámetro del modo y cada fila un paso del segundo, desde el mínimo hasta el máximo del rango que introduzcas. Se permiten entre 2 y 20 pasos por eje; 5×5 es un buen valor predeterminado. El tamaño de forma (10 @@ -61,13 +68,13 @@ Para el tipo de prueba Grabar, el intervalo de línea controla la distancia entr escaneo — los valores más pequeños rellenan de forma más densa pero tardan más. Déjalo en cero para usar el tamaño del punto de tu láser, que es una buena opción para la mayoría de los grabados. -La sección **Etiquetas** controla las anotaciones grabadas junto a la cuadrícula. Las etiquetas +Los ajustes de **etiquetas** controlan las anotaciones grabadas junto a la cuadrícula. Las etiquetas están activadas por defecto y se graban primero, de modo que el patrón de prueba no pueda ocultarlas. Tienen su propia potencia (10% por defecto) y velocidad (1000 mm/min por defecto), y los valores de velocidad se muestran en tu unidad de visualización preferida. -La sección **Parámetros** contiene los rangos que varía la cuadrícula — velocidad, potencia, pasadas -o desfase, según el modo seleccionado. Los modos que mantienen un parámetro fijo (por ejemplo la +Los **rangos de parámetros** son lo que varía la cuadrícula — velocidad, potencia, pasadas o +desfase, según el modo seleccionado. Los modos que mantienen un parámetro fijo (por ejemplo la velocidad en Potencia vs Pasadas) también te permiten establecer esa constante aquí. ## La Pieza de Trabajo Vinculada @@ -76,7 +83,7 @@ Normalmente una operación procesa todas las piezas de trabajo de su capa, y la trabajo** de su diálogo de ajustes las lista todas como incluidas. La Cuadrícula de Prueba de Material es la excepción: su operación está vinculada a la pieza de trabajo que generó y procesa solamente esa. En la pestaña Piezas de trabajo, la cuadrícula generada aparece como la única pieza -incluida, mientras que el resto de la capa aparece marcado como excluido. +incluida, y todo lo que añadas después a la capa de la prueba aparece marcado como excluido. ![Pestaña Piezas de trabajo de la Cuadrícula de Prueba de Material](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -136,7 +143,8 @@ sugerirá exactamente esos ajustes la próxima vez que cortes el mismo material. Las cuadrículas de prueba de material son piezas de trabajo normales, por lo que se combinan libremente con otras operaciones. Un patrón común es añadir una operación de contorno alrededor de -la cuadrícula terminada y cortar la pieza de prueba del material base cuando el grabado termina. +la cuadrícula terminada y cortar la pieza de prueba del material base cuando el grabado termina. Las +operaciones que añadas a la capa de la prueba se conservan cuando se reconstruye la cuadrícula. Ejecutar la misma configuración de cuadrícula en diferentes materiales es una forma rápida de construir una biblioteca de ajustes confiables — y las recetas hacen que esa biblioteca se pueda diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 75eefb6a5..13e494692 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,14 @@ description: # Tests d'intervalle et de mise au point À côté de la [grille de test de matériau](material-test-grid.md), le menu Outils propose deux autres -tâches de calibrage. Les deux sont créées comme un calque ordinaire avec des pièces et des -opérations : vous pouvez le déplacer, le cadrer et le prévisualiser comme tout autre contenu, puis -modifier les réglages de chaque cellule ou ligne dans son opération. +tâches de calibrage. Les trois fonctionnent de la même façon : **Outils → Créer …** ajoute le test +avec les réglages par défaut sur un nouveau calque et le sélectionne, et ses réglages apparaissent +sous **Test Settings** dans le panneau de propriétés à droite. Chaque modification y reconstruit +immédiatement le test, en une seule étape que vous pouvez annuler, si bien que vous voyez le +résultat pendant que vous l'ajustez. Le test est un contenu ordinaire : vous pouvez le déplacer, le +cadrer et le prévisualiser comme n'importe quel autre contenu. Chaque cellule ou ligne a sa propre +opération, que vous pouvez aussi affiner à la main, mais une modification ultérieure dans les Test +Settings reconstruit ces opérations. ## Test d'intervalle @@ -38,8 +43,8 @@ fine indique la meilleure mise au point. Les pas de l'axe Z ne sont proposés que sur les machines dotées d'un axe Z, et les pas de l'axe Z comme la méthode manuelle nécessitent un contrôleur G-code, car ils utilisent des opérations [Commande](command.md) entre les lignes. Les décalages sont limités à ±10 mm de la hauteur de -départ. La boîte de dialogue démarre sur la première méthode prise en charge par la machine, dans -l'ordre du tableau ci-dessus. +départ. Un nouveau test de mise au point utilise la première méthode prise en charge par la machine, +dans l'ordre du tableau ci-dessus. Avec la méthode manuelle, faites d'abord la mise au point du laser comme d'habitude. Les étiquettes sont gravées à cette hauteur. À la première pause, placez la tête au premier décalage ; à chaque diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index e70c372e4..036abc0c1 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -13,17 +13,21 @@ dans lesquelles chaque cellule est gravée ou découpée avec un réglage légè en un seul travail. Après une seule exécution, tu vois d'un coup d'œil quelle combinaison produit le résultat souhaité. -Crée-en une via **Outils → Créer une Grille de Test de Matériau**. Rayforge ajoute une pièce -spéciale sur le canevas ainsi qu'une opération correspondante, et tu configures la grille dans sa -boîte de dialogue de paramètres. +Crée-en une via **Outils → Créer une Grille de Test de Matériau**. Rayforge ajoute la grille sur un +nouveau calque portant le nom du test, avec une pièce et une opération correspondante, et la +sélectionne. Les paramètres de la grille apparaissent sous **Test Settings** dans le panneau de +propriétés à droite, si bien que le canevas et la grille restent visibles pendant que tu les +modifies. Chaque modification reconstruit immédiatement la grille, en une seule étape que tu peux +annuler. Sélectionne à nouveau la grille plus tard pour retrouver ses paramètres. Les [tests +d'intervalle et de mise au point](interval-and-focus-tests.md) fonctionnent de la même façon. ![Paramètres de la Grille de Test de Matériau](/screenshots/material-test.webp) ## Préréglages -La boîte de dialogue des paramètres propose des préréglages pour les types de laser courants. Ils -remplissent une plage de vitesse, une plage de puissance et un type de test raisonnables afin que tu -démarres avec une base solide : +Les Test Settings proposent des préréglages pour les types de laser courants. Ils remplissent une +plage de vitesse, une plage de puissance et un type de test raisonnables afin que tu démarres avec +une base solide : | Préréglage | Plage de Vitesse | Plage de Puissance | Type de Test | | ----------------- | ----------------- | ------------------ | ------------ | @@ -52,34 +56,35 @@ comparables. ## Configuration de la Grille -La boîte de dialogue des paramètres regroupe les paramètres en trois sections. +Les Test Settings affichent d'abord les paramètres de la grille, puis les étiquettes, puis les plages +de paramètres. La tête laser et le post-traitement restent dans les paramètres de l'opération. -La section **Grille** contrôle le test lui-même. Le type de test détermine si chaque cellule découpe -le contour d'un carré ou le remplit de lignes raster. Les dimensions de la grille définissent -combien de colonnes et de lignes tester — chaque colonne représente un pas du premier paramètre du -mode et chaque ligne un pas du second, du minimum au maximum de la plage saisie. Entre 2 et 20 pas -sont autorisés par axe ; 5×5 est une bonne valeur par défaut. La taille de la forme (10 mm par -défaut) et l'espacement (2 mm par défaut) déterminent la taille de la grille. Pour le type de test -Gravure, l'intervalle de ligne contrôle la distance entre les lignes de balayage — des valeurs plus -petites remplissent plus densément mais prennent plus de temps. Laisse-le à zéro pour utiliser la -taille du spot de ton laser, ce qui convient bien à la plupart des gravures. +Les paramètres de **grille** contrôlent le test lui-même. Le type de test détermine si chaque +cellule découpe le contour d'un carré ou le remplit de lignes raster. Les dimensions de la grille +définissent combien de colonnes et de lignes tester — chaque colonne représente un pas du premier +paramètre du mode et chaque ligne un pas du second, du minimum au maximum de la plage saisie. Entre +2 et 20 pas sont autorisés par axe ; 5×5 est une bonne valeur par défaut. La taille de la forme (10 +mm par défaut) et l'espacement (2 mm par défaut) déterminent la taille de la grille. Pour le type de +test Gravure, l'intervalle de ligne contrôle la distance entre les lignes de balayage — des valeurs +plus petites remplissent plus densément mais prennent plus de temps. Laisse-le à zéro pour utiliser +la taille du spot de ton laser, ce qui convient bien à la plupart des gravures. -La section **Étiquettes** contrôle les annotations gravées à côté de la grille. Les étiquettes sont -activées par défaut et sont gravées en premier, afin que le motif de test ne puisse pas les masquer. -Elles ont leur propre puissance (10% par défaut) et vitesse (1000 mm/min par défaut), et les valeurs -de vitesse sont affichées dans ton unité d'affichage préférée. +Les paramètres d'**étiquettes** contrôlent les annotations gravées à côté de la grille. Les +étiquettes sont activées par défaut et sont gravées en premier, afin que le motif de test ne puisse +pas les masquer. Elles ont leur propre puissance (10% par défaut) et vitesse (1000 mm/min par +défaut), et les valeurs de vitesse sont affichées dans ton unité d'affichage préférée. -La section **Paramètres** contient les plages que la grille fait varier — vitesse, puissance, passes -ou décalage, selon le mode sélectionné. Les modes qui gardent un paramètre fixe (par exemple la -vitesse dans Puissance vs Passes) te permettent également de définir cette constante ici. +Les **plages de paramètres** sont ce que la grille fait varier — vitesse, puissance, passes ou +décalage, selon le mode sélectionné. Les modes qui gardent un paramètre fixe (par exemple la vitesse +dans Puissance vs Passes) te permettent également de définir cette constante ici. ## La Pièce Liée Normalement, une opération traite toutes les pièces de son calque, et l'onglet **Pièces** de sa boîte de dialogue de paramètres les liste toutes comme incluses. La Grille de Test de Matériau fait exception : son opération est liée à la pièce qu'elle a générée et ne traite que celle-ci. Dans -l'onglet Pièces, la grille générée apparaît comme la seule pièce incluse, tout le reste du calque -étant marqué comme exclu. +l'onglet Pièces, la grille générée apparaît comme la seule pièce incluse, et tout ce que tu ajoutes +ensuite au calque du test est marqué comme exclu. ![Onglet Pièces des paramètres de la Grille de Test de Matériau](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -140,6 +145,7 @@ suggérera exactement ces réglages la prochaine fois que tu coupes le même mat Les grilles de test de matériau sont des pièces ordinaires, donc elles se combinent librement avec d'autres opérations. Un schéma courant consiste à ajouter une opération de contour autour de la grille terminée et à découper la pièce de test du matériau de stock une fois la gravure terminée. +Les opérations que tu ajoutes au calque du test sont conservées lorsque la grille est reconstruite. Exécuter la même configuration de grille sur différents matériaux est un moyen rapide de constituer une bibliothèque de réglages fiables — et les recettes rendent cette bibliothèque consultable par diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 11da29b72..eef4ff53a 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,13 @@ description: # इंटरवल और फ़ोकस टेस्ट [मटेरियल टेस्ट ग्रिड](material-test-grid.md) के अलावा, टूल्स मेनू दो और कैलिब्रेशन जॉब देता है। -दोनों वर्कपीस और ऑपरेशन वाली एक सामान्य लेयर के रूप में बनते हैं: आप इसे किसी भी अन्य सामग्री की तरह -खिसका, फ़्रेम और पूर्वावलोकन कर सकते हैं, और बाद में हर सेल या रेखा की सेटिंग उसके ऑपरेशन में बदल -सकते हैं। +तीनों एक ही तरह काम करते हैं: **टूल्स → … बनाएँ** टेस्ट को डिफ़ॉल्ट सेटिंग के साथ एक नई लेयर पर +जोड़ता है और उसे चुन लेता है, और उसकी सेटिंग दाईं ओर के आइटम गुण पैनल में **Test Settings** के +अंतर्गत दिखती हैं। वहाँ हर बदलाव टेस्ट को तुरंत फिर से बनाता है, एक ऐसे चरण के रूप में जिसे आप +पूर्ववत कर सकते हैं, ताकि समायोजन करते समय आप परिणाम देख सकें। टेस्ट सामान्य सामग्री है: आप इसे किसी +भी अन्य सामग्री की तरह खिसका, फ़्रेम और पूर्वावलोकन कर सकते हैं। हर सेल या रेखा का अपना ऑपरेशन है, +जिसे आप हाथ से भी बारीकी से समायोजित कर सकते हैं, लेकिन बाद में Test Settings में किया गया बदलाव उन +ऑपरेशनों को फिर से बनाता है। ## इंटरवल टेस्ट @@ -35,8 +39,8 @@ description: Z अक्ष चरण केवल Z अक्ष वाली मशीनों पर उपलब्ध हैं, और Z अक्ष चरण तथा मैनुअल विधि दोनों के लिए G-code कंट्रोलर चाहिए, क्योंकि वे रेखाओं के बीच [कमांड](command.md) ऑपरेशन का उपयोग करते हैं। ऑफ़सेट -शुरुआती ऊँचाई से ±10 mm तक सीमित हैं। डायलॉग ऊपर दी गई तालिका के क्रम में, मशीन द्वारा समर्थित पहली -विधि से शुरू होता है। +शुरुआती ऊँचाई से ±10 mm तक सीमित हैं। नया फ़ोकस टेस्ट ऊपर दी गई तालिका के क्रम में, मशीन द्वारा +समर्थित पहली विधि का उपयोग करता है। मैनुअल विधि में पहले लेज़र को हमेशा की तरह फ़ोकस करें। लेबल उसी ऊँचाई पर उकेरे जाते हैं। पहले विराम पर हेड को पहले ऑफ़सेट पर रखें; हर अगले विराम पर उसे एक चरण खिसकाएँ। इस पर भरोसा करने से पहले जाँचें diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index 235cde72f..440ab1de6 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -21,9 +21,18 @@ description: ## सामग्री परीक्षण ग्रिड बनाना -### चरण 1: जनरेटर खोलें +### चरण 1: ग्रिड बनाएँ -सामग्री परीक्षण ग्रिड जनरेटर तक पहुँचें: +सामग्री परीक्षण ग्रिड जोड़ें: + +- मेनू: **Tools → Create Material Test Grid** +- यह परीक्षण के नाम वाली एक नई लेयर पर एक वर्कपीस और उससे मेल खाता ऑपरेशन जोड़ता है, और ग्रिड को + चुन लेता है +- ग्रिड की सेटिंग्स दाईं ओर के आइटम गुण पैनल में **Test Settings** के अंतर्गत दिखती हैं, इसलिए + बदलाव करते समय कैनवस और ग्रिड दिखाई देते रहते हैं +- बाद में सेटिंग्स फिर से दिखाने के लिए ग्रिड को दोबारा चुनें + +[इंटरवल और फ़ोकस टेस्ट](interval-and-focus-tests.md) भी इसी तरह काम करते हैं. - मेनू: **Tools → Material Test Grid** - यह एक विशेष वर्कपीस बनाता है जो परीक्षण पैटर्न जनरेट करता है @@ -43,8 +52,9 @@ Rayforge सामान्य प्रसंगों के लिए प् ### चरण 3: पैरामीटर कॉन्फ़िगर करें -सेटिंग्स संवाद में परीक्षण ग्रिड पैरामीटर समायोजित करें. विकल्प बदलने पर ग्रिड कैनवस पर स्वतः अपडेट -होता रहता है: +**Test Settings** में परीक्षण ग्रिड पैरामीटर समायोजित करें. हर बदलाव ग्रिड को कैनवस पर तुरंत फिर से +बनाता है, एक ऐसे चरण के रूप में जिसे आप पूर्ववत कर सकते हैं. लेज़र हेड और पोस्ट-प्रोसेसिंग ऑपरेशन की +सेटिंग्स में ही रहते हैं: ![सामग्री परीक्षण ग्रिड सेटिंग्स](/screenshots/material-test.webp) @@ -95,7 +105,8 @@ Rayforge सामान्य प्रसंगों के लिए प् आम तौर पर एक ऑपरेशन अपनी लेयर के सभी वर्कपीस प्रोसेस करता है, और उसके सेटिंग्स संवाद का **वर्कपीस** टैब उन सभी को शामिल के रूप में सूचीबद्ध करता है. सामग्री परीक्षण ग्रिड इसका अपवाद है: इसका ऑपरेशन उस वर्कपीस से जुड़ा है जो उसने बनाया और केवल उसी को प्रोसेस करता है. वर्कपीस टैब पर बनाया गया ग्रिड -एकमात्र शामिल वर्कपीस के रूप में दिखता है, जबकि लेयर का बाकी सब कुछ बाहर चिह्नित होता है. +एकमात्र शामिल वर्कपीस के रूप में दिखता है, जबकि बाद में परीक्षण की लेयर पर जोड़ी गई हर चीज़ बाहर +चिह्नित होती है. ![सामग्री परीक्षण ग्रिड सेटिंग्स का वर्कपीस टैब](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -198,7 +209,8 @@ Order: 1 2 3 2. पूरे ग्रिड के चारों ओर कॉन्टूर कट जोड़ें 3. परीक्षण चलाएँ, स्वतंत्र काटें, परिणाम मूल्यांकन करें -यह परीक्षण टुकड़े को स्टॉक सामग्री से काटकर अलग करने के लिए उपयोगी है. +यह परीक्षण टुकड़े को स्टॉक सामग्री से काटकर अलग करने के लिए उपयोगी है. परीक्षण की लेयर पर जोड़े गए +ऑपरेशन ग्रिड के दोबारा बनने पर बने रहते हैं. ### कस्टम परीक्षण सीमाएँ diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 483a1944c..209f6c5d1 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,13 @@ description: # Testes de intervalo e de foco Ao lado da [grade de teste de material](material-test-grid.md), o menu Ferramentas oferece mais dois -trabalhos de calibração. Ambos são criados como uma camada comum com peças e operações: você pode -movê-la, enquadrá-la e visualizá-la como qualquer outro conteúdo, e alterar depois as configurações -de cada célula ou linha na sua operação. +trabalhos de calibração. Os três funcionam da mesma forma: **Ferramentas → Criar …** adiciona o +teste com as configurações padrão em uma nova camada e o seleciona, e as configurações dele aparecem +em **Test Settings** no painel de propriedades à direita. Cada alteração ali reconstrói o teste na +hora, como um único passo que você pode desfazer, para que você veja o resultado enquanto o ajusta. +O teste é conteúdo comum: você pode movê-lo, enquadrá-lo e visualizá-lo como qualquer outro +conteúdo. Cada célula ou linha tem sua própria operação, que você também pode ajustar à mão, mas uma +alteração posterior em Test Settings reconstrói essas operações. ## Teste de intervalo @@ -37,8 +41,8 @@ o melhor foco. Os passos do eixo Z só são oferecidos em máquinas com eixo Z, e tanto os passos do eixo Z quanto o método manual precisam de um controlador G-code, porque usam operações de [Comando](command.md) -entre as linhas. Os deslocamentos são limitados a ±10 mm da altura inicial. O diálogo começa no -primeiro método que a máquina suporta, na ordem da tabela acima. +entre as linhas. Os deslocamentos são limitados a ±10 mm da altura inicial. Um novo teste de foco +usa o primeiro método que a máquina suporta, na ordem da tabela acima. No método manual, primeiro foque o laser como de costume. Os rótulos são gravados nessa altura. Na primeira pausa, coloque a cabeça no primeiro deslocamento; em cada pausa seguinte, mova-a um passo. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index ad1a6859b..7ac9e1680 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -12,17 +12,21 @@ busca pela combinação certa: ela gera um padrão de células de teste em que c cortada com uma configuração ligeiramente diferente, tudo em um único trabalho. Depois de uma execução, você pode ver de relance qual combinação produz o resultado desejado. -Crie uma via **Ferramentas → Criar Grade de Teste de Material**. O Rayforge adiciona uma peça -especial à tela, junto com uma operação correspondente, e você configura a grade no diálogo de -configurações dela. +Crie uma via **Ferramentas → Criar Grade de Teste de Material**. O Rayforge adiciona a grade em uma +nova camada com o nome do teste, com uma peça e uma operação correspondente, e a seleciona. As +configurações da grade aparecem em **Test Settings** no painel de propriedades à direita, de modo +que a tela e a grade continuam visíveis enquanto você as altera. Cada alteração reconstrói a grade +na hora, como um único passo que você pode desfazer. Selecione a grade novamente mais tarde para +trazer as configurações dela de volta. Os [testes de intervalo e de +foco](interval-and-focus-tests.md) funcionam da mesma forma. ![Configurações da Grade de Teste de Material](/screenshots/material-test.webp) ## Predefinições -O diálogo de configurações oferece predefinições para tipos comuns de laser. Elas preenchem uma -faixa de velocidade, uma faixa de potência e um tipo de teste razoáveis, para que você comece com -uma base sensata: +A seção Test Settings oferece predefinições para tipos comuns de laser. Elas preenchem uma faixa de +velocidade, uma faixa de potência e um tipo de teste razoáveis, para que você comece com uma base +sensata: | Predefinição | Faixa de Velocidade | Faixa de Potência | Tipo de Teste | | ------------------ | ------------------- | ----------------- | ------------- | @@ -50,24 +54,25 @@ junto com a velocidade, de modo que todas as células permaneçam visualmente co ## Configurando a Grade -O diálogo de configurações agrupa os parâmetros em três seções. - -A seção **Grade** controla o teste em si. O tipo de teste determina se cada célula corta o contorno -de um quadrado ou o preenche com linhas raster. As dimensões da grade definem quantas colunas e -linhas testar — cada coluna representa um passo do primeiro parâmetro do modo e cada linha um passo -do segundo, do mínimo ao máximo da faixa que você inserir. São permitidas entre 2 e 20 etapas por -eixo; 5×5 é um bom padrão. O tamanho da forma (10 mm por padrão) e o espaçamento (2 mm por padrão) -determinam o tamanho da grade. Para o tipo de teste Gravação, o intervalo entre linhas controla a -distância entre as linhas de varredura — valores menores preenchem de forma mais densa, mas levam -mais tempo. Deixe em zero para usar o tamanho do ponto do seu laser, o que funciona bem para a -maioria das gravações. - -A seção **Rótulos** controla as anotações gravadas ao lado da grade. Os rótulos ficam ativados por -padrão e são gravados primeiro, para que o padrão de teste não os obscureça. Eles têm potência -própria (10% por padrão) e velocidade própria (1000 mm/min por padrão), e os valores de velocidade -são exibidos na sua unidade de exibição preferida. - -A seção **Parâmetros** contém as faixas que a grade varia — velocidade, potência, passagens ou +A seção Test Settings lista primeiro as configurações da grade, depois os rótulos e, por fim, as +faixas de parâmetros. A cabeça do laser e o pós-processamento ficam nas configurações da operação. + +As configurações de **grade** controlam o teste em si. O tipo de teste determina se cada célula +corta o contorno de um quadrado ou o preenche com linhas raster. As dimensões da grade definem +quantas colunas e linhas testar — cada coluna representa um passo do primeiro parâmetro do modo e +cada linha um passo do segundo, do mínimo ao máximo da faixa que você inserir. São permitidas entre +2 e 20 etapas por eixo; 5×5 é um bom padrão. O tamanho da forma (10 mm por padrão) e o espaçamento +(2 mm por padrão) determinam o tamanho da grade. Para o tipo de teste Gravação, o intervalo entre +linhas controla a distância entre as linhas de varredura — valores menores preenchem de forma mais +densa, mas levam mais tempo. Deixe em zero para usar o tamanho do ponto do seu laser, o que funciona +bem para a maioria das gravações. + +As configurações de **rótulos** controlam as anotações gravadas ao lado da grade. Os rótulos ficam +ativados por padrão e são gravados primeiro, para que o padrão de teste não os obscureça. Eles têm +potência própria (10% por padrão) e velocidade própria (1000 mm/min por padrão), e os valores de +velocidade são exibidos na sua unidade de exibição preferida. + +As **faixas de parâmetros** são o que a grade varia — velocidade, potência, passagens ou deslocamento, dependendo do modo selecionado. Os modos que mantêm um parâmetro fixo (por exemplo, a velocidade em Potência vs Passagens) permitem definir essa constante aqui também. @@ -76,8 +81,8 @@ velocidade em Potência vs Passagens) permitem definir essa constante aqui tamb Normalmente uma operação processa todas as peças de trabalho da sua camada, e a aba **Peças de trabalho** do seu diálogo de configurações as lista todas como incluídas. A Grade de Teste de Material é a exceção: a operação dela está vinculada à peça de trabalho que ela gerou e processa -somente essa. Na aba Peças de trabalho, a grade gerada aparece como a única peça incluída, enquanto -todo o resto da camada é marcado como excluído. +somente essa. Na aba Peças de trabalho, a grade gerada aparece como a única peça incluída, e tudo o +que você adicionar depois à camada do teste é marcado como excluído. ![Aba Peças de trabalho da Grade de Teste de Material](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -137,7 +142,8 @@ sugerirá exatamente essas configurações na próxima vez que você cortar o me Grades de teste de material são peças comuns, por isso se combinam livremente com outras operações. Um padrão comum é adicionar uma operação de contorno ao redor da grade pronta e recortar a peça de -teste do material de estoque depois que a gravação terminar. +teste do material de estoque depois que a gravação terminar. As operações que você adicionar à +camada do teste permanecem quando a grade é reconstruída. Executar a mesma configuração de grade em materiais diferentes é uma maneira rápida de construir uma biblioteca de configurações comprovadamente boas — e as receitas tornam essa biblioteca pesquisável diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index b66cfe29d..077e613de 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -7,9 +7,13 @@ description: # Тести інтервалу та фокусування Поруч із [сіткою тесту матеріалу](material-test-grid.md) меню «Інструменти» пропонує ще два -калібрувальні завдання. Обидва створюються як звичайний шар із заготовками та операціями: його можна -переміщати, обводити рамкою й переглядати, як будь-який інший вміст, а потім змінювати параметри -кожної комірки чи лінії в її операції. +калібрувальні завдання. Усі три працюють однаково: **Інструменти → Створити …** додає тест із +типовими налаштуваннями на новий шар і виділяє його, а його параметри з’являються в розділі **Test +Settings** на панелі властивостей праворуч. Кожна зміна там одразу перебудовує тест одним кроком, +який можна скасувати, тож результат видно під час налаштування. Тест є звичайним вмістом: його можна +переміщати, обводити рамкою й переглядати, як будь-який інший вміст. Кожна комірка чи лінія має +власну операцію, яку також можна підлаштувати вручну, але подальша зміна в Test Settings перебудовує +ці операції. ## Тест інтервалу @@ -36,8 +40,8 @@ description: Кроки осі Z пропонуються лише для верстатів з віссю Z, а кроки осі Z і ручний метод потребують G-code-контролера, бо між лініями використовують операції [Команда](command.md). Зміщення обмежені -±10 мм від початкової висоти. Діалог починається з першого методу, який підтримує машина, у порядку -таблиці вище. +±10 мм від початкової висоти. Новий тест фокусування використовує перший метод, який підтримує +машина, у порядку таблиці вище. Для ручного методу спочатку сфокусуйте лазер як зазвичай. Підписи гравіюються на цій висоті. На першій паузі встановіть голівку на перше зміщення, на кожній наступній — зсуньте її на один крок. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index d19d9b60c..b02d3b092 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -12,15 +12,18 @@ description: дещо іншими налаштуваннями, і все це в межах однієї роботи. Після одного запуску ви з першого погляду побачите, яке поєднання дає бажаний результат. -Створіть її через **Інструменти → Створити тестову сітку матеріалу**. Rayforge додає на полотно -спеціальну заготовку разом з відповідною операцією, а сітку ви налаштовуєте в її діалозі -налаштувань. +Створіть її через **Інструменти → Створити тестову сітку матеріалу**. Rayforge додає сітку на новий +шар, названий за тестом, з однією заготовкою та відповідною операцією, і виділяє її. Налаштування +сітки з’являються в розділі **Test Settings** на панелі властивостей праворуч, тож полотно й сітка +залишаються на виду, поки ви їх змінюєте. Кожна зміна одразу перебудовує сітку одним кроком, який +можна скасувати. Щоб знову побачити налаштування сітки, пізніше просто виділіть її ще раз. [Тести +інтервалу та фокусування](interval-and-focus-tests.md) працюють так само. ![Налаштування тестової сітки матеріалу](/screenshots/material-test.webp) ## Пресети -Діалог налаштувань пропонує пресети для поширених типів лазерів. Вони заповнюють розумний діапазон +Розділ Test Settings пропонує пресети для поширених типів лазерів. Вони заповнюють розумний діапазон швидкості, діапазон потужності та тип тесту, щоб ви могли почати з адекватної базової конфігурації: | Пресет | Діапазон швидкості | Діапазон потужності | Тип тесту | @@ -49,9 +52,10 @@ description: ## Налаштування сітки -Діалог налаштувань групує параметри у три розділи. +Розділ Test Settings показує спершу налаштування сітки, потім мітки, а потім діапазони параметрів. +Лазерна головка та постобробка залишаються в налаштуваннях операції. -Розділ **Сітка** керує самим тестом. Тип тесту визначає, чи буде кожна комірка різати контур +Налаштування **сітки** керують самим тестом. Тип тесту визначає, чи буде кожна комірка різати контур квадрата, чи заповнювати його растровими лініями. Розміри сітки задають, скільки стовпців і рядків тестувати — кожен стовпець відповідає одному кроку першого параметра режиму, а кожен рядок — одному кроку другого, від мінімального до максимального значення введеного вами діапазону. На кожну вісь @@ -61,21 +65,22 @@ description: значення дають щільніше заповнення, але виконуються довше. Залиште нуль, щоб використати розмір плями вашого лазера, що добре підходить для більшості завдань гравіювання. -Розділ **Мітки** керує написами, які гравіюються поруч із сіткою. Мітки увімкнені за замовчуванням і -гравіюються першими, тож тестовий патерн не може їх перекрити. Вони мають власну потужність (за -замовчуванням 10%) та швидкість (за замовчуванням 1000 мм/хв), а значення швидкості відображаються у -вашій уподобаній одиниці вимірювання. +Налаштування **міток** керують написами, які гравіюються поруч із сіткою. Мітки увімкнені за +замовчуванням і гравіюються першими, тож тестовий патерн не може їх перекрити. Вони мають власну +потужність (за замовчуванням 10%) та швидкість (за замовчуванням 1000 мм/хв), а значення швидкості +відображаються у вашій уподобаній одиниці вимірювання. -Розділ **Параметри** містить діапазони, які варіює сітка — швидкість, потужність, проходи або зсув, -залежно від обраного режиму. Для режимів, які фіксують один із параметрів (наприклад, швидкість у -режимі «Потужність проти проходів»), тут також можна задати це константне значення. +**Діапазони параметрів** — це те, що варіює сітка: швидкість, потужність, проходи або зсув, залежно +від обраного режиму. Для режимів, які фіксують один із параметрів (наприклад, швидкість у режимі +«Потужність проти проходів»), тут також можна задати це константне значення. ## Пов’язана робоча деталь Зазвичай операція обробляє всі робочі деталі свого шару, і вкладка **Робочі деталі** її діалогового вікна налаштувань перелічує їх усі як включені. Тестова сітка матеріалу є винятком: її операція пов’язана зі створеною нею робочою деталлю та обробляє лише її. На вкладці Робочі деталі створена -сітка показана як єдина включена робоча деталь, а все інше на шарі позначено як виключене. +сітка показана як єдина включена робоча деталь, а все, що ви пізніше додасте на шар тесту, позначено +як виключене. ![Вкладка Робочі деталі налаштувань тестової сітки матеріалу](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -135,7 +140,8 @@ description: Тестові сітки матеріалу є звичайними заготовками, тож вони вільно поєднуються з іншими операціями. Поширений підхід — додати контурну операцію навколо готової сітки та вирізати тестовий шматок із -матеріалу заготовки після завершення гравіювання. +матеріалу заготовки після завершення гравіювання. Операції, які ви додасте на шар тесту, +зберігаються, коли сітка перебудовується. Запуск тієї самої конфігурації сітки на різних матеріалах — це швидкий спосіб створити бібліотеку перевірених налаштувань, а рецепти згодом роблять цю бібліотеку доступною для пошуку за матеріалом і diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index a0da772e6..e26754fb5 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -4,7 +4,7 @@ description: "使用线间距测试和焦距测试,找到最适合雕刻的线 # 线间距与焦距测试 -除了[材料测试网格](material-test-grid.md),工具菜单还提供另外两种校准作业。两者都会创建为包含工件和操作的普通图层:你可以像其他内容一样移动、走边框和预览,之后还可以在各自的操作中修改每个单元格或每条线的设置。 +除了[材料测试网格](material-test-grid.md),工具菜单还提供另外两种校准作业。三者的用法相同:**工具 → 创建 …** 会以默认设置在新图层上添加测试并选中它,其设置显示在右侧项目属性面板的 **Test Settings** 下。在那里的每次修改都会立即重建测试,并作为一个可撤销的步骤,因此你可以边调整边查看结果。测试是普通内容:你可以像其他内容一样移动、走边框和预览。每个单元格或每条线都有自己的操作,你也可以手动微调,但之后在 Test Settings 中的修改会重建这些操作。 ## 线间距测试 @@ -23,7 +23,7 @@ description: "使用线间距测试和焦距测试,找到最适合雕刻的线 | **斜坡(倾斜材料)** | 一条带距离刻度的长线;你把平板条的一端垫高 | Z 轴步进仅在具有 Z 轴的机器上提供;Z 轴步进和手动方法都需要 G-code 控制器,因为它们在线之间使用[命令](command.md)操作。偏移限制在起始高度的 ±10 -mm 以内。对话框会按上表的顺序,默认选中机器支持的第一种方法。 +mm 以内。新建的焦距测试会按上表的顺序,使用机器支持的第一种方法。 使用手动方法时,先像平常一样对焦激光。标签在该高度雕刻。第一次暂停时,把激光头调到起始偏移;之后每次暂停时移动一个步进。在依赖此方法之前,请确认你的控制器会在 `M0` 处停止,并且“继续”按钮可以继续作业,例如以 0% 功率空跑一次。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index 4ef172265..b3206bffa 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -7,13 +7,13 @@ description: "生成材料测试网格,为任何材料找到最佳的激光功 每种材料——甚至是同一种材料的不同颜色和厚度——对激光功率和速度的反应都不相同。材料测试网格让您无需再凭猜测寻找合适的组合:它会生成一个由测试单元组成的图案,每个单元都以略有不同的设置进行雕刻或切割,全部在一次作业中完成。只需运行一次,您就能一目了然地看出哪种组合能产生您想要的结果。 通过 **工具 → 创建材料测试网格** -创建。Rayforge 会在画布上添加一个特殊的工件以及与之匹配的操作,您可以在其设置对话框中配置网格。 +创建。Rayforge 会将网格添加到一个以该测试命名的新图层上,包含一个工件和与之匹配的操作,并将其选中。网格的设置显示在右侧项目属性面板的 **Test Settings** 下,因此在修改设置时画布和网格始终可见。每次修改都会立即重建网格,并作为一个可撤销的步骤。之后再次选中网格即可重新显示其设置。[线间距与焦距测试](interval-and-focus-tests.md)的用法相同。 ![材料测试网格设置](/screenshots/material-test.webp) ## 预设 -设置对话框为常见激光类型提供了预设。它们会填入合理的速度范围、功率范围和测试类型,让您从一个合理的基线开始: +Test Settings 为常见激光类型提供了预设。它们会填入合理的速度范围、功率范围和测试类型,让您从一个合理的基线开始: | 预设 | 速度范围 | 功率范围 | 测试类型 | | -------------- | ----------------- | -------- | -------- | @@ -35,23 +35,23 @@ description: "生成材料测试网格,为任何材料找到最佳的激光功 ## 配置网格 -设置对话框将参数分为三个部分。 +Test Settings 先列出网格设置,然后是标签,最后是参数范围。激光头和后处理仍保留在操作的设置中。 **网格** -部分控制测试本身。测试类型决定每个单元是切出方块的轮廓,还是用光栅线填充它。网格尺寸设置要测试的列数和行数——每一列代表模式第一个参数的一步,每一行代表第二个参数的一步,从您输入范围的最小值到最大值。每个轴允许 2 到 20 步;5×5 是一个不错的默认值。形状大小(默认 10 +设置控制测试本身。测试类型决定每个单元是切出方块的轮廓,还是用光栅线填充它。网格尺寸设置要测试的列数和行数——每一列代表模式第一个参数的一步,每一行代表第二个参数的一步,从您输入范围的最小值到最大值。每个轴允许 2 到 20 步;5×5 是一个不错的默认值。形状大小(默认 10 mm)和间距(默认 2 mm)决定了网格的整体大小。对于雕刻测试类型,线间隔控制扫描线之间的距离——值越小填充越密集,但耗时更长。将其保留为零即可使用您激光器的光斑大小,这对大多数雕刻任务都很合适。 **标签** -部分控制雕刻在网格旁边的注释。标签默认开启,并且会先被雕刻,因此测试图案不会遮挡它们。标签拥有自己独立的功率(默认 10%)和速度(默认 1000 +设置控制雕刻在网格旁边的注释。标签默认开启,并且会先被雕刻,因此测试图案不会遮挡它们。标签拥有自己独立的功率(默认 10%)和速度(默认 1000 mm/min),速度值会以您偏好的显示单位显示。 -**参数** -部分保存网格要变化的范围——速度、功率、通过次数或偏移量,取决于所选模式。固定某个参数的模式(例如功率与通过次数中的速度)也允许您在此处设置该常量。 +**参数范围** +是网格要变化的值——速度、功率、通过次数或偏移量,取决于所选模式。固定某个参数的模式(例如功率与通过次数中的速度)也允许您在此处设置该常量。 ## 关联的工件 -通常,一个操作会处理其图层上的所有工件,其设置对话框的**工件**选项卡会将它们全部列为包含。材料测试网格是个例外:它的操作与它生成的工件绑定,只处理该工件。在工件选项卡中,生成的网格是唯一被包含的工件,图层上的其他工件都被标记为排除。 +通常,一个操作会处理其图层上的所有工件,其设置对话框的**工件**选项卡会将它们全部列为包含。材料测试网格是个例外:它的操作与它生成的工件绑定,只处理该工件。在工件选项卡中,生成的网格是唯一被包含的工件,之后添加到测试图层上的任何内容都会被标记为排除。 ![材料测试网格设置中的工件选项卡](/screenshots/step-settings-material-test-grid-workpieces.webp) @@ -95,7 +95,7 @@ mm/min 附近,那么一个覆盖 35-45% 和 3000-5000 mm/min 的后续网格 ## 高级用法 -材料测试网格是普通的工件,因此可以与其他操作自由组合。一种常见的做法是在完成的网格周围添加轮廓操作,在雕刻完成后将测试件从原材料上切下。 +材料测试网格是普通的工件,因此可以与其他操作自由组合。一种常见的做法是在完成的网格周围添加轮廓操作,在雕刻完成后将测试件从原材料上切下。添加到测试图层上的操作在网格重建时会保留。 在不同的材料上运行相同的网格配置,是快速建立可靠设置库的好方法——而配方能让这个库日后按材料和厚度进行搜索。 From b81bdb3f13381349bc91fc4c9ebcf1d46f92d143 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:21:36 +0200 Subject: [PATCH 18/28] docs: rewrap the French Test Settings paragraph Refs #519 --- .../current/features/operations/material-test-grid.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md index 036abc0c1..e8b1e751e 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/material-test-grid.md @@ -56,8 +56,9 @@ comparables. ## Configuration de la Grille -Les Test Settings affichent d'abord les paramètres de la grille, puis les étiquettes, puis les plages -de paramètres. La tête laser et le post-traitement restent dans les paramètres de l'opération. +Les Test Settings affichent d'abord les paramètres de la grille, puis les étiquettes, puis les +plages de paramètres. La tête laser et le post-traitement restent dans les paramètres de +l'opération. Les paramètres de **grille** contrôlent le test lui-même. Le type de test détermine si chaque cellule découpe le contour d'un carré ou le remplit de lignes raster. Les dimensions de la grille From 03bae07c7a245ac821bf704de8618372ccea355d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:31:12 +0200 Subject: [PATCH 19/28] fix(core): wire step signals when steps are added in bulk Workflow.add_children and remove_children went straight to DocItem and skipped the per-step transformer wiring that add_child and set_children do, so a step added in bulk did not report post-processor changes. Doc.remove_asset_by_uid gets a silent flag, like add_asset, for commands that change several assets and notify once. Refs #519 --- rayforge/core/doc.py | 5 +++-- rayforge/core/workflow.py | 20 ++++++++++++++++++++ tests/core/test_doc.py | 15 +++++++++++++++ tests/core/test_workflow.py | 27 +++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index c6a45d17e..2f421e27d 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -196,7 +196,7 @@ def add_asset( if not silent: self.updated.send(self) - def remove_asset_by_uid(self, uid: str): + def remove_asset_by_uid(self, uid: str, silent: bool = False): """Removes an asset from the document by its UID.""" if self.assets.pop(uid, None): try: @@ -205,7 +205,8 @@ def remove_asset_by_uid(self, uid: str): logger.warning( f"Asset UID {uid} was in asset dict but not in order list." ) - self.updated.send(self) + if not silent: + self.updated.send(self) def remove_asset(self, asset: "IAsset"): """Removes an asset from the document.""" diff --git a/rayforge/core/workflow.py b/rayforge/core/workflow.py index 5ec4269d5..4199ff4c9 100644 --- a/rayforge/core/workflow.py +++ b/rayforge/core/workflow.py @@ -111,6 +111,26 @@ def remove_child(self, child: DocItem): ) super().remove_child(child) + def add_children( + self, children_to_add: Iterable[DocItem], index: int | None = None + ): + children = list(children_to_add) + for child in children: + if isinstance(child, Step): + child.per_step_transformer_changed.connect( + self._on_per_step_transformer_changed + ) + super().add_children(children, index) + + def remove_children(self, children_to_remove: Iterable[DocItem]): + children = list(children_to_remove) + for child in children: + if isinstance(child, Step) and child in self.children: + child.per_step_transformer_changed.disconnect( + self._on_per_step_transformer_changed + ) + super().remove_children(children) + def set_children(self, new_children: Iterable[DocItem]): old_steps = self.steps for step in old_steps: diff --git a/tests/core/test_doc.py b/tests/core/test_doc.py index 217addf28..61074889b 100644 --- a/tests/core/test_doc.py +++ b/tests/core/test_doc.py @@ -116,6 +116,21 @@ def test_doc_asset_management(doc, asset_factory, get_compatibility_dict): doc.add_asset("not an asset") +def test_remove_asset_silently(doc): + """A silent removal skips the updated signal, for commands that + change several assets and notify once.""" + asset = StockAsset(name="Stock") + doc.add_asset(asset) + handler = MagicMock() + doc.updated.connect(handler) + + doc.remove_asset_by_uid(asset.uid, silent=True) + + assert doc.get_asset_by_uid(asset.uid) is None + assert asset.uid not in doc.asset_order + handler.assert_not_called() + + def test_doc_stock_items_management(doc): """Tests adding, removing, and getting stock items.""" asset1 = StockAsset(name="Asset 1") diff --git a/tests/core/test_workflow.py b/tests/core/test_workflow.py index bcd8611f6..ecca69217 100644 --- a/tests/core/test_workflow.py +++ b/tests/core/test_workflow.py @@ -248,3 +248,30 @@ def test_workflow_backward_compatibility_with_missing_optional_fields(): # Verify defaults are applied for missing optional fields assert workflow.name == "Workflow" assert workflow.extra == {} + + +def test_bulk_added_steps_bubble_transformer_changes(workflow): + """Steps added in bulk send per-step transformer changes upward, + like steps added one by one.""" + handler = MagicMock() + workflow.per_step_transformer_changed.connect(handler) + steps = [Step("A"), Step("B")] + + workflow.add_children(steps) + steps[1].per_step_transformer_changed.send(steps[1]) + + assert workflow.steps == steps + handler.assert_called_once_with(workflow) + + +def test_bulk_removed_steps_stop_bubbling(workflow): + handler = MagicMock() + workflow.per_step_transformer_changed.connect(handler) + steps = [Step("A"), Step("B")] + workflow.add_children(steps) + + workflow.remove_children(steps) + steps[0].per_step_transformer_changed.send(steps[0]) + + assert workflow.steps == [] + handler.assert_not_called() From 80f7724723bcb268416880ccc18cc66aecb2cda8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:31:12 +0200 Subject: [PATCH 20/28] perf(laser): rebuild a calibration test in one bulk change Rebuilding a test replaced its workpieces, steps and assets with one command per item. Every item made the layers panel and the workflow views rebuild, so a Focus Test with Z axis steps froze the window for about three seconds after each change. RebuildCalibrationCommand now swaps them in bulk with the pipeline paused, as grouping does, which takes under 0.1 s. Cells in a group go back to it on undo. Refs #519 --- .../laser_essentials/calibration/record.py | 5 +- .../commands/calibration_cmd.py | 144 ++++++++++++------ .../tests/test_calibration_generators.py | 26 +--- tests/doceditor/test_calibration_tests.py | 53 +++++++ 4 files changed, 149 insertions(+), 79 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py index cac2a99c8..8e46d1e9c 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py @@ -8,7 +8,7 @@ """ import uuid -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from typing import Any, ClassVar from blinker import Signal @@ -62,9 +62,6 @@ def set_state(self, state: CalibrationState) -> None: self.state = state self.updated.send(self) - def with_params(self, params: dict[str, Any]) -> CalibrationState: - return replace(self.state, params=dict(params)) - def owns(self, uid: str) -> bool: return uid in self.state.workpiece_uids diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py index a1f081b9a..ae335d977 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py @@ -9,6 +9,8 @@ """ import logging +from collections import defaultdict +from dataclasses import dataclass from gettext import gettext as _ from typing import TYPE_CHECKING, Any @@ -18,8 +20,8 @@ from rayforge.core.item import DocItem from rayforge.core.layer import Layer from rayforge.core.step import Step -from rayforge.core.undo import ChangePropertyCommand, ListItemCommand -from rayforge.core.undo.list_cmd import ReorderListCommand +from rayforge.core.undo import Command, ListItemCommand +from rayforge.core.workflow import Workflow from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand @@ -131,35 +133,34 @@ def update_params( built = self._build(generator, params) old_wps = self.workpieces(record) self._place(built, old_wps) - steps = _merge_steps( - layer.workflow.steps, set(record.state.step_uids), built.steps + old = GeneratedItems( + old_wps, + self._steps(layer.workflow, record), + self._unused_assets(record, old_wps), + record.state, + ) + new = GeneratedItems( + built.workpieces, + built.steps, + list(built.assets), + _state(params, built), ) label = _("Change {test}").format(test=generator.title) with self._editor.history_manager.transaction(label) as t: if record.uid not in self._doc.assets: t.execute(self._add_asset_cmd(record)) - for wp in old_wps: - t.execute(self._remove_child_cmd(wp)) - for asset in self._unused_assets(record, old_wps): - t.execute(self._remove_asset_cmd(asset)) - for asset in built.assets: - t.execute(self._add_asset_cmd(asset)) - for wp in built.workpieces: - t.execute(self._add_child_cmd(layer, wp)) - t.execute( - ReorderListCommand(layer.workflow, "steps", steps, "set_steps") - ) t.execute( - ChangePropertyCommand( - record, - "state", - _state(params, built), - setter_method_name="set_state", - coalesce=False, + RebuildCalibrationCommand( + self._editor, layer, record, old, new, label ) ) return built.workpieces + @staticmethod + def _steps(workflow: Workflow, record: CalibrationRecord) -> list[Step]: + generated = set(record.state.step_uids) + return [s for s in workflow.steps if s.uid in generated] + def workpieces(self, record: CalibrationRecord) -> list[WorkPiece]: """The workpieces of the test that are still in the document.""" found = ( @@ -212,18 +213,6 @@ def _build( def _add_asset_cmd(self, asset: IAsset) -> ListItemCommand: return ListItemCommand(self._doc, asset, "remove_asset", "add_asset") - def _remove_asset_cmd(self, asset: IAsset) -> ListItemCommand: - return ListItemCommand(self._doc, asset, "add_asset", "remove_asset") - - @staticmethod - def _add_child_cmd(parent: DocItem, wp: WorkPiece) -> ListItemCommand: - return ListItemCommand(parent, wp, "remove_child", "add_child") - - @staticmethod - def _remove_child_cmd(wp: WorkPiece) -> ListItemCommand: - assert wp.parent is not None - return ListItemCommand(wp.parent, wp, "add_child", "remove_child") - def _state(params: dict[str, Any], built: BuiltTest) -> CalibrationState: return CalibrationState( @@ -234,22 +223,77 @@ def _state(params: dict[str, Any], built: BuiltTest) -> CalibrationState: ) -def _merge_steps( - current: list[Step], generated_uids: set[str], new: list[Step] -) -> list[Step]: +@dataclass +class GeneratedItems: + """The generated items of a test and the state they belong to.""" + + workpieces: list[WorkPiece] + steps: list[Step] + assets: list[IAsset] + state: CalibrationState + + +class RebuildCalibrationCommand(Command): """ - The workflow after a rebuild: the new generated steps take the - place of the first old one, and steps the user added keep their - order around them. + Replaces the generated items of a test in bulk. Each parent, the + workflow and the asset list change once, so the UI and the pipeline + update once instead of once per cell, as grouping does it. New steps + take the place of the first old one; steps the user added stay. """ - result: list[Step] = [] - inserted = False - for step in current: - if step.uid not in generated_uids: - result.append(step) - elif not inserted: - result.extend(new) - inserted = True - if not inserted: - result = new + result - return result + + def __init__( + self, + editor: "DocEditor", + layer: Layer, + record: CalibrationRecord, + old: GeneratedItems, + new: GeneratedItems, + name: str, + ): + super().__init__(name) + self._editor = editor + self._layer = layer + self._record = record + self._old = old + self._new = new + self._parents = {wp.uid: wp.parent for wp in old.workpieces} + + def execute(self) -> None: + self._swap(self._old, self._new) + + def undo(self) -> None: + self._swap(self._new, self._old) + + def _swap(self, out: GeneratedItems, into: GeneratedItems): + doc = self._editor.doc + with self._editor.pipeline.paused(): + self._swap_workpieces(out.workpieces, into.workpieces) + self._swap_steps(out.steps, into.steps) + for asset in into.assets: + doc.add_asset(asset, silent=True) + for asset in out.assets: + doc.remove_asset_by_uid(asset.uid, silent=True) + self._record.set_state(into.state) + doc.updated.send(doc) + + def _swap_workpieces(self, out: list[WorkPiece], into: list[WorkPiece]): + by_parent: dict[DocItem, list[WorkPiece]] = defaultdict(list) + for wp in out: + if wp.parent is not None: + by_parent[wp.parent].append(wp) + for parent, items in by_parent.items(): + parent.remove_children(items) + by_parent.clear() + for wp in into: + by_parent[self._parents.get(wp.uid) or self._layer].append(wp) + for parent, items in by_parent.items(): + parent.add_children(items) + + def _swap_steps(self, out: list[Step], into: list[Step]): + workflow = self._layer.workflow + assert workflow is not None + current = workflow.children + positions = [current.index(s) for s in out if s in current] + index = min(positions) if positions else 0 + workflow.remove_children(out) + workflow.add_children(into, index) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py index 1d36c2c4f..a0ea9a5c2 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py @@ -49,7 +49,6 @@ MaterialTestGridGenerator, params_from_step, ) -from laser_essentials.commands.calibration_cmd import _merge_steps from laser_essentials.material_test_helpers import ( draw_material_test_preview, get_material_test_proportional_size, @@ -233,7 +232,7 @@ def test_set_state_notifies(self): ) seen = [] record.updated.connect(lambda sender: seen.append(sender), weak=False) - record.set_state(record.with_params({"count": 3})) + record.set_state(CalibrationState({"count": 3})) assert record.params == {"count": 3} assert seen == [record] @@ -330,29 +329,6 @@ def test_rejects_too_small_grid(self): self.generator._grid_params(params) -class TestMergeSteps: - def _steps(self, *names): - steps = [] - for name in names: - step = MaterialTestStep() - step.name = name - steps.append(step) - return steps - - def test_new_steps_take_the_place_of_the_old(self): - user_a, old1, old2, user_b = self._steps("ua", "o1", "o2", "ub") - new = self._steps("n1", "n2", "n3") - result = _merge_steps( - [user_a, old1, old2, user_b], {old1.uid, old2.uid}, new - ) - assert result == [user_a, *new, user_b] - - def test_new_steps_go_first_when_none_are_left(self): - (user,) = self._steps("u") - new = self._steps("n") - assert _merge_steps([user], {"gone"}, new) == [*new, user] - - class TestHelpers: def test_text_shrinks_to_fit(self): wide = text("0.25 mm", 2.5, 0.0, 0.0) diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index 29dc6db65..7fdd1ffc1 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -13,6 +13,7 @@ import pytest_asyncio from rayforge.context import get_context +from rayforge.core.group import Group from rayforge.core.vectorization_spec import ProceduralSpec from rayforge.core.workpiece import WorkPiece from rayforge.image.procedural import ProceduralImporter @@ -286,6 +287,45 @@ async def test_update_keeps_steps_the_user_added(doc_editor, machine): assert len(steps) == 1 + 4 + 1 +@pytest.mark.asyncio +async def test_update_keeps_a_user_step_between_the_cells(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + layer = _layer(doc_editor, record) + extra = _steps(layer)[0].__class__.create(doc_editor.context) + layer.workflow.add_child(extra, index=2) + + cmd.update_params(record, {**record.params, "count": 3}) + + steps = _steps(layer) + assert steps.index(extra) == 4 + assert len(steps) == 5 + doc_editor.history_manager.undo() + assert _steps(layer).index(extra) == 3 + + +@pytest.mark.asyncio +async def test_update_and_undo_keep_cells_in_their_group(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + layer = _layer(doc_editor, record) + cells = cmd.workpieces(record) + group = Group("Cells") + layer.add_child(group) + for wp in cells[1:]: + group.add_child(wp) + + new = cmd.update_params(record, {**record.params, "count": 3}) + assert all(wp.parent is layer for wp in new) + assert group.children == [] + + doc_editor.history_manager.undo() + assert [wp.parent for wp in cells] == [layer, group, group] + doc_editor.history_manager.redo() + assert [wp.parent for wp in new] == [layer] * 4 + assert all(wp.parent is None for wp in cells) + + @pytest.mark.asyncio async def test_invalid_params_leave_the_test_alone(doc_editor, machine): cmd = CalibrationCmd(doc_editor) @@ -676,6 +716,19 @@ async def test_focus_ramp_has_no_commands(doc_editor, machine): assert "G91" not in gcode +@pytest.mark.asyncio +async def test_focus_gcode_follows_a_rebuild(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("focus_test", _focus(FocusMode.MANUAL)) + assert (await _gcode(doc_editor)).count("M0") == 3 + + cmd.update_params(record, {**record.params, "count": 5}) + assert (await _gcode(doc_editor)).count("M0") == 5 + + doc_editor.history_manager.undo() + assert (await _gcode(doc_editor)).count("M0") == 3 + + @pytest.mark.asyncio async def test_focus_switch_mode_rebuilds_commands(doc_editor, machine): cmd = CalibrationCmd(doc_editor) From 95107a0825c959a275eda667864fa75059df1121 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:24:22 +0200 Subject: [PATCH 21/28] fix(laser): keep the grid's step, follow recipes, leave with the layer A recipe applied to the Material Test Grid's step changed its grid settings, but the preview kept its size, the Test Settings kept the old values and the next change there dropped the recipe. A rebuild also replaced the step, so the laser head and the post-processing set in its own settings were lost. - A generator can now keep its steps on a rebuild and change only the settings it controls. The grid does, and reads its settings back from the step, where recipes apply them. - CalibrationSync, registered as an editor command, redraws the grid preview when its step's grid settings change, as before the grid got its own layer. - The same command takes a test's record and hidden source assets out of the document with the test's layer and puts them back when the layer returns, so a deleted test leaves nothing behind in the saved project and undo still works. - The Test Settings provider is registered in register_commands instead of on import, so importing the frontend does not add it twice. Refs #519 --- .../laser_essentials/calibration/builder.py | 16 +- .../laser_essentials/calibration/generator.py | 34 ++++ .../calibration/material_test_grid.py | 16 +- .../laser_essentials/commands/__init__.py | 2 + .../commands/calibration_cmd.py | 95 +++++++--- .../commands/calibration_sync.py | 133 +++++++++++++ .../laser_essentials/frontend.py | 17 +- .../tests/test_calibration_generators.py | 4 +- tests/doceditor/test_calibration_tests.py | 176 ++++++++++++++++++ 9 files changed, 452 insertions(+), 41 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py index 8b9820d98..281d6b2af 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py @@ -1,13 +1,15 @@ """ Turns a :class:`CalibrationLayout` into workpieces, steps and assets. -The builder creates the items but does not add them to the document; -their source assets are hidden, since they belong to the test and are -replaced when it is rebuilt; -``CalibrationCmd`` does that in one undoable transaction. Items are -created at their test-local position and then moved together, so the -conversion from test-local to world coordinates happens in one place -(:meth:`BuiltTest.move_to`). +The builder creates the items; ``CalibrationCmd`` adds them to the +document in one undoable transaction. Their source assets are hidden, +since they belong to the test and are replaced when it is rebuilt. +Items are created at their test-local position and then moved +together by :meth:`BuiltTest.move_to`. + +Vector cells go through the SVG importer: core has no other way to +turn geometry into a workpiece with a source asset that the renderer +and the Engrave step can rasterize. """ import logging diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py index f50fd6229..5a4ab2134 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py @@ -10,6 +10,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast +from rayforge.core.step import Step from rayforge.core.varset import VarSet from rayforge.pipeline.transformer.registry import transformer_registry @@ -36,6 +37,11 @@ class CalibrationGenerator(ABC): id: ClassVar[str] + #: Whether a rebuild keeps the existing steps and only changes the + #: settings in :meth:`step_settings`, so what the user set in a + #: step's own settings (laser head, post-processing) stays. + keeps_steps: ClassVar[bool] = False + @property @abstractmethod def title(self) -> str: @@ -54,6 +60,12 @@ def layout( the user when the parameters do not make a valid test. """ + def settings_varset( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> VarSet: + """The settings shown for an existing test with *params*.""" + return self.varset(context) + def defaults(self, context: "RayforgeContext") -> dict[str, Any]: """The parameters of a new test.""" return self.varset(context).get_values() @@ -82,6 +94,28 @@ def help_text(self, params: dict[str, Any]) -> str: """Instructions shown with the settings.""" return "" + def warning( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> str: + """Why the test cannot run as it is on the current machine.""" + return "" + + def step_settings(self, step: Step) -> dict[str, Any]: + """ + The step attributes this generator controls, for a rebuild that + keeps the existing steps (see :attr:`keeps_steps`). + """ + return {} + + def params_from_steps( + self, params: dict[str, Any], steps: list[Step] + ) -> dict[str, Any]: + """ + The parameters of a test whose steps may have changed outside + the Test Settings, for example by applying a recipe. + """ + return params + def contour_step( context: "RayforgeContext", power_percent: float, speed: float diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py index f769cfe97..8670febfd 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py @@ -150,6 +150,7 @@ def _max_speed(context: "RayforgeContext") -> float | None: class MaterialTestGridGenerator(CalibrationGenerator): id = "material_test_grid" + keeps_steps = True @property def title(self) -> str: @@ -220,10 +221,21 @@ def help_text(self, params: dict[str, Any]) -> str: "the settings of its operation." ) + def step_settings(self, step: Step) -> dict[str, Any]: + return {key: getattr(step, key) for key in PARAM_KEYS} + + def params_from_steps( + self, params: dict[str, Any], steps: list[Step] + ) -> dict[str, Any]: + """The grid settings live on the step, where recipes apply.""" + if not steps: + return params + return {**params, **params_from_step(steps[0])} + def layout( self, params: dict[str, Any], context: "RayforgeContext" ) -> CalibrationLayout: - grid_params = self._grid_params(params) + grid_params = self.grid_params(params) step = MaterialTestStep.create(context) step.name = self.title for key, value in grid_params.items(): @@ -238,7 +250,7 @@ def layout( return CalibrationLayout([CellEntry(self.title, shape, step)]) @staticmethod - def _grid_params(params: dict[str, Any]) -> dict[str, Any]: + def grid_params(params: dict[str, Any]) -> dict[str, Any]: """ The step attributes for *params*. Counts are whole numbers even when a spin row or a saved project gives them as floats. diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py index f37ce5979..44a4ec5fb 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py @@ -5,7 +5,9 @@ """ from .calibration_cmd import CalibrationCmd +from .calibration_sync import CalibrationSync __all__ = [ "CalibrationCmd", + "CalibrationSync", ] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py index ae335d977..30a25a8b7 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py @@ -21,7 +21,6 @@ from rayforge.core.layer import Layer from rayforge.core.step import Step from rayforge.core.undo import Command, ListItemCommand -from rayforge.core.workflow import Workflow from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.layer_cmd import AddLayerAndSetActiveCommand @@ -50,8 +49,6 @@ def _doc(self): def generator(generator_id: str) -> CalibrationGenerator: return GENERATORS[generator_id] - # -- Creating --------------------------------------------------------- - def create( self, generator_id: str, params: dict[str, Any] | None = None ) -> CalibrationRecord: @@ -107,15 +104,11 @@ def _new_layer(self, title: str) -> Layer: ) return layer - # -- Finding ---------------------------------------------------------- - @staticmethod def record_for_items(items: list[DocItem]) -> CalibrationRecord | None: """The record of the test all *items* belong to, or None.""" return record_for_items(items) - # -- Updating --------------------------------------------------------- - def update_params( self, record: CalibrationRecord, params: dict[str, Any] ) -> list[WorkPiece]: @@ -124,7 +117,7 @@ def update_params( where it is now. Steps the user added to the layer stay. Raises ``ValueError`` when the parameters do not make a valid test. """ - if params == record.params and record.uid in self._doc.assets: + if params == self.params(record) and record.uid in self._doc.assets: return self.workpieces(record) generator = self.generator(record.generator_id) layer = self._doc.find_descendant_by_uid(record.layer_uid) @@ -133,9 +126,11 @@ def update_params( built = self._build(generator, params) old_wps = self.workpieces(record) self._place(built, old_wps) + old_steps = self._steps(record) + changes = _reuse_steps(generator, old_steps, built) old = GeneratedItems( old_wps, - self._steps(layer.workflow, record), + old_steps, self._unused_assets(record, old_wps), record.state, ) @@ -151,15 +146,26 @@ def update_params( t.execute(self._add_asset_cmd(record)) t.execute( RebuildCalibrationCommand( - self._editor, layer, record, old, new, label + self._editor, layer, record, (old, new), changes, label ) ) return built.workpieces - @staticmethod - def _steps(workflow: Workflow, record: CalibrationRecord) -> list[Step]: - generated = set(record.state.step_uids) - return [s for s in workflow.steps if s.uid in generated] + def params(self, record: CalibrationRecord) -> dict[str, Any]: + """ + The parameters of the test, including changes made to its steps + outside the Test Settings, such as an applied recipe. + """ + generator = self.generator(record.generator_id) + return generator.params_from_steps(record.params, self._steps(record)) + + def _steps(self, record: CalibrationRecord) -> list[Step]: + """The generated steps that are still in the document.""" + found = ( + self._doc.find_descendant_by_uid(uid) + for uid in record.state.step_uids + ) + return [step for step in found if isinstance(step, Step)] def workpieces(self, record: CalibrationRecord) -> list[WorkPiece]: """The workpieces of the test that are still in the document.""" @@ -201,8 +207,6 @@ def _unused_assets( ) return [asset for asset in found if asset is not None] - # -- Building blocks -------------------------------------------------- - def _build( self, generator: CalibrationGenerator, params: dict[str, Any] ) -> BuiltTest: @@ -223,6 +227,43 @@ def _state(params: dict[str, Any], built: BuiltTest) -> CalibrationState: ) +@dataclass +class StepChange: + """Settings of a kept step before and after a rebuild.""" + + step: Step + before: dict[str, Any] + after: dict[str, Any] + + def apply(self, values: dict[str, Any]): + for key, value in values.items(): + setattr(self.step, key, value) + + +def _reuse_steps( + generator: CalibrationGenerator, old: list[Step], built: BuiltTest +) -> list[StepChange]: + """ + For a generator that keeps its steps, puts the old steps in place of + the new ones and returns the settings to change on them. The steps + are kept only when they match the new ones one to one. + """ + if not generator.keeps_steps or len(old) != len(built.steps): + return [] + if any(type(a) is not type(b) for a, b in zip(old, built.steps)): + return [] + changes = [] + for index, (kept, fresh) in enumerate(zip(old, built.steps)): + after = { + **generator.step_settings(fresh), + "workpiece_uids": list(fresh.workpiece_uids), + } + before = {key: getattr(kept, key) for key in after} + changes.append(StepChange(kept, before, after)) + built.steps[index] = kept + return changes + + @dataclass class GeneratedItems: """The generated items of a test and the state they belong to.""" @@ -246,34 +287,38 @@ def __init__( editor: "DocEditor", layer: Layer, record: CalibrationRecord, - old: GeneratedItems, - new: GeneratedItems, + items: tuple[GeneratedItems, GeneratedItems], + changes: list[StepChange], name: str, ): super().__init__(name) self._editor = editor self._layer = layer self._record = record - self._old = old - self._new = new - self._parents = {wp.uid: wp.parent for wp in old.workpieces} + self._old, self._new = items + self._changes = changes + self._parents = {wp.uid: wp.parent for wp in self._old.workpieces} def execute(self) -> None: - self._swap(self._old, self._new) + self._swap(self._old, self._new, forward=True) def undo(self) -> None: - self._swap(self._new, self._old) + self._swap(self._new, self._old, forward=False) - def _swap(self, out: GeneratedItems, into: GeneratedItems): + def _swap(self, out: GeneratedItems, into: GeneratedItems, forward: bool): doc = self._editor.doc with self._editor.pipeline.paused(): + self._record.set_state(into.state) + for change in self._changes: + change.apply(change.after if forward else change.before) self._swap_workpieces(out.workpieces, into.workpieces) self._swap_steps(out.steps, into.steps) for asset in into.assets: doc.add_asset(asset, silent=True) for asset in out.assets: doc.remove_asset_by_uid(asset.uid, silent=True) - self._record.set_state(into.state) + for change in self._changes: + change.step.updated.send(change.step) doc.updated.send(doc) def _swap_workpieces(self, out: list[WorkPiece], into: list[WorkPiece]): diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py new file mode 100644 index 000000000..5e3c17be7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py @@ -0,0 +1,133 @@ +""" +Keeps state that follows from the document in step with it, without +undo entries of its own: undoing the change that caused it brings the +document back and this follows again. + +- The preview of a Material Test Grid follows its step, so a recipe + applied to the step resizes the grid. +- The record and the hidden source assets of a test leave the document + with the test's layer and come back when the layer does. +""" + +import json +import logging +from typing import TYPE_CHECKING, Any, cast + +from rayforge.core.asset import IAsset +from rayforge.core.item import DocItem +from rayforge.core.layer import Layer +from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece + +from ..calibration.material_test_grid import ( + MaterialTestGridGenerator, + params_from_step, +) +from ..calibration.record import CalibrationRecord +from ..material_test_helpers import get_material_test_proportional_size + +if TYPE_CHECKING: + from rayforge.core.doc import Doc + from rayforge.doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +class CalibrationSync: + """ + Registered with the command registry so it lives as long as the + editor and keeps its signal connections. + """ + + def __init__(self, editor: "DocEditor"): + self._editor = editor + self._doc: Doc = editor.doc + self._parked: dict[str, list[IAsset]] = {} + self._connect() + editor.document_changed.connect(self._on_document_changed) + + def _connect(self): + self._doc.descendant_updated.connect(self._on_updated) + self._doc.descendant_removed.connect(self._on_removed) + self._doc.descendant_added.connect(self._on_added) + + def _on_document_changed(self, sender): + self._doc.descendant_updated.disconnect(self._on_updated) + self._doc.descendant_removed.disconnect(self._on_removed) + self._doc.descendant_added.disconnect(self._on_added) + self._doc = self._editor.doc + self._parked.clear() + self._connect() + + def _on_updated(self, sender, *, origin: DocItem, **kwargs): + if ( + isinstance(origin, Step) + and origin.ASSEMBLER_NAME == "material_test_grid" + ): + sync_grid_preview(self._doc, origin) + + def _on_removed(self, sender, *, origin: DocItem, **kwargs): + if not isinstance(origin, Layer): + return + assets = [ + asset + for record in _records(self._doc, origin.uid) + for asset in [record, *_assets(self._doc, record)] + ] + if not assets: + return + self._parked[origin.uid] = assets + for asset in assets: + self._doc.remove_asset_by_uid(asset.uid, silent=True) + self._doc.updated.send(self._doc) + + def _on_added(self, sender, *, origin: DocItem, **kwargs): + if not isinstance(origin, Layer): + return + assets = self._parked.pop(origin.uid, []) + for asset in assets: + self._doc.add_asset(asset, silent=True) + if assets: + self._doc.updated.send(self._doc) + + +def _records(doc: "Doc", layer_uid: str) -> list[CalibrationRecord]: + return [ + cast(CalibrationRecord, asset) + for asset in doc.get_all_assets() + if asset.asset_type_name == CalibrationRecord.asset_type_name + and cast(CalibrationRecord, asset).layer_uid == layer_uid + ] + + +def _assets(doc: "Doc", record: CalibrationRecord) -> list[IAsset]: + found = (doc.get_asset_by_uid(uid) for uid in record.state.asset_uids) + return [asset for asset in found if asset is not None] + + +def sync_grid_preview(doc: "Doc", step: Step): + """ + Redraws and resizes the grid preview when its step's grid settings + differ from the ones it was drawn with. + """ + if not step.workpiece_uids: + return + wp = doc.find_descendant_by_uid(step.workpiece_uids[0]) + source = wp.source if isinstance(wp, WorkPiece) else None + if source is None or source.original_data is None: + return + try: + recipe: dict[str, Any] = json.loads(source.original_data) + drawn = MaterialTestGridGenerator.grid_params(recipe["params"]) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + logger.warning("Material Test Grid preview has no grid settings.") + return + wanted = MaterialTestGridGenerator.grid_params(params_from_step(step)) + if wanted == drawn: + return + recipe["params"] = wanted + source.original_data = json.dumps(recipe).encode("utf-8") + assert isinstance(wp, WorkPiece) + wp.clear_render_cache() + wp.set_size(*get_material_test_proportional_size(wanted)) + wp.updated.send(wp) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py index 811aa6dfa..e104ed95c 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -7,7 +7,7 @@ from gettext import gettext as _ from pathlib import Path -from gi.repository import Gio, GLib +from gi.repository import Gio from rayforge.core.hooks import hookimpl from rayforge.ui_gtk.action_registry import MenuPlacement @@ -16,7 +16,7 @@ ) from rayforge.ui_gtk.icons import register_icon_path -from .commands import CalibrationCmd +from .commands import CalibrationCmd, CalibrationSync from .widgets import ASSEMBLER_WIDGETS from .widgets.calibration_provider import ( CalibrationPropertyProvider, @@ -27,7 +27,6 @@ _ICONS_DIR = Path(__file__).parent / "resources" / "icons" register_icon_path(_ICONS_DIR) -property_provider_registry.register(CalibrationPropertyProvider, ADDON_NAME) @hookimpl @@ -41,8 +40,16 @@ def register_step_settings_pages(step_settings_page_registry): @hookimpl def register_commands(command_registry): - """Register editor command handlers.""" + """ + Register editor command handlers, and the Test Settings they are + edited with. The main window creates its property panels after the + commands are registered. + """ command_registry.register("calibration", CalibrationCmd, ADDON_NAME) + property_provider_registry.register( + CalibrationPropertyProvider, ADDON_NAME + ) + command_registry.register("calibration_sync", CalibrationSync, ADDON_NAME) def _create_test(action_registry, generator_id: str): @@ -54,7 +61,7 @@ def _create_test(action_registry, generator_id: str): except ValueError as e: editor.notification_requested.send(editor, message=str(e)) return - GLib.idle_add(show_new_test, window, cmd.workpieces(record)) + show_new_test(window, cmd.workpieces(record)) @hookimpl diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py index a0ea9a5c2..c9dbbc1e7 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py @@ -317,7 +317,7 @@ def test_counts_are_whole_numbers(self): params = self.generator.defaults(self._context()) params["grid_dimensions"] = [3.0, 4.0] params["passes_range"] = (1.0, 5.0) - grid = self.generator._grid_params(params) + grid = self.generator.grid_params(params) assert grid["grid_dimensions"] == (3, 4) assert grid["passes_range"] == (1, 5) assert all(isinstance(v, int) for v in grid["passes_range"]) @@ -326,7 +326,7 @@ def test_rejects_too_small_grid(self): params = self.generator.defaults(self._context()) params["grid_dimensions"] = (1, 4) with pytest.raises(ValueError): - self.generator._grid_params(params) + self.generator.grid_params(params) class TestHelpers: diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index 7fdd1ffc1..b3a4debce 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -8,12 +8,14 @@ import re from itertools import pairwise from pathlib import Path +from types import SimpleNamespace import pytest import pytest_asyncio from rayforge.context import get_context from rayforge.core.group import Group +from rayforge.core.undo import ChangePropertyCommand from rayforge.core.vectorization_spec import ProceduralSpec from rayforge.core.workpiece import WorkPiece from rayforge.image.procedural import ProceduralImporter @@ -28,6 +30,13 @@ _command = importlib.import_module("laser_essentials.commands.calibration_cmd") FocusMode = _calibration.FocusMode CalibrationCmd = _command.CalibrationCmd +_sync = importlib.import_module("laser_essentials.commands.calibration_sync") +CalibrationSync = _sync.CalibrationSync +sync_grid_preview = _sync.sync_grid_preview + + +def _steps_module(): + return importlib.import_module("laser_essentials.steps") @pytest.fixture @@ -400,6 +409,105 @@ async def test_record_survives_save_and_load(doc_editor, machine): assert CalibrationCmd.record_for_items(items) is copy +@pytest.mark.asyncio +async def test_deleting_the_layer_removes_the_record(doc_editor, machine): + """The record and hidden assets of a test leave with its layer, are + not saved, and come back when the deletion is undone.""" + sync = CalibrationSync(doc_editor) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + owned = {record.uid, *record.state.asset_uids} + assert owned <= set(doc_editor.doc.assets) + + doc_editor.layer.delete_layer(_layer(doc_editor, record)) + + assert owned.isdisjoint(doc_editor.doc.assets) + saved = doc_editor.doc.to_dict()["assets"] + assert owned.isdisjoint(a["uid"] for a in saved) + + doc_editor.history_manager.undo() + assert owned <= set(doc_editor.doc.assets) + workpieces = cmd.workpieces(record) + assert cmd.record_for_items(workpieces) is record + doc_editor.history_manager.redo() + assert owned.isdisjoint(doc_editor.doc.assets) + assert sync is not None + + +@pytest.mark.asyncio +async def test_undoing_a_new_test_leaves_nothing(doc_editor, machine): + sync = CalibrationSync(doc_editor) + assets = set(doc_editor.doc.assets) + record = CalibrationCmd(doc_editor).create("focus_test") + doc_editor.history_manager.undo() + assert set(doc_editor.doc.assets) == assets + doc_editor.history_manager.redo() + assert record.uid in doc_editor.doc.assets + assert set(record.state.asset_uids) <= set(doc_editor.doc.assets) + assert sync is not None + + +@pytest.mark.asyncio +async def test_sync_follows_a_new_document(doc_editor, machine): + sync = CalibrationSync(doc_editor) + old_doc = doc_editor.doc + doc_editor.set_doc(type(old_doc)()) + record = CalibrationCmd(doc_editor).create("interval_test") + doc_editor.layer.delete_layer(_layer(doc_editor, record)) + assert record.uid not in doc_editor.doc.assets + assert sync is not None + + +@pytest.mark.asyncio +async def test_sync_ignores_other_changes(doc_editor, machine): + """Removing a layer without a test, or a workpiece, leaves the assets + alone; a grid step without a preview is not touched.""" + sync = CalibrationSync(doc_editor) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + assets = set(doc_editor.doc.assets) + (wp,) = cmd.workpieces(record) + other = doc_editor.doc.layers[0] + + doc_editor.layer.delete_layer(other) + _layer(doc_editor, record).remove_child(wp) + assert set(doc_editor.doc.assets) == assets + + (step,) = _steps(_layer(doc_editor, record)) + step.grid_dimensions = (3, 3) + step.updated.send(step) + step.workpiece_uids = [] + step.updated.send(step) + assert sync is not None + + +@pytest.mark.asyncio +async def test_grid_preview_with_broken_settings_is_left_alone( + doc_editor, machine +): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + (wp,) = cmd.workpieces(record) + (step,) = _steps(_layer(doc_editor, record)) + wp.source.original_data = b"not json" + step.grid_dimensions = (3, 3) + sync_grid_preview(doc_editor.doc, step) + assert wp.size == pytest.approx((73.0, 73.0)) + + +@pytest.mark.asyncio +async def test_steps_are_replaced_when_they_do_not_match(doc_editor, machine): + reuse = _command._reuse_steps + generator = CalibrationCmd.generator("material_test_grid") + contour = importlib.import_module("laser_essentials.steps").ContourStep() + built = SimpleNamespace(steps=[_steps_module().MaterialTestStep()]) + assert reuse(generator, [contour], built) == [] + assert reuse(generator, [], built) == [] + interval = CalibrationCmd.generator("interval_test") + assert interval.step_settings(contour) == {} + assert generator.params_from_steps({"a": 1}, []) == {"a": 1} + + @pytest.mark.asyncio async def test_record_for_items(doc_editor, machine): cmd = CalibrationCmd(doc_editor) @@ -507,6 +615,74 @@ async def test_material_test_grid_gcode_matches_the_old_grid( assert generated.splitlines() == old.splitlines() +def _apply_recipe_values(editor, step, values): + """Change step settings the way the recipe control does.""" + with editor.history_manager.transaction("Apply Recipe") as t: + for key, value in values.items(): + t.execute( + ChangePropertyCommand( + target=step, + property_name=key, + new_value=value, + on_change_callback=lambda: step.updated.send(step), + ) + ) + + +@pytest.mark.asyncio +async def test_material_test_grid_follows_a_recipe(doc_editor, machine): + """A recipe applied to the grid's step resizes the preview, shows in + the Test Settings, and stays when another setting changes.""" + sync = CalibrationSync(doc_editor) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + (step,) = _steps(_layer(doc_editor, record)) + (wp,) = cmd.workpieces(record) + assert wp.size == pytest.approx((73.0, 73.0)) + + _apply_recipe_values(doc_editor, step, {"grid_dimensions": [6, 6]}) + + (wp,) = cmd.workpieces(record) + assert wp.size == pytest.approx((6 * 10 + 5 * 2 + 15,) * 2) + params = cmd.params(record) + assert tuple(params["grid_dimensions"]) == (6, 6) + + cmd.update_params(record, {**params, "spacing": 3.0}) + assert tuple(step.grid_dimensions) == (6, 6) + (wp,) = cmd.workpieces(record) + assert wp.size == pytest.approx((6 * 10 + 5 * 3 + 15,) * 2) + + doc_editor.history_manager.undo() + doc_editor.history_manager.undo() + (wp,) = cmd.workpieces(record) + assert tuple(step.grid_dimensions) == (5, 5) + assert wp.size == pytest.approx((73.0, 73.0)) + assert sync is not None + + +@pytest.mark.asyncio +async def test_material_test_grid_keeps_its_operation(doc_editor, machine): + """Changing the grid keeps the step and what was set in its own + settings, such as air assist.""" + cmd = CalibrationCmd(doc_editor) + record = cmd.create("material_test_grid") + layer = _layer(doc_editor, record) + (step,) = _steps(layer) + step.air_assist = True + + cmd.update_params(record, {**cmd.params(record), "shape_size": 8.0}) + + assert _steps(layer) == [step] + assert step.air_assist is True + assert step.shape_size == 8.0 + (wp,) = cmd.workpieces(record) + assert step.workpiece_uids == [wp.uid] + doc_editor.history_manager.undo() + assert step.shape_size == 10.0 + (old_wp,) = cmd.workpieces(record) + assert step.workpiece_uids == [old_wp.uid] + + @pytest.mark.asyncio async def test_legacy_material_test_grid_gets_a_record(doc_editor, machine): """A grid from an older version (step and workpiece on an ordinary From 5667e86c9bb6ab9761204e9dd03a62f633a26f8d Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:24:22 +0200 Subject: [PATCH 22/28] fix(laser): keep a Focus Test's method and scroll without timers A Focus Test made with Z axis steps showed Manual on a machine without a Z axis, and any other change rebuilt it as Manual. The Test Settings now apply only the settings that were changed, list the test's own method even when the machine lacks it, and say that it is not available there. The panel no longer waits on timeouts: a rebuilt test is selected again right away, and a new test's settings are scrolled into view each time the panel's size changes until a setting is edited or another test is selected. Refs #519 --- .../calibration/focus_test.py | 28 +++- .../widgets/calibration_provider.py | 156 +++++++++++------- .../tests/ui_gtk/test_calibration_provider.py | 30 +++- .../test_calibration_test_settings.py | 9 + 4 files changed, 157 insertions(+), 66 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py index 1c9fa3b91..cce79a67d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py @@ -204,7 +204,32 @@ def title(self) -> str: return _("Focus Test") def varset(self, context: "RayforgeContext") -> VarSet: + return self._varset(context, available_modes(context)) + + def settings_varset( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> VarSet: + """Also lists the test's own method when this machine lacks it.""" modes = available_modes(context) + mode = FocusMode(params["mode"]) + if mode not in modes: + modes.append(mode) + return self._varset(context, modes) + + def warning( + self, params: dict[str, Any], context: "RayforgeContext" + ) -> str: + mode = FocusMode(params["mode"]) + if mode in available_modes(context): + return "" + return _( + "The method {method} is not available on this machine. Choose " + "another method to rebuild the test." + ).format(method=mode.label) + + def _varset( + self, context: "RayforgeContext", modes: list[FocusMode] + ) -> VarSet: limit = MAX_FOCUS_OFFSET_MM return VarSet( vars=[ @@ -270,9 +295,6 @@ def varset(self, context: "RayforgeContext") -> VarSet: ] ) - def is_available(self, context: "RayforgeContext") -> bool: - return context.machine is not None - def help_text(self, params: dict[str, Any]) -> str: return FocusMode(params["mode"]).help_text diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py index d9702e80c..62053f021 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -32,8 +32,6 @@ logger = logging.getLogger(__name__) DEBOUNCE_DELAY_MS = 300 -#: Time the item properties need to slide in before they can be scrolled. -REVEAL_DELAY_MS = 400 class CalibrationPropertyProvider(PropertyProvider): @@ -47,31 +45,21 @@ def can_handle(self, items: list[DocItem]) -> bool: return record_for_items(items) is not None def create_widgets(self) -> list[Gtk.Widget]: - self.help_row = Adw.ActionRow(activatable=False) - self.help_row.set_title_lines(0) - self.help_row.add_css_class("dim-label") + self.help_row = _text_row("dim-label") self.varset_widget = VarSetRowList() self.varset_widget.data_changed.connect(self._on_data_changed) - self.error_row = Adw.ActionRow(activatable=False) - self.error_row.set_title_lines(0) - self.error_row.add_css_class("error") - self.error_row.set_visible(False) - self.note_row = Adw.ActionRow( - title=_( - "Changing a setting rebuilds the test and replaces changes " - "made to its operations." - ), - activatable=False, - ) - self.note_row.set_title_lines(0) - self.note_row.add_css_class("dim-label") + self.warning_row = _text_row("warning") + self.error_row = _text_row("error") + self.note_row = _text_row("dim-label") self._record: CalibrationRecord | None = None self._generator_id: str | None = None self._pending_keys: set[str] = set() self._timer_id = 0 + self._scroll_handlers: list[tuple[Gtk.Adjustment, int]] = [] return [ self.help_row, + self.warning_row, self.error_row, self.varset_widget, self.note_row, @@ -88,35 +76,68 @@ def update_widgets(self, editor: "DocEditor", items: list[DocItem]): try: self.editor = editor self.items = items + previous = self._record self._record = record_for_items(items) + if self._record is not previous: + self.stop_following() generator = self.generator if self._record is None or generator is None: return if generator.id != self._generator_id: self.varset_widget.clear_dynamic_rows() self._generator_id = generator.id - self.varset_widget.populate(generator.varset(editor.context)) - self._show_params(self._record.params) + params = CalibrationCmd(editor).params(self._record) + self.varset_widget.populate( + generator.settings_varset(params, editor.context) + ) + self._show_params(params) self.error_row.set_visible(False) + self.note_row.set_title(_rebuild_note(generator)) self.group_subtitle = self._record.name or generator.title finally: self._in_update = False - def scroll_into_view(self) -> bool: + def scroll_into_view(self): """ Scrolls the panel so the Test Settings card starts at its top. The card comes after the workflows and the item properties, so - it is often below the visible part of the panel. + it is often below the visible part of the panel. While the panel + is still being laid out, the card is followed each time the + panel's size changes, until a setting is edited or another test + is selected. """ - card = self.varset_widget.get_ancestor(Expander) scrolled = self._scrolled_window() - content = scrolled.get_child() if scrolled else None - if card is None or content is None: - return GLib.SOURCE_REMOVE + if scrolled is None: + return + self._follow_card(scrolled.get_vadjustment()) + if not self._scroll_handlers: + adjustment = scrolled.get_vadjustment() + self._scroll_handlers = [ + (adjustment, adjustment.connect("changed", self._follow_card)) + ] + + def _follow_card(self, adjustment: Gtk.Adjustment): + scrolled = self._scrolled_window() + offset = self._card_offset(scrolled) if scrolled else None + if offset is not None: + self._scroll_to(offset) + + def stop_following(self): + """Leaves the scroll position to the user.""" + for adjustment, handler in self._scroll_handlers: + adjustment.disconnect(handler) + self._scroll_handlers = [] + + def _card_offset(self, scrolled: Gtk.ScrolledWindow) -> float | None: + """Where the card starts in the panel, once it is laid out.""" + card = self.varset_widget.get_ancestor(Expander) + content = scrolled.get_child() + if isinstance(content, Gtk.Viewport): + content = content.get_child() + if card is None or content is None or card.get_height() == 0: + return None ok, point = card.compute_point(content, Graphene.Point()) - if ok: - self._scroll_to(point.y) - return GLib.SOURCE_REMOVE + return point.y if ok else None def _scrolled_window(self) -> Gtk.ScrolledWindow | None: """The scrolled right panel the settings are shown in.""" @@ -127,13 +148,12 @@ def _scroll_offset(self) -> float: scrolled = self._scrolled_window() return scrolled.get_vadjustment().get_value() if scrolled else 0.0 - def _scroll_to(self, offset: float) -> bool: + def _scroll_to(self, offset: float): scrolled = self._scrolled_window() if scrolled is not None: adjustment = scrolled.get_vadjustment() top = adjustment.get_upper() - adjustment.get_page_size() adjustment.set_value(max(0.0, min(offset, top))) - return GLib.SOURCE_REMOVE def _show_params(self, params: dict[str, Any]): generator = self.generator @@ -147,9 +167,9 @@ def _show_params(self, params: dict[str, Any]): if adapter is not None: adapter.set_item_labels(labels) adapter.set_item_subtitles(subtitles) - help_text = generator.help_text(params) - self.help_row.set_title(GLib.markup_escape_text(help_text)) - self.help_row.set_visible(bool(help_text)) + _show_text(self.help_row, generator.help_text(params)) + warning = generator.warning(params, self.editor.context) + _show_text(self.warning_row, warning) def _on_data_changed(self, sender: VarSetRowList, key: str): if self._in_update or self._record is None: @@ -171,11 +191,14 @@ def apply_now(self): self._timer_id = 0 keys = set(self._pending_keys) self._pending_keys.clear() + self.stop_following() record, generator = self._record, self.generator if not keys or record is None or generator is None: return - params = record.params - params.update(self.varset_widget.get_values()) + cmd = CalibrationCmd(self.editor) + values = self.varset_widget.get_values() + params = cmd.params(record) + params.update({key: values[key] for key in keys if key in values}) params = generator.apply_change(params, keys, self.editor.context) self._in_update = True try: @@ -184,45 +207,54 @@ def apply_now(self): self._in_update = False offset = self._scroll_offset() try: - workpieces = CalibrationCmd(self.editor).update_params( - record, params - ) + workpieces = cmd.update_params(record, params) except ValueError as e: - self.error_row.set_title(GLib.markup_escape_text(str(e))) - self.error_row.set_visible(True) + _show_text(self.error_row, str(e)) return self.error_row.set_visible(False) self._reselect(workpieces, offset) def _reselect(self, workpieces: list[WorkPiece], offset: float): """ - Selects the rebuilt test and puts the panel back where it was: - the old workpieces are gone, so the selection was cleared and the - item properties slid out. + Selects the rebuilt test and puts the panel back where it was. + The old workpieces are gone, so the selection was cleared; it is + restored before the panel is drawn again. """ root = self.varset_widget.get_root() if not isinstance(root, MainWindow): return - GLib.idle_add(select_items, root, workpieces) - GLib.timeout_add(REVEAL_DELAY_MS, self._scroll_to, offset) + root.surface.select_items(workpieces) + self._scroll_to(offset) -def select_items(window: MainWindow, items: list[WorkPiece]) -> bool: - """ - Selects a rebuilt test, so its settings stay in the panel. Runs on - idle, once the canvas has elements for the new workpieces. - """ +def show_new_test(window: MainWindow, items: list[WorkPiece]): + """Selects a new test and scrolls its settings into view.""" window.surface.select_items(items) - return GLib.SOURCE_REMOVE - - -def show_new_test(window: MainWindow, items: list[WorkPiece]) -> bool: - """ - Selects a new test and scrolls its settings into view once the - item properties have slid in. - """ - select_items(window, items) for provider in window.item_props_widget.providers: if isinstance(provider, CalibrationPropertyProvider): - GLib.timeout_add(REVEAL_DELAY_MS, provider.scroll_into_view) - return GLib.SOURCE_REMOVE + provider.scroll_into_view() + + +def _text_row(css_class: str) -> Adw.ActionRow: + row = Adw.ActionRow(activatable=False) + row.set_title_lines(0) + row.add_css_class(css_class) + row.set_visible(False) + return row + + +def _show_text(row: Adw.ActionRow, text: str): + row.set_title(GLib.markup_escape_text(text)) + row.set_visible(bool(text)) + + +def _rebuild_note(generator: CalibrationGenerator) -> str: + if generator.keeps_steps: + return _( + "Changing a setting rebuilds the test. The other settings of " + "its operation stay." + ) + return _( + "Changing a setting rebuilds the test and replaces changes made " + "to its operations." + ) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py index d67e98286..3237751eb 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py @@ -1,6 +1,7 @@ # flake8: noqa: E402 """UI tests for the Test Settings of the calibration tests.""" +from dataclasses import replace from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock @@ -295,7 +296,8 @@ def test_without_a_test_selected(provider, editor, cmd): provider.update_widgets(editor, [other]) assert provider.generator is None provider.apply_now() - assert provider.scroll_into_view() is False + provider.scroll_into_view() + assert provider._scroll_handlers == [] @pytest.mark.ui @@ -333,3 +335,29 @@ def test_changes_while_updating_are_ignored(provider, editor, cmd): provider._in_update = False assert provider._timer_id == 0 assert not provider._pending_keys + + +@pytest.mark.ui +def test_unavailable_method_is_kept_and_explained( + provider, editor, cmd, laser_machine, monkeypatch +): + """A Focus Test saved on a machine with a Z axis keeps its method on + a machine without one: the panel says so, and an unrelated change + does not switch the method.""" + monkeypatch.setattr( + type(laser_machine), "has_z_axis", property(lambda self: False) + ) + record = cmd.create("focus_test", {"mode": "ramp"}) + record.set_state( + replace(record.state, params={**record.params, "mode": "z_axis"}) + ) + provider.update_widgets(editor, cmd.workpieces(record)) + + assert provider.varset_widget.get_values()["mode"] == "z_axis" + assert provider.warning_row.get_visible() + assert "not available" in provider.warning_row.get_title() + + _set(provider, "ramp_length", 50.0) + provider.apply_now() + assert record.params["mode"] == "z_axis" + assert provider.error_row.get_visible() diff --git a/tests/ui_gtk/doceditor/test_calibration_test_settings.py b/tests/ui_gtk/doceditor/test_calibration_test_settings.py index 6abd88a64..3e05d2282 100644 --- a/tests/ui_gtk/doceditor/test_calibration_test_settings.py +++ b/tests/ui_gtk/doceditor/test_calibration_test_settings.py @@ -113,6 +113,15 @@ def test_create_shows_settings_in_the_panel(app_and_window, action): assert expander.get_visible() assert provider.varset_widget.keys() assert _windows(app, win) == [] + scrolled = provider._scrolled_window() + offset = provider._card_offset(scrolled) + adjustment = scrolled.get_vadjustment() + top = adjustment.get_upper() - adjustment.get_page_size() + assert adjustment.get_value() == pytest.approx(min(offset, top), abs=1) + + assert provider._scroll_handlers + provider.stop_following() + assert provider._scroll_handlers == [] @pytest.mark.ui From 9a61fed4f50cfc923abeff76e9b9fb96c5561cfc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:48:52 +0200 Subject: [PATCH 23/28] fix(laser): keep assets a moved cell uses when the test layer goes Deleting a test's layer took all of its source assets out of the document, also the one of a cell the user had moved to another layer, which then lost its source. The layer sync now uses the same in-use check as a rebuild. Refs #519 --- .../laser_essentials/calibration/lookup.py | 24 +++++++++++++++++++ .../commands/calibration_cmd.py | 21 ++-------------- .../commands/calibration_sync.py | 8 ++----- tests/doceditor/test_calibration_tests.py | 21 ++++++++++++++++ 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py index e9bec15c8..7188f2a48 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py @@ -1,5 +1,6 @@ """Finds the calibration test that selected workpieces belong to.""" +from collections.abc import Iterable from typing import TYPE_CHECKING, cast from rayforge.core.item import DocItem @@ -14,6 +15,7 @@ from .record import CalibrationRecord, CalibrationState if TYPE_CHECKING: + from rayforge.core.asset import IAsset from rayforge.core.doc import Doc @@ -85,3 +87,25 @@ def _record_from_step( state=state, name=step.name, ) + + +def unused_assets( + doc: "Doc", record: CalibrationRecord, removed: Iterable[str] = () +) -> list["IAsset"]: + """ + The record's assets that no workpiece in the document uses, leaving + out the workpieces with uids in *removed*. A cell the user moved to + another layer keeps its asset. + """ + gone = set(removed) + in_use = { + wp.source_segment.source_asset_uid + for wp in doc.all_workpieces + if wp.uid not in gone and wp.source_segment is not None + } + found = ( + doc.get_asset_by_uid(uid) + for uid in record.state.asset_uids + if uid not in in_use + ) + return [asset for asset in found if asset is not None] diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py index 30a25a8b7..6930749ef 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py @@ -26,7 +26,7 @@ from ..calibration import GENERATORS, CalibrationGenerator from ..calibration.builder import BuiltTest, CalibrationBuilder, bounds_of -from ..calibration.lookup import record_for_items +from ..calibration.lookup import record_for_items, unused_assets from ..calibration.record import CalibrationRecord, CalibrationState if TYPE_CHECKING: @@ -131,7 +131,7 @@ def update_params( old = GeneratedItems( old_wps, old_steps, - self._unused_assets(record, old_wps), + unused_assets(self._doc, record, (wp.uid for wp in old_wps)), record.state, ) new = GeneratedItems( @@ -190,23 +190,6 @@ def _place(self, built: BuiltTest, old_wps: list[WorkPiece]): if dims: built.keep_inside(*dims) - def _unused_assets( - self, record: CalibrationRecord, removed: list[WorkPiece] - ) -> list[IAsset]: - """Assets of the old test that no remaining workpiece uses.""" - removed_uids = {wp.uid for wp in removed} - in_use = { - wp.source_segment.source_asset_uid - for wp in self._doc.all_workpieces - if wp.uid not in removed_uids and wp.source_segment is not None - } - found = ( - self._doc.get_asset_by_uid(uid) - for uid in record.state.asset_uids - if uid not in in_use - ) - return [asset for asset in found if asset is not None] - def _build( self, generator: CalibrationGenerator, params: dict[str, Any] ) -> BuiltTest: diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py index 5e3c17be7..38cb23950 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py @@ -19,6 +19,7 @@ from rayforge.core.step import Step from rayforge.core.workpiece import WorkPiece +from ..calibration.lookup import unused_assets from ..calibration.material_test_grid import ( MaterialTestGridGenerator, params_from_step, @@ -72,7 +73,7 @@ def _on_removed(self, sender, *, origin: DocItem, **kwargs): assets = [ asset for record in _records(self._doc, origin.uid) - for asset in [record, *_assets(self._doc, record)] + for asset in [record, *unused_assets(self._doc, record)] ] if not assets: return @@ -100,11 +101,6 @@ def _records(doc: "Doc", layer_uid: str) -> list[CalibrationRecord]: ] -def _assets(doc: "Doc", record: CalibrationRecord) -> list[IAsset]: - found = (doc.get_asset_by_uid(uid) for uid in record.state.asset_uids) - return [asset for asset in found if asset is not None] - - def sync_grid_preview(doc: "Doc", step: Step): """ Redraws and resizes the grid preview when its step's grid settings diff --git a/tests/doceditor/test_calibration_tests.py b/tests/doceditor/test_calibration_tests.py index b3a4debce..074496c9a 100644 --- a/tests/doceditor/test_calibration_tests.py +++ b/tests/doceditor/test_calibration_tests.py @@ -434,6 +434,27 @@ async def test_deleting_the_layer_removes_the_record(doc_editor, machine): assert sync is not None +@pytest.mark.asyncio +async def test_deleting_the_layer_keeps_assets_a_moved_cell_uses( + doc_editor, machine +): + sync = CalibrationSync(doc_editor) + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 2}) + moved = cmd.workpieces(record)[-1] + other = doc_editor.doc.layers[0] + other.add_child(moved) + + doc_editor.layer.delete_layer(_layer(doc_editor, record)) + + assert moved.source is not None + assert record.uid not in doc_editor.doc.assets + doc_editor.history_manager.undo() + assert record.uid in doc_editor.doc.assets + assert moved.source is not None + assert sync is not None + + @pytest.mark.asyncio async def test_undoing_a_new_test_leaves_nothing(doc_editor, machine): sync = CalibrationSync(doc_editor) From 36f1971e0c1b8ca110f5d0e45cac646290d3e2e3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:50:20 +0200 Subject: [PATCH 24/28] fix(laser): stop following the Test Settings card when it is hidden After a new test was deselected, or another item was selected, the panel kept scrolling to where the Test Settings card had been whenever its size changed. Following now stops as soon as the card is hidden. Refs #519 --- .../widgets/calibration_provider.py | 22 ++++++++++++------- .../test_calibration_test_settings.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py index 62053f021..67969016d 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -10,7 +10,7 @@ from gettext import gettext as _ from typing import TYPE_CHECKING, Any -from gi.repository import Adw, GLib, Graphene, Gtk +from gi.repository import Adw, GLib, GObject, Graphene, Gtk from rayforge.core.item import DocItem from rayforge.core.workpiece import WorkPiece @@ -56,7 +56,7 @@ def create_widgets(self) -> list[Gtk.Widget]: self._generator_id: str | None = None self._pending_keys: set[str] = set() self._timer_id = 0 - self._scroll_handlers: list[tuple[Gtk.Adjustment, int]] = [] + self._scroll_handlers: list[tuple[GObject.Object, int]] = [] return [ self.help_row, self.warning_row, @@ -103,17 +103,19 @@ def scroll_into_view(self): The card comes after the workflows and the item properties, so it is often below the visible part of the panel. While the panel is still being laid out, the card is followed each time the - panel's size changes, until a setting is edited or another test - is selected. + panel's size changes, until a setting is edited, another test is + selected or the card is hidden. """ scrolled = self._scrolled_window() - if scrolled is None: + card = self.varset_widget.get_ancestor(Expander) + if scrolled is None or card is None: return self._follow_card(scrolled.get_vadjustment()) if not self._scroll_handlers: adjustment = scrolled.get_vadjustment() self._scroll_handlers = [ - (adjustment, adjustment.connect("changed", self._follow_card)) + (adjustment, adjustment.connect("changed", self._follow_card)), + (card, card.connect("notify::visible", self._on_card_shown)), ] def _follow_card(self, adjustment: Gtk.Adjustment): @@ -122,10 +124,14 @@ def _follow_card(self, adjustment: Gtk.Adjustment): if offset is not None: self._scroll_to(offset) + def _on_card_shown(self, card: Gtk.Widget, _pspec): + if not card.get_visible(): + self.stop_following() + def stop_following(self): """Leaves the scroll position to the user.""" - for adjustment, handler in self._scroll_handlers: - adjustment.disconnect(handler) + for source, handler in self._scroll_handlers: + source.disconnect(handler) self._scroll_handlers = [] def _card_offset(self, scrolled: Gtk.ScrolledWindow) -> float | None: diff --git a/tests/ui_gtk/doceditor/test_calibration_test_settings.py b/tests/ui_gtk/doceditor/test_calibration_test_settings.py index 3e05d2282..180ecfc93 100644 --- a/tests/ui_gtk/doceditor/test_calibration_test_settings.py +++ b/tests/ui_gtk/doceditor/test_calibration_test_settings.py @@ -147,3 +147,25 @@ def test_change_rebuilds_and_keeps_the_selection(app_and_window): win.doc_editor.history_manager.undo() process_events_for_duration(0.5) assert len(layer.all_workpieces) == 6 + + +@pytest.mark.ui +@pytest.mark.parametrize("deselect", ["nothing", "other"]) +def test_scrolling_stops_when_the_test_is_deselected(app_and_window, deselect): + _app, win = app_and_window + win.lookup_action("interval_test").activate(None) + process_events_for_duration(1.5) + provider, _expander = _provider(win) + assert provider._scroll_handlers + + if deselect == "nothing": + win.surface.unselect_all() + else: + other = WorkPiece("Other") + other.set_size(5.0, 5.0) + win.doc_editor.doc.layers[0].add_child(other) + process_events_for_duration(0.3) + win.surface.select_items([other]) + process_events_for_duration(1.0) + + assert provider._scroll_handlers == [] From 9a578e3c5eace72a75185d0fd757ecc3d3f5f0fa Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:51:59 +0200 Subject: [PATCH 25/28] refactor(laser): register the Test Settings from the init hook Like the sketcher's parameters, the Test Settings now have their own register function. The frontend calls it from rayforge_init instead of register_commands. It still does not run on import, so a module imported under two names does not add a second panel. Refs #519 --- .../laser_essentials/frontend.py | 20 ++++++++----------- .../widgets/calibration_provider.py | 12 +++++++++++ .../tests/ui_gtk/test_calibration_provider.py | 18 +++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py index e104ed95c..c25072cf8 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/frontend.py @@ -11,15 +11,12 @@ from rayforge.core.hooks import hookimpl from rayforge.ui_gtk.action_registry import MenuPlacement -from rayforge.ui_gtk.doceditor.property_providers import ( - property_provider_registry, -) from rayforge.ui_gtk.icons import register_icon_path from .commands import CalibrationCmd, CalibrationSync from .widgets import ASSEMBLER_WIDGETS from .widgets.calibration_provider import ( - CalibrationPropertyProvider, + register_test_settings, show_new_test, ) @@ -38,17 +35,16 @@ def register_step_settings_pages(step_settings_page_registry): ) +@hookimpl +def rayforge_init(context): + """Register the Test Settings with the item properties panel.""" + register_test_settings(ADDON_NAME) + + @hookimpl def register_commands(command_registry): - """ - Register editor command handlers, and the Test Settings they are - edited with. The main window creates its property panels after the - commands are registered. - """ + """Register editor command handlers.""" command_registry.register("calibration", CalibrationCmd, ADDON_NAME) - property_provider_registry.register( - CalibrationPropertyProvider, ADDON_NAME - ) command_registry.register("calibration_sync", CalibrationSync, ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py index 67969016d..418fdf262 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -16,6 +16,7 @@ from rayforge.core.workpiece import WorkPiece from rayforge.ui_gtk.doceditor.property_providers.base import ( PropertyProvider, + property_provider_registry, ) from rayforge.ui_gtk.mainwindow import MainWindow from rayforge.ui_gtk.shared.expander import Expander @@ -233,6 +234,17 @@ def _reselect(self, workpieces: list[WorkPiece], offset: float): self._scroll_to(offset) +def register_test_settings(addon_name: str): + """ + Registers the Test Settings, like the sketcher registers its own + parameters. Called from the addon's init hook, not on import, so a + module imported under two names does not register a second copy. + """ + property_provider_registry.register( + CalibrationPropertyProvider, addon_name + ) + + def show_new_test(window: MainWindow, items: list[WorkPiece]): """Selects a new test and scrolls its settings into view.""" window.surface.select_items(items) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py index 3237751eb..47023927f 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py @@ -12,6 +12,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") +from laser_essentials import frontend from laser_essentials.commands import CalibrationCmd from laser_essentials.frontend import _create_test from laser_essentials.widgets.calibration_provider import ( @@ -19,6 +20,9 @@ ) from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.property_providers import ( + property_provider_registry, +) from rayforge.ui_gtk.shared.pref_rows import SpeedSpinRow @@ -361,3 +365,17 @@ def test_unavailable_method_is_kept_and_explained( provider.apply_now() assert record.params["mode"] == "z_axis" assert provider.error_row.get_visible() + + +@pytest.mark.ui +def test_provider_is_registered_once_at_init(): + """Importing the frontend registers nothing; the init hook registers + the Test Settings once, however often it runs.""" + registry = property_provider_registry + assert CalibrationPropertyProvider not in registry.all() + try: + frontend.rayforge_init(context=None) + frontend.rayforge_init(context=None) + assert registry.all().count(CalibrationPropertyProvider) == 1 + finally: + registry.unregister(CalibrationPropertyProvider) From 71eeeee4d8775aa19e510aafd175b2209e5a329a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 19:53:18 +0200 Subject: [PATCH 26/28] fix(laser): keep a rejected setting for the next change A value that made a test invalid stayed in its row but was dropped from the next rebuild, so fixing another setting quietly went back to the old value. It now stays pending until a rebuild takes it or another test is selected. Refs #519 --- .../widgets/calibration_provider.py | 2 ++ .../tests/ui_gtk/test_calibration_provider.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py index 418fdf262..8ce34f3d5 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -81,6 +81,7 @@ def update_widgets(self, editor: "DocEditor", items: list[DocItem]): self._record = record_for_items(items) if self._record is not previous: self.stop_following() + self._pending_keys.clear() generator = self.generator if self._record is None or generator is None: return @@ -217,6 +218,7 @@ def apply_now(self): workpieces = cmd.update_params(record, params) except ValueError as e: _show_text(self.error_row, str(e)) + self._pending_keys |= keys return self.error_row.set_visible(False) self._reselect(workpieces, offset) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py index 47023927f..1d4891a39 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py @@ -379,3 +379,20 @@ def test_provider_is_registered_once_at_init(): assert registry.all().count(CalibrationPropertyProvider) == 1 finally: registry.unregister(CalibrationPropertyProvider) + + +@pytest.mark.ui +def test_rejected_value_is_kept_for_the_next_change(provider, editor, cmd): + """A value that made the test invalid is not dropped when another + setting then makes it valid.""" + record = _show(provider, editor, cmd, "interval_test") + _set(provider, "min_interval", 0.3) + provider.apply_now() + assert provider.error_row.get_visible() + + _set(provider, "max_interval", 0.5) + provider.apply_now() + + assert not provider.error_row.get_visible() + assert record.params["min_interval"] == pytest.approx(0.3) + assert record.params["max_interval"] == pytest.approx(0.5) From 604e079b3270cd9cfa78ec251f2f29ead768059a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 20:30:44 +0200 Subject: [PATCH 27/28] fix(screenshot): open the Material Test Grid's own step on its layer The Workpieces tab target looked the grid's step up by its index in its layer, but opened the step at that index across all layers. With the grid on its own layer that was another step. It also moves one workpiece onto the grid's layer, so the tab shows a workpiece the grid's step leaves out, as the docs describe. Refs #519 --- scripts/screenshot/step_settings_workpieces.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/screenshot/step_settings_workpieces.py b/scripts/screenshot/step_settings_workpieces.py index 6ad642527..c3e1e4a63 100644 --- a/scripts/screenshot/step_settings_workpieces.py +++ b/scripts/screenshot/step_settings_workpieces.py @@ -5,6 +5,7 @@ from utils import ( find_step_by_type, + get_all_steps, get_target, load_project, open_step_settings, @@ -23,8 +24,13 @@ def _create_material_test() -> None: """Create the grid through the editor's own command so the step is - linked to its generated workpiece like in the real flow.""" + linked to its generated workpiece like in the real flow, and move + another workpiece onto the grid's layer, so the tab shows one + workpiece the grid's step leaves out.""" + doc = win.doc_editor.doc + other = doc.layers[0].all_workpieces[0] win.doc_editor.calibration.create("material_test_grid") + doc.active_layer.add_child(other) @restore_config @@ -43,7 +49,8 @@ def main(): app.quit_idle() return - step, step_index = found + step = found[0] + step_index = get_all_steps(win).index(step) logger.info("Found step: %s at index %s", step.name, step_index) open_step_settings(win, step_index=step_index, page="workpieces") From a27431405d0903cf30382b4c9b2bb60064d11597 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 20:30:44 +0200 Subject: [PATCH 28/28] docs: screenshots of the calibration tests with their Test Settings Regenerate material-test and the Material Test Grid's Workpieces tab, and add interval-test and focus-test to the Interval and Focus Tests page in every language. Refs #519 --- .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ .../operations/interval-and-focus-tests.md | 4 ++++ website/static/screenshots/focus-test.webp | Bin 0 -> 82656 bytes website/static/screenshots/interval-test.webp | Bin 0 -> 85416 bytes website/static/screenshots/material-test.webp | Bin 27578 -> 104304 bytes ...ettings-material-test-grid-workpieces.webp | Bin 11716 -> 11718 bytes 12 files changed, 32 insertions(+) create mode 100644 website/static/screenshots/focus-test.webp create mode 100644 website/static/screenshots/interval-test.webp diff --git a/website/docs/features/operations/interval-and-focus-tests.md b/website/docs/features/operations/interval-and-focus-tests.md index dae852bf4..be8e48cb3 100644 --- a/website/docs/features/operations/interval-and-focus-tests.md +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -21,6 +21,8 @@ hand, but a later change in the Test Settings rebuilds those operations. interval you enter, while power and speed stay the same for all of them. The labels under each square show the interval in millimeters and the matching line density in lines per inch (LPI). +![Interval Test](/screenshots/interval-test.webp) + Pick the square that is filled evenly without visible lines and without burning too deep, and use its interval for engravings on that material. The labels are cut with a separate low-power operation before the squares. @@ -30,6 +32,8 @@ before the squares. **Tools → Create Focus Test** finds the height at which the beam is sharpest. A positive offset means more distance between the head and the material. The thinnest line marks the best focus. +![Focus Test](/screenshots/focus-test.webp) + | Method | How the height changes | | -------------------------------- | ------------------------------------------------------------------------------------------------- | | **Z axis steps** | The head moves to each offset with relative Z moves and returns to its starting height at the end | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 8d053ffaa..122b35eec 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -24,6 +24,8 @@ bis zum größten eingegebenen Abstand, während Leistung und Geschwindigkeit f Die Beschriftungen unter jedem Quadrat zeigen den Abstand in Millimetern und die passende Liniendichte in Linien pro Zoll (LPI). +![Intervalltest](/screenshots/interval-test.webp) + Wähle das Quadrat, das gleichmäßig ohne sichtbare Linien und ohne zu tiefes Einbrennen gefüllt ist, und verwende seinen Abstand für Gravuren auf diesem Material. Die Beschriftungen werden vor den Quadraten mit einer eigenen Operation mit geringer Leistung geschnitten. @@ -34,6 +36,8 @@ Quadraten mit einer eigenen Operation mit geringer Leistung geschnitten. positiver Versatz bedeutet mehr Abstand zwischen Kopf und Material. Die dünnste Linie markiert den besten Fokus. +![Fokustest](/screenshots/focus-test.webp) + | Methode | Wie sich die Höhe ändert | | ----------------------------------- | ------------------------------------------------------------------------------------------------ | | **Z-Achsen-Schritte** | Der Kopf fährt mit relativen Z-Bewegungen zu jedem Versatz und am Ende zurück zur Ausgangshöhe | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 8a7f85862..eda3133d6 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -23,6 +23,8 @@ entre el intervalo menor y el mayor que indiques, mientras la potencia y la velo para todos. Las etiquetas bajo cada cuadrado muestran el intervalo en milímetros y la densidad de líneas correspondiente en líneas por pulgada (LPI). +![Prueba de intervalo](/screenshots/interval-test.webp) + Elige el cuadrado que esté relleno de forma uniforme, sin líneas visibles y sin quemarse demasiado, y usa su intervalo para grabar en ese material. Las etiquetas se cortan antes que los cuadrados con una operación propia de baja potencia. @@ -33,6 +35,8 @@ una operación propia de baja potencia. desplazamiento positivo significa más distancia entre el cabezal y el material. La línea más fina marca el mejor enfoque. +![Prueba de enfoque](/screenshots/focus-test.webp) + | Método | Cómo cambia la altura | | ------------------------------- | -------------------------------------------------------------------------------------------- | | **Pasos del eje Z** | El cabezal va a cada desplazamiento con movimientos Z relativos y vuelve a la altura inicial | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 13e494692..69b4973e9 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -24,6 +24,8 @@ plus grand intervalle saisi, tandis que la puissance et la vitesse restent les m étiquettes sous chaque carré indiquent l'intervalle en millimètres et la densité de lignes correspondante en lignes par pouce (LPI). +![Test d'intervalle](/screenshots/interval-test.webp) + Choisissez le carré rempli de façon homogène, sans lignes visibles et sans brûlure trop profonde, et utilisez son intervalle pour les gravures sur ce matériau. Les étiquettes sont découpées avant les carrés avec une opération séparée à faible puissance. @@ -34,6 +36,8 @@ carrés avec une opération séparée à faible puissance. net. Un décalage positif signifie plus de distance entre la tête et le matériau. La ligne la plus fine indique la meilleure mise au point. +![Test de mise au point](/screenshots/focus-test.webp) + | Méthode | Comment la hauteur change | | ----------------------------------- | ----------------------------------------------------------------------------------------------- | | **Pas de l'axe Z** | La tête rejoint chaque décalage par des mouvements Z relatifs et revient à la hauteur de départ | diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index eef4ff53a..a53c48c38 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -22,6 +22,8 @@ description: से फैला होता है, जबकि पावर और गति सभी के लिए समान रहती है। हर वर्ग के नीचे के लेबल मिलीमीटर में अंतराल और उससे मेल खाती रेखा घनत्व को लाइन प्रति इंच (LPI) में दिखाते हैं। +![इंटरवल टेस्ट](/screenshots/interval-test.webp) + वह वर्ग चुनें जो बिना दिखने वाली रेखाओं और बिना ज़्यादा गहरे जलाव के समान रूप से भरा हो, और उस सामग्री पर उकेरने के लिए उसका अंतराल इस्तेमाल करें। लेबल वर्गों से पहले कम पावर वाले अलग ऑपरेशन से काटे जाते हैं। @@ -31,6 +33,8 @@ description: **टूल्स → फ़ोकस टेस्ट बनाएँ** वह ऊँचाई खोजता है जिस पर बीम सबसे तीक्ष्ण होती है। धनात्मक ऑफ़सेट का अर्थ है हेड और सामग्री के बीच अधिक दूरी। सबसे पतली रेखा सबसे अच्छा फ़ोकस दिखाती है। +![फ़ोकस टेस्ट](/screenshots/focus-test.webp) + | विधि | ऊँचाई कैसे बदलती है | | -------------------------------- | ------------------------------------------------------------------------------------------ | | **Z अक्ष चरण** | हेड सापेक्ष Z गतियों से हर ऑफ़सेट पर जाता है और अंत में शुरुआती ऊँचाई पर लौटता है | diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 209f6c5d1..57b8704c2 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -23,6 +23,8 @@ menor ao maior intervalo informado, enquanto a potência e a velocidade são as rótulos abaixo de cada quadrado mostram o intervalo em milímetros e a densidade de linhas correspondente em linhas por polegada (LPI). +![Teste de intervalo](/screenshots/interval-test.webp) + Escolha o quadrado preenchido de forma uniforme, sem linhas visíveis e sem queimar demais, e use o seu intervalo para gravações nesse material. Os rótulos são cortados antes dos quadrados com uma operação separada de baixa potência. @@ -33,6 +35,8 @@ operação separada de baixa potência. deslocamento positivo significa mais distância entre a cabeça e o material. A linha mais fina marca o melhor foco. +![Teste de foco](/screenshots/focus-test.webp) + | Método | Como a altura muda | | ------------------------------- | --------------------------------------------------------------------------------------------- | | **Passos do eixo Z** | A cabeça vai a cada deslocamento com movimentos Z relativos e volta à altura inicial no final | diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index 077e613de..9f9e82f0f 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -23,6 +23,8 @@ Settings** на панелі властивостей праворуч. Кожн кожним квадратом показують інтервал у міліметрах і відповідну щільність ліній у лініях на дюйм (LPI). +![Тест інтервалу](/screenshots/interval-test.webp) + Виберіть квадрат, заповнений рівномірно, без видимих ліній і без надто глибокого пропалу, і використовуйте його інтервал для гравіювання на цьому матеріалі. Підписи вирізаються перед квадратами окремою операцією з низькою потужністю. @@ -32,6 +34,8 @@ Settings** на панелі властивостей праворуч. Кожн **Інструменти → Створити тест фокусування** знаходить висоту, на якій промінь найрізкіший. Додатне зміщення означає більшу відстань між голівкою і матеріалом. Найтонша лінія позначає найкращий фокус. +![Тест фокусування](/screenshots/focus-test.webp) + | Метод | Як змінюється висота | | ------------------------------ | ------------------------------------------------------------------------------------------ | | **Кроки осі Z** | Голівка переходить до кожного зміщення відносними рухами Z і наприкінці повертається назад | diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md index e26754fb5..a551a7064 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -10,12 +10,16 @@ description: "使用线间距测试和焦距测试,找到最适合雕刻的线 **工具 → 创建线间距测试**会雕刻一排填充方块。每个方块都有自己的**雕刻**操作和自己的线间距,从你输入的最小值到最大值均匀分布,而所有方块的功率和速度都相同。每个方块下方的标签显示以毫米为单位的线间距,以及对应的每英寸线数(LPI)。 +![线间距测试](/screenshots/interval-test.webp) + 选择填充均匀、看不到线条且没有烧得过深的方块,在该材料上雕刻时使用它的线间距。标签会在方块之前用单独的低功率操作切割。 ## 焦距测试 **工具 → 创建焦距测试**用于找到光束最锐利的高度。正偏移表示激光头与材料之间的距离更大。最细的线即为最佳焦点。 +![焦距测试](/screenshots/focus-test.webp) + | 方法 | 高度如何变化 | | ---------------------- | ------------------------------------------------------ | | **Z 轴步进** | 激光头通过相对 Z 移动到达每个偏移,最后回到起始高度 | diff --git a/website/static/screenshots/focus-test.webp b/website/static/screenshots/focus-test.webp new file mode 100644 index 0000000000000000000000000000000000000000..bdd421761d8dab2d2d93fb8b0c0a529464c629e5 GIT binary patch literal 82656 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 z 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 GuKy3Bb|aJk literal 0 HcmV?d00001 diff --git a/website/static/screenshots/interval-test.webp b/website/static/screenshots/interval-test.webp new file mode 100644 index 0000000000000000000000000000000000000000..df237feeab0f08afab52d7fa041220ca5cf2cd14 GIT binary patch literal 85416 zcmYIvV{|56uo*i29~|&Qk?EBI0?_}KEJIR78Emm7&c4s;{GO0p z-Ib*M2(#pL2>OzBFG@F z(8%l9o%xu)ud<+KpPr7?vLO*vcFvr%C|Gb!+sJ!7oRyB=|EzW+I&pYNra5i%TR9xr z&?on*njOIc50S&V*8|i!q)SS4QEO6Lz`zW9(k~yE9uFNNBEsAp2`R=btP=!u*1!7r z`!tk;9OIp zb8T5fW8}*~1ucRaX2?KZ3|EpfZf__D^=X5SnD>*(K#6J7+6ngsEe18yGFpb59bgj7 zF});;Cvf2Zx~>ND+~7C!N~8s{{;=vqzX)uXdE7>TRCJq zl|$37s|MZ;`YH9s5pTPFgYVcjHeg%V$P=4vxu1`#rrWpRub#IAIzcXrMmr;~^Tf?m zZEy(om(*;?lWnZITf|>nYc70q+Ke_@Y&Yae;yF{qc8OM1X*ar?{2q6JO*V`+*iP;n z08l~SetEFuPJsK1B(gKHjc+Zv2}C_RX3egbZJKl&iHWG3&k|r<;{&FFnF}6BWibC-8 zu`rX3ATx0=8=y9Hi{Pb{I?6L!pg)U1aPd?;q`Ub@DRpSOvQ_y1@{i5fNjikNSp+~} z!93(fVsv0uWsuUzRbg-xgIdW4Ty|w;D$sAyAb(~hS^4?BvkH}}FnsQovb2>?d zp=i8(@3tg#kd97)B+!s*EwQKz+eug!HmKuiU~=NnO6mqso!-AwjxRMAWmqF45KZ5nN%Rx%!Ij^+HEm{6vqxRI$+m4sh5 zl(&_}rD9RRz$sy^xdx^bJDHSnLK@}Y$fb0Q&b6f7^o)V3==B6J>hr7gRC7KUMlN(S zbe3?2_!iq!+n0aJ0k#{q=T+8_PA1N-Z=#N#cIxr@1z+DzvRE(WZJn{5o!r;gH3!UX zot>R3=3tlMa1JvM-_A3WUE=?GPx^6OHUcNnXP}RNEo5ISlwbD%xm((CJG_X-y8_t{ z{n-^MxPaf}wRR0B<%yhmB1}I{IKH0W!lRO9ZfY;Hn4@l5P58UC)^@wE&$aNr*IbPJ&A)-@SC~F0Vhcd75ftg?>Yreo5_!;|2)ZgO71G zr^9KCSfUD_NI8Gx~>g)i5cRtwed;ZOoTaPAD27sLB>NYqVYU0%DV`7vrWdxar4d{}H8gqQMA5@Y~#1m6VRp66LpQZrRk z6BAXJmo;aBN4kv6%!PN%%s$&{aF3S~X7B9s|7JMTHN~US)l#sAX=2mr$qS~Y2+hKB z>O8i>(j6kwr<-u7OH`Zkho+j0Qq6EFUp>_~&Hl0F*K0`qD6>{O&czhtMY1bQV>L26 zpHuNzw~0J)ht+UHjLTA?pl5 z9eLwL7|Xds)Q&1l(*#Aum_`{j%`$`@v&G2*Qdt)Pt4^s=#%5;68rXpH&j%64Kd{Uc zxRNFDrK)L~jD@k?&XB^>gjF~yX%KxirHFE2eN_iW%mJrU1uyVZc}uSB*XwTmPz)1( zZfWCs7-~pO)wc4en(gr}y+o=4*wEmAG6+$spsmMVZ3fpydwy?zjXkMBk=Wj~c7G%P zV3G|o_KALJbiF>XWl*FM7u$_`?V1sZS}Oj2w#k-iQ#A9nW2cFqH}=1?GUw$>Z%WnK z@5ZC|CEB~?%7@jc@+jwr^BwTFQ8UgKhre&eYFLcOt-)*W9ZGW1O^x{-@vi^W!(nN17JxY_72JJmK*w=_j>K`Ljp#kQs5DdruBwPB=cRZgvX)zUF; z)nbjZ$!OyZs^!Xd!@Je0sI~KXyHU;C`^Lz9!y93u-nOzOlici-l6q!TN*HVmJrxkbQ>{`XD0`W3zhfXK)7MIVOEP{65%DM zS{l&5@{wgn5_Ct(>gp|e~ z=H_3K4^(Q_qrOg|lPOP;fh`f|EICh%S2sM(D8}CsR>+ipkl9&c9aDEH<@z1t^2_vI z>m!;fBOORps>1?|eKP6)DlPH+0wHO*CWQ~M$|8M5_U$8PLaj<6h#Z58?6Que7IK9y zO*Vi4_i4Z0m!MXNUg0n3BZ;~ueNOiv3Q;!p!GkxEOoc=dAME)_jI2ti=uBP@I#g-ML`jKA0zj(lnvQ9Hz>O>- zaMEr2L~ zdS@BOe1`PojxflU@@bd}_T<5=0XP$QDmqkymUe7^W6K$xn66j5rB}jh3$rXW!~<|X2vR@8q!hN_0;Y*&dUNegBp?x-~07BImmOe17gh#_gaU1vI1qm?Et)#}gph@!01Y!vPNJTlK z=`0K*ynx-BpZNoK0UO;>4H{_$T*5&8hNuK+7`Hw-3IRURU4N~*aZbIC0Y58Xx6 z#4GUYOj>Ys|3dhNIUqc-=M#uONu5vX%o+)DV4#&>YUdbV=T*(yX=jhZH)R7n?=Mca z4eXa)eF25SxFCRx(|h#T`|P^n4Y7^2bTbSf-vM*POL4L zoQT$VD+AJumNoI=l(5Q4al5(@4dno(r7K~!?^rZT)E6yGAcxeIo3i_H)1Lnal%)`2 z%X>T*EOz&<_>bW{aivFhW1!k2?Tze>QWpC%SaGexkkKuahX*CE)7#og3)xlUCp=1a zMG+i#w-{Ws3Wq+S+{iGKz2m9WG!Bl&c)Ct@XY|mvVBp`1HMmVn)#bI+{`t!)WsYTx zX7{hX1PS2VCZT=&7|sjfTHn&xr{nY~Od z6ylzrm8k9)1sFwsUK|W>?-m7?EmkYIlJ5KGt+%BJaze!+!rmZwE@IKZ8^AI3#7wR) zX%<$;*syLO&A~K;G45k9=}&l0~oFMv6n z&@sgXm?PI0`H+r;t&0`u{alB~iA?QN-_@PS5hk<5|M_In+6M;+lwQ7adhWSZy=Ei>6+ zjCafdQn9_8S<%($7Wi#N1j#SeNNr;;H7$^G2R}rDNd>wr1~qW#Ht|QWX^q2InNfOt zcIKy`!mziuQMu_&4y3JH{s|L1#Kg=m2>LEwkC(dzd;gD6fxOz7*!OguX#ufdk~H$O z>jJ@Bk85ayiUf_YT-Ojho*>l9KOBDJ;P6mJw|_$@0cC7f2Mu!qi0c1tNXs z)sPk3%p(a48WR8AKRywrVH`eaEH9ex6LFizm@#2gdps1r;EjSx1&iF}qiA|BYS4Z{ z{FXsSD^Qh(D1J1QIxk*}oTP}RyI7P#RSiaagaQNBNkxrP@6WWZ`aw_AQc@3n*i25T zU=+f+C>}_sHaH4fzI~NG&|cGRL5t8hLhRVE$H|IWN*JCbUB4a^9mFSI{q z6P+4JiU-X|p_%=%9j}T7`3)x~wwc`G%7ss=^4TVJY4X%gXRC%18dtEKb2M1IoZq4T zZotD{NdHAIhacXfG`qPoBBdkFPb5)rF1*7`mB!G6K(u^cFw~Vo z)n-PPwH`B-7HSp4?sbn7<62&vkMQ*)p*3QOeg$C*ikx>VqW7T4X)qr27T=Hzg~JF$U5Z3rETt?!L2Q+U;o?PTj0Ht_ zw6*7_u_*Te?C6ve>x&?d#Oh^Y1ecQbBSE;L0u-iYEEU6`@F zfqG5pGVIlPt(#3%5a>LedP(XBSkU1+u za`!uHhDRQa!58C-(pqG`RU1=ii4^O8mu2d%@&@_uHjlZE&bNj`O_gjb?IDZXIju zUX+ScZp*)f=>)|%Ue4gmL26Ad10oY4Scax_QkKUv0E8gfx&+DLGQ zbS2?52L~)V)ejhX$n=I!ko?igsxm5snYfNWWZHxo=Uxf#Cb6Os4FfQ zZMXrtY-6xn)aJjev1(Ut6l`{q!c7kO+I+wjD@I(t#^bLBnk>`QT<>=Fh)FSgoG&BW z839+Swujqm&ktIoRx1C^w+o2n4YG3_k-j0xZlG;Ex{Vn< z2&O}!M%qNI>WB zr?4DtoW!b9!9JkU3;z7Z=hcXXe_8!88oreac9VZ5p(anCHP07Lz$ZTDb;$K{S37Hd z-9%;OfmNWa4O4vc#CQnEmu%d}G!!ZF$kunc*!Zo_`9ozRWc4S+6}n`HHe%Mp>2$9z z7ZNAlN9=2U8>d7sR7@rJ^f%9|t<&U-xS$;9&=Q1w_+!@$i!1M?$)Y#+XQ?26z7@_?`b&lM+jq zP*ej7S(cEl#0E$oE_V6%5YR66Qm(TpEjnz`%F$&vNbv#0Ox^~RT2nTzQ=<-Qw0CFX zVwj+yAh7vOB(#?TAwZ?UUgb~D4Esc4%rQh=fhJh|JgnJIu+bM)0lt`OEEj!&zNcrB z!yZqQWuS9r+|^x@7HGdOvPBJvY8o8zP4kW+m{SCH%`{g}?6v(nKSmL1Ydcrl{^}>E z$4xK6#TglSOUl^r=FCe78JM$EG_xb36L_HFcx*!NNp^Xf4^MDq24F5dq8D9sG6G{p zk493=>^mYONtL$R_xibytP41pwdr`itl@N~nt1>VECEB>p!h4s1PVRHG=Iq!qyvR1 zu7@ylEHJ%W0F#)@6o`DD7fGXVNi=B|gt9|rgHi1wDCY-Jc1v1?4V93;%E^Lxp!6cp#;)1S-$a4h zA5xSDCnC=tJe`tgPl(uexgkf=ZL$7ap$c*#WuW+5*52-BeXjzzXt=$lG_K#l{ZvPm zZu25pM^KK*XSgnoR=i_M`SoOB*!TtGlUdWJ9d^_BJ)CYG~QlQOe87$IpU z@D54P{l#Meecq0(gd>gs!D{GeiWxukT9F!Z?8rjruqXhc=hMh%kcEFhQ?mLy8XKT_ zd_ZDMnL9)A@*JqngofCgE{B*(Iy@3WFR)NKjnN}DyE+GRl}RFw8*k!*0RU-7dJ5xM z_?A7>BLgvi?86a^h2*s8W(G9YU@6eul|RZhmurS;*MUG^J=H*t2eEi2xV=qj$>N?& z8b?u4_tI16VjXC#?WIyB%VNx-$^smrk+C$&`u1BwJhF}fQB+9S02Pv;jcJKe7_lk3N?hv%dTdw#dhonA}96t%)?GuWQop$2C! zvWY>KQP&a-iBUxlqwqLFH5CUJKw`yqy{R4JGEkntKRSSEE#IUj&3zbLjlofGl^JRP z&2TMUqw(W4+pnmDF24>F{d&Yh9G0q6qLCe=@<`WwzKdv&R<{YV;s>_f;%(lQ$J(}r zpi&0pyiJ46I6F#c5Dv~%jh^3yF!>s@al*;7=&G^BnK5LE?tqf~8*(@ibOdiuHO3Z!5~|jv#oM^FmF6ZAXh{*HfQ^Ipmf31fB@D z^#sq)%b~^Ace@?035J`0PfsJ!v43=LOn`$2$dPAyEl8fjQ{aoSxk4gS%vo^g0QetG zb zuKW$JWiU$d(>5r`bL<^%1t-&4=JX{%0C3z%tQuD4E;F=+YhY~v!$#A=#G88On4YNo zZUvpE+a&RrAPm%J-)MA(-ST#OJ`?zqifGxWUm2Mc%%uo9yqMC{8AatNMB@}eID|J_ zVpzvDxIZpt0#6eWCk~k0^`m8C17gTjvC__TqHUTW_L2wG#p=sh(PCZYsvJGD z8J@&B>0L~N%65VDv7R0n(b*pXb`sE|xO$62xFJF?^cF{0Zj@FdhH>vF&fu8&UAga~T$lgq?7 z1QNO60Gh>7Z`Y-yc!1?-%H-SZCBNA>BF#SV)59j!y!``&e#rKkdPO$DtweTJtnD}uuF$i4(Y*}7%3dyYmMRdx zlhsy3fiU}tR+;wfT@735Zq@m&rmcSk)OLn8Z~BTh`M}2xAHwQKwvyBj#Uf*HoG)nB zVTgH%;aCqRIL!Sg3e+Xjpp!A#(Wn2`#9m7Y&`tqGhnGSm%{g*rKuzOO31nx8ZtH{0gmP1>=IMCf)jUqF5 z+jOzr4Tq3$#7#qVFawZq>n0BuvR1(UO_X+f#(qf}?p2{0Y4U}8F}?+~lJwXC+TIG( zobqBDt0Ml#fNT2eeck_y!=b$BwF>Hw`n+GDXlI83gb-GwrH67-L;vG{hm6zh{~t-{ z4m@1zgAI8Iww)Lvj2Ndij=y;)@IM}uO_F4Vy9WN~vDYK^S8^tAeoc`FDJPPUEkyy% zR-kT2a>pj3gYAQaUou^Y{Qo0qcqBe2N`YWN(Yfpp+w#>KWKsID?81Xt(ZL)I0b&k$ zbSQkw|94`ty-?c{ifquwDPt0j=HWcbu*DM5q)cH8GSOC8P&ZNUrw1FPA_JAKoo?0L zGN>AlVvf!Zj+Xr}&Yn~*-RMU@@$@lhyh~TV^LZP>fQP`mz6Q`_%!CS_p&w1@IU2W3 z;2YMri8vo`QP2LZzQ6V)B@>%k#X`81nnP<#lrt1-hsId$Ggj})*~u)@YVLT@p6!RA z)w&IAUd2W0xy7hq5NC8x0v%CM@nRf;$PstWw;b|+r-ApIaG#{`M2A2a8oEtVmlc0F zgc{WZ2OD%Hbl<}gHeABQmE6!#M4hdlprXs7EOPm{P7zv{Xt6mjC9WrE@{F#~$?W0W zFJQosa@-rhZ7+Oy2nnyOtQ-`SJ?T#<(d?8q&u7tc*Tln>td_Hi(?~jzJ`TNP4JR{Ds^fYq+I*NYQA9D zSPcGfCWp$9YM^3w?sL>q>cd^oUgR!d`su>oR~<*6B4)pb!6VcgvUnN7xeXM=`A+RzNnq57Poy~p zO$H}xaFQhuCjcgx%YitI?43W+lZwFIE~U%JsEdydp=<|j_fHNT$Cw4~sbv%?Z7ON$ zX(TsiC7tiVuFMgvVKTGIaUiB?ivh;)!<3F^=>&VBYF&YYd2}S3YBJWWVm!H#id14q z3!j5NX4e2Ouznjw${AQ$Rhz?}LBu?wpOIZ#dpia`V}qWBC~Vv&9-4AKs_~ut{Z(6a z2`x1{Ttpnag;skp4tb)gLiM~feRYRL=rS3U$8;G(8KVEo=qm*QLDwR%sP}ofMWFjc zcBC;yw(VzKjPo6f6Z*Qus#4~A-Du@$$unswhc%}8CQLJA{;|t9S{d^y%6%w|E2$xt zQ|sZuDV=4I!JjmMd`*#ZoG_B?M?Hf$8Ml2Sn0cCEzu8H4%y&{dJuKfI5wJhQ*9IHk zHw?HOE}Q$7uCr+CwwBDZjwKD`qhd0U^aE@AKaK7$lZ1q%W_RZUvCbRudKVKDu&A4r z8InL3DI2pk3@5G{6~19gvBE+5HyUNff1~e1V4t7RGB$hRPw&^z!Q{mO6GY3L_Cl29Kd$#2-46dlURYUKO2Vw?3IXuQ^Gp%@)bIOSB(2yx+hN{zT;!({g;$%|g2+q(g)Douu;s?K+MI_YJ=i zf2P(#v3Icgo%c1!(kc!n_$1mfu8*0Hjzz>Oaej%s{IV5!GywrEeR|rK9r-PPbMrNW zq||bN#9UpjXe4nWl7o4jGVzv$X`Qks@>{wHhDwUWodzA7XNuxrJcWcl#Vbb9PfjoI zi?J2qRvsY{-aw49lJAdIB04-fZN@w|E$6E2fO$pc=pWot+LMG4y3((A{qH8aSWg_q z+=Sr*US?!S$Bg+PSZ1k)yinp{7*!YN$=rO7m9BQGvaiQwC89r;18x@SE;*ua8~8LY z_oCW*$hsuPWg-Dm6JL8CMtNuD~tA7X@t$F_DXI7I>PSII^NgBtON}5~;id9oJD_kBHoM zRm6b1Da(z>rOM{>ias*@zCDIbZZrCmM(0MsrY6VT83utR1l@5E*h>>2sxMIs7p4kaM8qNCN?;uTFv63k^kOv3appYL0912-eQEY z7DGf|<+9#OScdBU2U8R#L2L1De<0MO2DfKMKRF99%7+?g;_ZGK=-;6tK}@p0LJakW z$VzZYb>L}BumeRN93)RyX(Ay(+cR7-T6PrlC;W}yXB2 zMT@qwQl0@$0s#%|Y@)~3mN)Y&iG)HA_7JgyH(;?L*gH5(GBXqK64lB0%(B}ZX_i_= z!!nKE0_H#xR}_fk0qJJyfxMs$i{&d=UFe^RrI%uI!hzd8$o<@?K`q!)gs=;%~-w1p*6 zi=`&{XVevcCTwFF#IAxiD(G6na8#+TBU=XHW$*9K7+FaoMTWz;!+#@`u4uOWOUxFD zseKJq8A3)A>~E}7azM=9rq4`Fq1)(O&mPK_z{DBglO1LRCBBi=c z^W}g$r5ERlX6nIC&pm4m9=USF7e8{WQ^}!l`>p2z7yf>Lew-B%TX28DuwD_kI9^ib z;9-%ErMlT0+Uytj}Y&Od0p+B)|Lz2CUW}Rz$*j=*EY1S$s@bC-OLdrV$PHE^!n^XT-fC7 zOGF0_wOkr^%a4WDcyFX?;bA^Kdy5G1DL z-`^#tJ*#_2XIBm$;$9AeqKe6w=qA5!UdAoKUd_5q%%|BjU-Fe>u}fMuVeQ{Zoohdz z!m-aUYO_A1>7LUy5@?iM+s8~cBYJLzR~2UM+vRKAA|5M8Q$M-lW$e@Q4j~$RClk!l zO*#{5Yd}8Xn5nAR`)b+ofstQWUml6SJdNW3JRS>Gim))Gq2zsNHedZq{N>$ zfz^$rxB4@(jpy-K=vuvCVXSmRGOp$Cxx*`9&D-5iot7A?O;Z)IH*5&bT3grj(6d*~ zHY}1gSNFH_pJ`H7>klc!t(cZ76?^+oArRG)Q1P}#96WT-K_DeoS>_gwxv|)$^RQcx zKJ_SrPOROQ)n%uTVf1F{%VSAX5=#lHx4h;4CIgY>vQ9O;^J}u~VOMF?|1q$VYhn(D zFytMWl-5}&zy}neb-&4JEuj#JkR`E#D96GE8EQ{AhM!a*qA2HY;PaTb){|)8C%3Ct z;ATwLV3q8wVR4_oiLNifPrmWuX|MJUk&0Tm(X_wGMG@5j)3U-j+1nAqZd~2rkeX)8 zq%SMNKa8%e&QM$#v+1_8XNqs>WQ_k+#%#UH!Rz^BU*Zvn2I;$ zXep5~GbTeJoWlFXqr&3^E(VN+zm&b1O7X_#Et$THRSX&dOEcDxe!OR?0Ilutmo=09 zoBUs!0nWwMT#0-V_uA{Q6u&OocB^ovb+DdFaR21+4k3p1e>T`JMk5N$s(i?~)yfdb z3Iz@7@7mPhwBF*+bGHq~hrT@GH;_qp%;+U2b^L2C28`g2siXi+Gri->aZJHdb#~oe zbRU)zy4CNCyt;z+K+lWG)SdeJh5CoWA9y)~ei<77MI-1@6DUBST0ldCIlw{042MJ1 za0mpQ8O0~Fn?!a6glnPsLKtr4e$Gl9fYPoP3w7@|wa9@xa5egg+iVs4;-d3xJo8RU zM;Qm#1w=*p-e_Zn$7whaqO2V?(7GnQ@C696fN<+scW|0W0nfUezMFq-0`tq_w`Cpp z=}%CmHU~y3_XeeeyKzTFvJ~9fJ|`|g0Zu6|efSjk!VjptLF6Ler+BY#O!BC z5bNmeCtBD0G#QwO?N#Av+MYW6GFN>4iS;?LQP2G}o-&qA(9qGU-IF!sGeS-14f1)u@*T=r&cVvbz1`3|0AyNHw-rlV3 zyY$t_#U548dd%8C2mhdh0@Xv{9Nm?t9y_x#r~p9OB%ZETpUvMom!y$I?Bnpnzy4W% zhk21epB^ioS=p}iCfF}My_mAiKWB5+9?`4qN73*?>C`_aZ8e;8Ni&V) zx7P8hSr#{ltL;xH02Yz zvfV!j{&AHz(xI{3YDQKXO0j?CB@^_90&`fYtE-rv(jt!C6Rj`MP{3T)E^!iXOu%Im zZ*bg>eaO+SY@z;aL+u<(p*P`!&UFoKmztdAHmhT!RB zA-h?DQtm$3BW1dF3wb$Am)097F%g~#2>4cyNc4U1O}jNO3rv?6)nmGXZPZPvrblGV z_iQKjzC8)&;cUtpHFp~HUByI3t}$t1LyXjH69epWiza8T?6&uiefv0ssUCakkoX2S zj^3Y)CJ7sJ^XWQ6W+{LF!W@Pb%P`&Z0}Sv9d&Zqo^ZU+M3TVpbrr z>T(G=T!K;p1x1SRZt4CXK>Xi6;>=@iJ?(4>$oNu@wJDy&|8^IRp9vbjCCnVQx?H#6 zy>{@4t*G{(mp_!_z6LJO!*t(BussNP>3;!i54jkdWF)os3i9P=)wf`My&{3SnA1Dm zQ(w|gd2@#Eq0Z0d##Wvn0uw370JJ9gsSO-NtvV649hU9!O@PwJ*0)!v?W2z?-mKHF z(kd+m(?#}@$oJo9x0>EDPy#StuF{%S@!B~#KLVa!mVpU9{*T@GGo2^GV5wj?|G>?r zP=zBt<`}m>o|4}|M0v|}{8NfyMR^Oh9DxcJ(%jt%+J7tcHnKO-=#2?KsId!6!_0jv zI6cU(IUxoRG#oWvi4QkFm^J3a4kA>6H94^NVPN%~oYs`UdG>BY6v z$%%zNL!h}##aMnjA-gbK8|fFio2S{W9cQ{qfUoac+2P)vgpLeL`a8tUS)cBii@Rbq zsA{RK-9{Fn&|F=d_+hy8-i`!RgKJ+>j=$M=O89<-eGeTko-&Bxa)Ync?f@A8yFVV_ z-)b|apGe$VrE>Du;Y-vX8F8Ya){T0xX3QtXuY?D{6Ro?Xq}O9yVC`{&W8Yb-Jf8bo zX(Z!yPRLzMOY!}QQ~v_hN@f5+7(@`KT0bhjOzR1GW~G-t-I(NK-kjmwm;n{ww5)w5+~r1r9&}6_StJIA9VLFO_ZR zCVzy4!~>mU`7hqGSyE|U%S%%J)~w3kM4NfIkC-vkISqZ+lw0EbxgWR=w5L?=zBmecB zOXrfxS&qyz#Zxhy&O%(&Gsu!=xo;boeymnTXOfn}Wfr26@gu#7IP>$b(a{f+s|FOS z4fz~X;fMAM6R}4z;I^9f3 zmrk?O&I06yDp-N^_2##X?8-HQ4Rm>jC*xw#4m-o5j50ctOv3hjP;6et8+XNs>;^=L>ZqoaGfylnTK1j6%|KIUie;bEtCC2Vkpoqqo z{}44x7-A{mtC~oF-C!~mAnWjt$6I*;Qi;$q<;jK5Rb)*iXp;oQmcBqI<16-wgez`; zG3+dfZ>@0ugQK|D$WuBWjw#mSCEjGjmaEw8C+7l?WR*pu&(tW?9+9%#ekP zFDD_9EoRd1btsGm$pkK*%3glXNmdOYunQ_+3TC1%O*HkaXlGxFi>CCM9%B%+zmQq+ zNiHP)1=r#IWc9O2mjhQ*FJC>q#486KJg%@Q>4@29ypvQ@B;NF=U9|@b8B%`E@wv7S zzQCoYwaFS$&!KA=DC9CiK|aZ;JPBv{0%h(z=BOP@7!CP+UmKRWbSyGa)N)Z8aeS$zIGn2lJhQ!qf8WR_HHYk{r# zPqjR)7KL!~YJ^(Ly30Uw`_QJ`Wb`eyf>i`UvuCqXnI)Exj<|s)r7tjr#nmxPIpL2y z#RqJ=2|s&dJ_#uI(P6EfX)oDhF+lE=byt~Wc!zyOPu*ZZL^BlvOB$JF{Fo~Waq+z1 zD1`_vuoc_+cCiGiketP47`@zo2`~DFeiLldX~>2xdc8LbR9yo<1{nn4h!X(Py=F^g zY+`pAmiW3EHLTv3!*~uFuV{Empr3?86a{&ul@*GxF*Eo1Yh;S0uG$MZO|6|RsCecX z(iTcs$w1@}>o~|*CeC@fpTMQ&LZT~cJ&G=dr;#*Vo&Os-H19m#*PCk0+1Go>L;M*O zB)kfUF$6{g**%r8t;KqA7&yRE+`||Lhz0>T zpa;;$%)x;i=I1MCfxI6VsO(2QmeW zBII|w+us&Qm|oxN_8>)=Bv)NdgLS&zAFfC7v?T^o(H7|!%NyeNVP6ctLwTu{J(}L4 z2s=tS1QJVqmGsfv1GseRp#2_Hw+iU$hbJ+)XP2};8y!lC*d0>UHWRA81zKr=p#ceE zEk@*D!T|7S{1sQC3JUIg4+83Il=&RaJ^4#*VdBX_S&G!`MzvdMr)Q{-hgpVo$p#GY2jcc>g!qKAn7&yjyo;{f_l!%t zUX@pVviS4h*{2=R>ei=yx{vYal5jL&xgVfD_HanK*T1wY_}i_G930?K@;&v>_&JyP z%ltn8^wUcFU!*j3ksp5Xf`JTo7>WecCU)`_Is`Bkz3l2WxZXL^pfMGj&U)gA$Cw4( zrOJN6SOCsC=AVZ_=QS`6((robr1l#mkp(3Ms)aTIcF3OjfRO|;($j|n)iG@_4v1q7c2Jfe;>T|%prp8@b7ksxq&9DI=ruQ9C>v@AN~m`1%&y#yj>%8Nh)BLpV8P)f?dgC$E=9^d0?sGI;A5@c6ovie2G#o5lDw0E> z0CVo?efo8z)2bXd^d#VzK1c$3fU$C4pha4HZs`<-ZWS~g|D?WM2_4c};gBVu!=)Yo zVm=8II!k?`nhfZa{fwJD3~Mtz&&S9Iz859t>}OaCr3}CqMXcEOMHlK(%?>4LV=9ox=iVrOD&c5HiM zTN6&~Ol;ej*tTukwte>VzTb7ucYf~P-L+O#FWgn@u4*q)DuvS};uO{b_JWJ+?)t6H zgVI@+-7cPsuLmT?QwrLT&)%0FuJ@;$)ywl_;M6hZ-^^;9sk~3b@dMW%zmnGEime&K zCA+$ae@D2z*s1xugrDQcPd4_-oO<3gOiBM9>AXx+36bsUifPS#scGA0eO7i7<{z%r zE-ApL`jY5)5K`n@u+QtWTKaU9v-sA!ZTi<{!LkuYzIJ^stoFxRBHf(by%s%9vU21w zTYvGEp2X^PYGP~@86)>j&HkWxg2{`FR7sd0$`L6msnEx#&}=YD@jd<6tqe{w6$&iH+O`cw6>rmjJMkFKUojTg zP+B+|SYqq3lxknyLX6%1nVWh2fT9>g3$5_>A<1UF2__@dY=DoUKXQ|`Jh_T}j=K#? zU>pLM{O|9v$CfY`VdP&)UQuiB2{?rhZwL)PtFUC=YWB$jM{BtQynPHFySDV2@h097 z>b^ks1UE#{bamPPwcgarI01#P zB~;^zx0Em&Wp0+_#X{c3SsNv(Z$vj3xv@p__SDL@Y?<|HcV|Y+H#4j1aqv18^pa73 zr9n~9+J6PI$&uZ#3(bd4IM+Ti3c%tS8qD#Wn`<_wd{>ga6$)O-)8Z}AUg`ybqAe6w z+0H9agt|%5KiQ+Q#oQ6zR(bR@>!<+U-qp#}vQ>!g2Slm+Q)H@XTqN^c#5p`(k%rPZ z?Pw+#tL*K5iUrF)2oqJ{8sjd-GMAa}Nixl?yYi2-HHrTRw_cH70%@xl`VWV=22+fp zEX+tSPxDLd6}%)4A}L}Fgpj*aRQIvcndcH7dSv#{LQ+jV(0ax|6(0UEsGClcEmaZK z095^NkfmkKfZE^^CYRq>WzRBFEQ)MV@J!e%Tvr{If3VzxA-TCx|~w%5v!2*CEh-eoorC zX2|BE`=;JY%7K}yG}HH)Q=Y76fbfy@c&?Zn^TcU}^8zvuArnPbU`!t_3@!9V*mm1l zi`Va_9$jHPSrsB6=m*nn(;~vAS5{#I20?MMJE>ct36VGlsR{9*wa9V+^BdzZ2OJgG ztZ;L_6yJ_A0TeWyeZ>!>29Lg^HqGkB)r8ehqpyDp-;nCn!Tl5Ee;;=%_wtMKO6pp$ z=CT)>x)Qu{w4;&E^42ZdrEnM?N1mBsOQJQ6np*3KD zXiaw}$Pn0cJA4CYHaVIsMt6q{Cj!~|H6Pd?)gPg{+zeHz-G&r-Vs`rnk*6EKe}u!F z&i&u7db;5^&p<{j!ELy1IwDwuRy@bY!u4kgWl z0RaMqqE*=&@cQaBIlvhs2mr45-^`&qh)caM0Km~kghHQ?Z^+OByB7<{=9s%OfQjE}DxXZ@ibA%eu+jAm1)7j2&_6}lT_P&p`UU@`q4GageIcY@TAw_Sk zlrN%=U}w3RgkD39nEu>XNzMP@=CJe@Grat(qT64O6Gm{AdVTl&li)J^)}A05SY4W9 zf(5>*{srWXqDojJEkL_*xnD?5R?7sz4~AcDb5L_7F00kxJOkoogk&{C%xw_7jqF~ zK4NG=w{yh`nucCfoNoN6j1hsN__5vBd<8G5B2o}PKZ`xd_9pYG2v@O3ee;J8Pq&co zH)3fT-j;;&M!b`*%Po1+zXPe=h+nGvdlBDc1i$~if*E=iN9f$UHVF%YAAy|*r0i+X zT^7B3OD7m{VqcIkDsKT5ejxZ;_2Zi(*#H)UEhbPNYF~p;eYl2C9L>nB`>B)snq+a~f%N&BgA+JI*kIXHYd7XE9wj2@cS1GuO1aD_mwt8_Xlc!5{qN&BY%v^CUyyTfJBUdM6_Re_D7`N zGx#8CJOmltm#3iIEsUbJV;%NJ>rmZJjw}UpoY1w%hJo@1K0!<{e+?%{?gp4}X5{<=lX79M}g)C_PdsZKgQ8mm! zPAd_P4*eFqA@Z$JnD?md%HbQ+^(>s);w`VVFTa2MD4Y$gbLNXN76ww1a*|2#Q%zuS zGQLWMf@T=kgO$HMn;T@Huw^*vFcaGMUXp`4*FhPK^fLV-$;0T>L-#yL#;2Ca0jA^MK7dp&kCFec3pp*ME!=jqR0hzs3LKd01 zEao!my6+$WvxVkF;n$HHnBc>bJ6e_+h$jygvQl4~s_O9A5Pzq~X#(3QF7Mi`;&IAl z6iv%(28m0g;`+Gv~FSwvxbImgS-xAIqfMCSA+^*di zDRxxTv>P2zK#Uf~sAxqtVf%nk9}vvl!XSpF&#%f&>p7w?VUt0?I`Qv@X-?$=k7azG z3`W>84Fuj~xIttC$)X+^*4z0Q1NTaN#L_=yhDDN{%%*8w(2v3BNe zDjpK!m9-Za!*X(-L&z7iIaXR zolMl4#=g1}L@boCPSA|aGBW*3hMV5&Pa^anq=@@9#cE6P`O5BhzkGL{n6SepC&Y^- z5!J94wy(h}E@|8Vu_-ib^O4s5|9P+=&-VXvYFQ&e7+jXPWvdDfgwL(OA>6tsLHHc> zEBoFG9In>omhHnr+^`R#2zXI$`$&`-9;K#qBI(+A$PVTph4;H6VHzlLTX*O4M?E2F z?mADGtyjSRi8(=hI{wh6)?-YbyXJNlEPG@33+#KN z>fKCi*TG4lw;TU73p{ys0&K^iYlC9Kb4IN`aWE+)1J2u;A zVAqyvmS=`{3dlC*t{~eu{MjN;*+VewAP2?D1$rp{A`ezlh7X`QUh?XeG%$UtHlwy1 zR(SIinZ9SV=9qk>3EUb252N)CPSchkm9US&N`Sosmw^X~V1V1c*3-74`Bt(dn7zh3 z`KKr=hhU;nj5Uz2M#&ZF{hw=>*8L88A^$UC$cI7W@IO-e|8Ic3MXMlduH@MC%Hxoq z1Pvptid{I{7{!T1TFqAK5)}vkB$)YDAGvzAI*1wiyU~bi&oMe5Mf@JxCjYUuky$iP zcH%hAMgUGIp56?#K)Zvu@3{cpU!@+jmTYkOrxxjRjI;6n7cj>;v;qfzWE8;K&QDfK z$6(i~X(} z%{qZy$UNt%y8KN_!i0`m#m$){IMefXq$$t!cse(kSXL;PGL5#W60WqtkkvRP0%9RB zp##b^O$F4277W9p6*rM<2S)AXCc7n?67X^M=+%spldMamIG6&A_T(O!x_{qU zWX>e{h!ee~#@YH%qEk&r?^7%mm+@I5yi`#n==laW#TI2r5cQddBEs_W|Fx;Pb zQ_HEFy$jABnzFL9(t0xguYq2*6YL)}LBJkZgaZ5px@@%n+}c=Lf1LTa*XhK6uI&Bl zC1N+tA?9CYl};9vR!a?C{LwtDgZB2Et4kCMvVH9K*=mta%cfw9+*uyvW6J4O@yRHs z?h;teaL7h#;(5Fk4vw$Q&y3zj_u|*~*JjE`KiN>Q$LQ*e4iVJ1@slhs`+j4=d&fFL zxR;S{h9yV9+xrAM*}nVs_!eZ4L<_LnD`l%qawRdaM>AFHjG2(nV$8*0gm_eLgS0tM zU2Gk!w~(@WK-0tSewa%SB!;~ZoWQX#z>=HMJ6WgA<@;-K=KdY_S?jWy9R(DZJk4_J zLn9qj6@W>HrnkirvJtCXHM+>zkbt-~egLbxXmd_0u}M9UZxKNml@ocwaf@Kp(N z*2~23+GTV3$>sFAPci7!D7j+<~B9ky3rw^Ab$=jd&2Sil%ryK=OppX z&`5c3i6?_pjJ5!jZy(*|Ql+W4df$jVYd%UO5t~#A-%a3!@t3oAQgBiYw2H9jPaFyf znwaP5UH@ZVJ){at{i!P2F{HOweKyk+iKK&+hkfl(kA#CR;Zky`|2f!^qSn`PyzF$u zxL;j}8#PNZX?a0q!{(wHfVJ5lnPgB-tLFL63>r$rHTEnyI@f{`U@xKGDQV;xPxH&8^;-J2u1WU~P zCn>sqH&%srq{ss_@mLc##i`PhkeQ=ANXc*eeSDQaimhE7TKY+AJrDbomtr1%9k4#@ z$R(*qU5ScE{HNd=(}6V=-TWb%F?UOx9J0=K!V7Z6;t1s!9VOnOClq$FXs9yf1f}#k zaWUN&Skwi@*uv{Ck)*FEl$rN*k+J!Gy?2Oy&vo=X0Iwur%sxrSyELSvMFu(Uq5>Pr z55}#73^U|e)IW?U-!l&P04{&wg4r=Az~_>|KNK%%4if!>a`Qj>uus|d*wSX^Itux4 z7uLCUg?8gA2QCD@9(#JelN#9?40t<@{$g7D!0@+sFmthm3_+&>^Q6v$SWKGEjP*R% z=7STIaZ8NZ2WxGYvOl7hKNo88D~=|#fOS3Cf^w8hy*f<1v!}f+uD7H z8%k?lJAz<1!>k8-A}3Tr69fW#myz`^#TZ;T2;)TD61O{N_O4ZpSlx7MXzhclEU%0; zNFO-_fVk?uT7$twwO?J}cjMsyM5Ks?49G*gLiP>K_RvJDLqMGk$}&SPj#V))_WuSG zz@ro{a#~CFI=M;8#({4gBmvvoEt6w1&+$h*I-q1Yu;M@j)Ob*@Zf0Ob3*~>UD=zBR z(Z*0$p=*;(?S<8MyG*e}uL-9wqwsPybHGwtAbx*`43%%~UMm+ZfiZRy+teC=h=O)DQ5eN%S+r!*_=KTq#uPs^JJF?ud zdVgy1MG+g}bzrVYyQOhBh(Y4Pw8VG`9U>!N+7|ygE6$1B@26QaAc;3gj51g;5}`}} zwQVy(8!3_M1`z>9VtPK{_dWeW5k`&Fb@C8a<7> zj?1~owK8~L8BHSt#__+cH6&C6^7Ry=)dW~;ge&}$RPt@h+@T$)+kRx+AXJA#_q7;P z#lUf%PDSGPhz>4P58{RX%@L~~hhgF3d2xUEOu^Kf_#A3mWncNoDW(>2f+=t3UP=3; zH*A*rI@{~?y8p&jFNy;le;NwK%On}2sUz^Wb`pt8La?<^S_4Yck7{EK4DX(ih*deU zOw;aN#@Y0^qV$VaeqoBNIo z0R*CcV~XwkP0^1ycQhO3qEZb6OUf_$cJWXqN}TA5H_p}|4f5)qT|ua=h0qjhA@e`f zrlnt$TEt+Q=AKH@_a61lNKNr%6$JZ6!c$Gz{sVIdG88Iq>~#}cTVIgOXN>mx6jTkB ziPQ{)EmFIR{R{Nb?+c>R4o!Xt;n|BgbNp>>!Yuk##@7bqB7nh6YuUSIwf+U6=*rlk zowJ9(kdU62;m|O4ItX**UQYvY{HAl#ZX)bu@{xR*ircW9MvT;Zb0@pIu?-vGjka#& z0A2PVZH@K%PNwBZg*QmNoIVLyLk)Pynux&h9VC!>)+Nv48!pI)+HE4icTCTrNN87O zuyCT=8*da+TMuH7l;MQ`@Z(@}Rxr|Dd}vT(EHl9d1>C^KiE7i|bFFvZH5*QG4mLZ( zt>i6WEiu?(a*=rt>Y^sO((ATsOkv|h2lnr=Bm5^xy`IzMM#TQv!pmU7hW3)O+5i<5 zGs}xB?S-ik+nvC6+bt@L>~DpfRsbEArQ#N>D^Rv4sE>R64Pj4nyuq}gV`)EX`EK80 zZF+&da`>a>DE3+AzknAcwjs!)q(d!b4hiLF$GtxaeRjt{Q-7W65~khLW=)(dEal)Z zPWf>(SWe2FrktCVP=t@j((t#W=k3>@J*l%#w^GFT>>ruZ$~M-+Ouv#ml_Y-Ub7PhU zo1(>gNFd4m>$)tKHc45QIGQbI<1ohEnqV#)PD>ghMaS@j*4rzC=)ujHFex&{VZzL=x{a ziIsf^li7zB_~mjR2#2ARVjjY)cG| z(5T}hHaz`LJrY^sl9vS(t|%g3x4Fh|$ z)a4)Bo@gE}Z?=pEP`We#B+yofX-(j+qcuKMHSdj32y_beg%S3@t7=7UNz@RiJVenu zhSwkixaD*c;P^`Veh-M6@4DWt>q_GyJ*6u*`^ERj!P_Colsi!`Op_quBcAas+s`U+l}UV%{2gKmk?0TH)_wJ{w4%e0U<_~yA@Ah{;+OOn)Q*6(?fs~ zqR*DIt}7ehUUuxsj|df$BcQLdqv>AWWY(E{i!>#1g!ffvmXwua1Kf)8!sI=zacWuB zv+YZJ`>}FAH}{V9e`wJsa6F(YhhBG(0RH7D+mo;%og%8W(jO#`T)3{)tH2T9Gm3Xy zDfs|lvUTHNK&NYH%}^-+g33!FVRgA-^}@$vm7TG%N@(;cfn+xc1*~-BFzUoc4wdv! zu$IaWt7q7L!f;lHbkzAuUARhVrJwxM8qn;InQuncrU9h`;2SSv{&(D1X%8mr^&ZwK zJktWhl1S9RL@yhO3<+QoSCOf-jfNnI1}!+eG(S8{OpOeIGs(zFOhrER)90M;x^lhn zw0RI5cf)fv%^GO)n{qJYr59PY&5on85&Mt3&7b?nQh*SS9f$rx;Hp&xKJTIcHMa}k z$2RnIT;?^NK3t7QHn!1;6;OkiL(E5NR)8j1W4r zH{S9jqI|~_?Cv-HwH(PNZv6x#HtG>3S)kDS+GNF}rDnXV`)mE{{`}S|FnPUrf#kBg zyS>eX#PiH_t_2R;VuTNIZ*H@*xQgz;gBv&15iN`c7*-y%%_+taD#23!GCbdTy*xV5 zV}|`-cCx@apH{$7mTT?*DPO?N%DU8SG!wnP`jH@27=x?-RW@~YUzHHHbtC2<5~^)EaI=;x`6W|QHs+kbeCsgnoYr; zUE<)*7cu_aQ7u5FXFbKD)1G#m&5FHOrmoP^W{=y6-BWb0xDqycaLVFYNIyOeXbM48 zw-DX*61{Z8Oy}Y=e)v=8W2v%vH|?kCv;B5MFct4zeiL%QIkwD7o)XBpZbPDUk=Di096DQIe zz!vOEOrlD#-@^ONFHo$$^3Sp9pDC%IX>SEbwMwemskdP+7hPyH+Tj_l)jMYu&Mhvu zHoYyhA2xZdHPWZoLas4yxcff3uPTZUuiw0C; z43eHrFGvzwh)vU4otft_$0Je?)9JzRO#fknE?Y#~9$G28gQ8_8f_BtxA%Oy;Ww8ne z3qd6kCp`DjFU&yGkU@4aA8rTs&_<_TTGfWrjt0tXaq$VA4o}rcUmN@oDl@#xkIR+A z`j0r|gT=gCZ^Ck-0Hjn|@x=PEko*Qk7t{2~PMQ_P2Hu9;zr~`}c8A3V-}gVXFHSLD zTOV_mxn6smXcuKk1ZKzUP6THA{PnMzAcxG}4F4{iQ|AsY=&{E|q5L(bGSCtu5{ef2 z_L?yzk6>^GGXr^%Q_xw7)ozzJvOM91%Z&vW?B%1g)t@sxEG#PyZj4~a6IXUwv31ru zl@6o65WPf254*5>&brEy*g($INP1x2AVQa5ABg@B7hS|yC`wMk-=1<`N$NMveoPR0 zY78V?AUK_|e!9&eaC*`Y!iExdh85TGE38z-Gof30;1lx*eHzoHCj2`}B_xajA5|A_ z;ogmse+Ksp?bZOppvUcZKiXpk1*CFFoDGOa`F08*Y-u7JvT9kdqW{+`j@n6ZRQuOd z5Ci6rP?5JW0&uVomF)8PQ(a-QA)oKeS`sZ2RXIh%f21HaEWA_$C7mYMRi(;t)#Y)8 zr^trK^x_UatJrFm)*Tus*7MB=M!A$Ld7HQQJ`sHg{XD(3sBCCNUM}%o-2LVB ztzZ;U#osD_`5s53f{()P57R?$_UHaTmM(dz4C(89?DiYK`OzV9$tq-zJXVbtDenV_ znWW=+V6;?mC$zgil>lweW*H%`? zcx)JN7B?WGB(fy*1Y-y;1gb(qX=K83*k>wbp2cRF($Mzn#%LC7MDI{~sOMqIMn4Kw#%Sz7Q^HqcLEm*A%8Hja*P74Y-3e%*Ib|l7b0L1ik_xHJ z6^e1kx)aIgr9IM35em#N5qMd%M1?YN=l1YoRjmb-X6kXxI0sW%^!3KP<- zW|=TwY^rv8_r;dHelY5q@^eGo{Ij>=Co$ys18TeH*4p(=m3mG=UcVlwT049v;fFWl zf?G4$@~s!|BjNFD(O?k*xUl19oK^`k&LdatR8H}>13A9y*}R-qwMJnH81_qGPSpl3 z#-ll*dxP=s;_wg69ClU-n$KlsUQVokE9Y`U-_|S8$!P?;{#a%v&;ONljBCBg1(DHx z1dceBx9Y%N%)a5e*!`B(QN^IK_9D8OylD4yz6Cq|n!iB`7H!@`4u`QPSDC-pNreZI zeKh0^M?+bv$NtZd{4M@|8YgV0-xd6PsPyK6Qc>KvIMiq?ob&Ff^4_#cs{1gp7{>4v ztUtVI%;-|^=4RVVS2le{Z=W6pZz53_@}HLF=G`oYXPjY52^qq`3=Bp5@F7Idq%q?7O zeEZcBmcGVz?1H$-n8#5S72nFo_O50J!UO7K39pY16w$PpwOCG9R-C;yJ|Oe0i$2*^ zi`C8>h~~0yWE6y-X{mzi-zAdCfs*FnE8rS1su>O-Co~*;0Tb6Z7yvf}Frk3Z zD{Rd{yz6&b4HG>Q44I$R53m2I<7q&ev5ViUjI*QpMu#eNOi?=@X4k~0dx+CH(75ej&WKo2P8KB#v6FLHPEz#N~Shd6VelK(YK!cBH5W z)YWdhsX*4SQgQ=bKn3u191b*o_PoLNg*Gy(rjh-cO0-bp*CAwZmZ3Rq5f;hvBp|3RftAYJ<(eqT)B+yZQ6(B#m3!dc$Vy< z*y3)JD_2$N_EjzE@@D&m5kKrpBNF{$vJw`+)XaYn#5$je}dld+=}ZUehno(nAE(?VSJ6Bqqcb7s>#t?tm%q>%W(c$w+^W6Dy1kMrBp z_&G=l^*@B^PNRu8{HFOuh74BP4&~V9GJ9P`M{UO4v%aH!(rPZ=r*XdXhEv!Gw@#Ej zzD>S_#EoyCP2>?K9vG^!NF__p-J0&*HPy@{8+!32%r^j{@@4*8y}5|{ZilidyeZ-h zvy1U^8gb|veO-V}cI@yaj=+|E|B`pYtBQG9;~+eY)ehDAq<0i|fmN-lQls`W4tKJ{ zu~KiK5lu^yOWUmN8FvUeesRUVxwbFS=<{WCr>r`YlEaU}b|$nUmGzNCl^Ob(>CadG z2U|1Z3p$!C;T0H5FpNT8@L71o7t0t=AOpa3;0@~D10jX01G{CmAh2ewC|IN!0BFwi za{@4Q_G}j5NAY6A-qGge0l%o)?g+eC$&kkiTpe}MHl zYy230FQzTuW|}STvWnk?9e);>6K@fT!xcD5^dcfp6mOD{IwEIiY^fTBYN0TF18}F9 z%g68DJ-Q-W|5cm%U2jV6Rm?OFhUse6yC?~xuwH?L=*&yqaelD~F-Cd6Zk3xYN@f*> zKwSG~G3!XcU-d!0^pr^clP@r1e6;oO>VS{-B24q8S47=V|x1E8%&FF|{^;C8f zON7oXeP+G8QBz6q^#9@Xk|G($eN`~~Z~O$vxm1_FD2KiV$ZhCFPO(axzE4rDWG|Ih zF{)5FJYQ|Qug78@LPL(2noIV*O^0>w^)Hgy{8cl9%C`EMUimXtyOa6-IB!~6P7X5x z#dl&WTdZJW3!9+bpw<^jU|MNT3k{68WX!0VH$t(ic{2YBprC`szdR2ZPD*Rt?^6wM z9jV$E+JIcFMD*gj+X4_xDjqqIVrHVs;iVwRmy zskR9D3(-uWp?Lg8a5E1nyAP$JPig1bYnc9GV!-%wwp6X2x+043Qz;tvJBE=~c01B5 z_)5jlg7Lu8U7N^5_*q>eWxKe}YKDF~j({13!-N^y$icS*QovCKlo-s}%lpR&g6HZx z{;=B0kB2!|`0vz}`aa_8x{OQ}B+ON7wA2ve4sCzhPCR`x003Tbbji=ZBM;_bS{Xru z7b=#Ytw^(8ONBL(kMVe4%94*~&;aWMBv0h8{^g?Z%rmFE->M(}c#XZbR48}gA?J70 zGadwJEr)f_`?oFqkF0UoI8NC-q&`0A*&JQaw=alh!uO_A}X zOx^I*mZeQF;5~L}Psah=8>5-46V0qJ`mPV9V`q`xboX#Zj7uPI-##UG7fw`#+D<+) zW{7O^6E#&j5v;8Me&DzTG=E1UrbG0#fep=vGnd%V%vH`tu5iz7wU1r&GYn;)nf7!l9!pMn9=^ zpLgTQ=aKryFsbPY7WfS^Fvmu6W&tG{eOx{viWj0pejie6tfqa|(w95(SGgJTJs)-_ zbf+2r1bqJVJ|K6ai4MnhiNM2eQ1wvwoZkL+Kr4-P>#L{33*VZ)EnapP@Cfu_!9(;=Yg1mOfw`FHNrC zaEkOzF82QnbuB{XnyWpr{cHJ%3Y(kykeUq_JCWgEw%o)rLX2QpI092W^D`oqFaO)R zw&9yVSY_b&7VwS<)dLbxQvdRR!QX_i3>g0LFkv(X;T4W69!+myz0{Y4zKpxzILfR; zVM_%X=au(5wIB|{?UpN`n_P!HHSKGD##dwu^hyg^K9Azhmo7rhX;AVUo{Q0sub$_j zUQMl|MFbk6wU8m_Vx;|#pr(NrL+!OwTcyAae!1$C*^Oq<2H_i8GSKY?CP0O@@&T?d zm-o91;Ly0CX0E5k$L4& zC~7`;vml2A!-})F&<>!iiN?^{c2wk?P;{~0vl=XS1IvK8AP90ZHuS9)yIw#~hCOfU z;yT9>7>j1R!~x;J|B$^*I|}_qcFbV4Ds@WnNIw7!X?Jwv4t)5*`GMJw8-0?xXm`0C zAgo@MUl0P;6Tu@xm=zcB@ksRgTNhyDLwr$;v-*La6u0UI7S_#&T2O|dx9AaKPJo5E z$o($_Od{JxsxTq9xpC`L)G%5Y;iQmORf%uHT*vFb%}wt>L|@l8m+0=1^ATnitX{+w zSQ8GClIPeRH{={eYv`0k3EG_oT0!l#^2nc)LSGXMs<7}f3ZSJh+E4byj~zkb4z^r= z&>feTMb`k$Cx;aCo`2ShMoc0AOd;Gy`gz2$*!dU%NwUKfm}jSRi0|!y%EJv-c|F90 z$m_tXb^zL=janrU^oXef`k#%r{PVR<9Y(|$`fkXbx9L_f*C{?P`?mO<A5-$T{uF!`BKDGem|3B@JJ zbtjDOy9J&}%}iTrbgrYD_%g!2e=jmhp;sW$*YOI&?Z(X_iXiKgx%UXv5hSaCiblB; zE}`hX180MHRdSy-_|x=eRZ;>&efoP1+VS$o>vy29+Ud?F5&WV6Ng#Tp^Oc_9w&UKM zjD?PugXvjnn-v24k$?wv>B|O#4^IjP_iLXXErm04joqFhtGA|E#bhd!`b9agtVl`m`Ey)d_iFoV?;sb&f} znuZbA%bAR{Q}M;%m@)D9M6&*R|H9n1WZL?lH0AqK?;hVVDA~Lu@YSkt-JP>qfQ9BC zzRdBAd>r7BgR5TL3_$*i(cQTV(7i-GWA=%X8Z;E<^VC4{kQ^0~S_F5Dba0u^)Q5Sd zR&rarGx3D^+Y;#D&lHX)Dn>AI4wEN^ln3uP-RtVJpOvI(EHZ13@l=jJ1QqhN&fde4 z_IoVMif6_rCk`o2ZJE%-@lf}t7t}%s?CcJ42IUn)y(+KEdZFnYIsYNr=%JY_9IwC? zW5Sz}Yc&PpM~_R}`fB5j@nOul=94!l1Js=V$VZ0}w?c*VF)p3(Q5u z3HHm3MSvtjQJbO`G+{Ab{HNBF7zCG4(sR@?P{2a6^p$YhRnct;G2d&f1iRZg>u^Sh zLj|@zKAb%>8_;QICzB=tLHc4J$a)7sp=Yg6oER_Q@0H&mf76M1l_Tr^3-!5gfM_90 z!tl_fEww4t_5u~$P?`b0Zd;}G`dE<@kyjlp&?_J4)vB3IFh5;!?&|EyD#{}vDG01i z&0aWz%d^<%cn#7+`=)qRf!PnVY{?yuG&wO9hA=y~Sv5Aa?=aTj^=Bo~iuE`BQ~#Pp zkOGUyfT(C2WNU?4fToi^l@$RFg5oh4Q>s|>0UoLHF z2E>}#I~SuI;v~xCzp9TM^k5`YRZCw;m{JuYUYg^9<(ml*>u>vIriFu}x8=cxJjxb*=`Z#zD~Q}^Abv-BR+^=$O^nD1{_bk z-#EkHuJXo~zkVAE^K>VE`^q!5^M8;0$QFmobKHi|UzaEmIa>PtQiqSqxyiy^lg$e5~Gt2VE{gLjA#idI+Hu!n54`)K=js{tz-|U;P4G&;S z^fy4@9P!7t-Jz=%O!1G6fJHnqQQGeP!6&1?Bg#m;5|I(WfAu213_jez4RBrvI zzl8Mp6m>Y0HMz@^MfemokD#b=2u+u>q-%Kq^HA93*YH;uYCG|eTzEOxfeJe}=zbwM zCIDjuO9q#CX$1OOLSh3ZC%&ToS*uOy$U_zYgO26&&$-9&FzT~2o5%vjGF}&$>ia(prX;7kMc$jAL*~`eg?k}2`ji%C z_X4R?s0VW~9-1zj*2s3xpNfxT*KvsYRnGycYt9QvFV6d=zxU6+{B3xn; zi%9<*CCX;F<#}=|T!P(#^rPVjHO*=b3<(M>pwY(0;oz#V#a3$mM44wkJKY&icPtc| zeZHIRB2mGsX~9tmV1sUIo6mT(!hHm_nV5P;jHhGnt7mpa>zc$ieZT5!amX`CH4t4G zp0bQHM-cwl;W9A5;+l`ogPb{V-vYNGHtk?>6htK*X7vp>nD>Ycp`-O%1lARr$ z{HSd?mp<-P4wjVTcx_63@OFevLDpOri+W;<)yb(P58R0rjz%u$?rKNk^)At03))|2%pEAb#k1X+~ zf%q5pe(Bp@NeJd6YjVX-Dn`Z5L+L}GArgwlgV!XKLF@J7r)JDuGHAHfMd4>o8%ZCV&l2TO#pbt zFgdL|8wXty%4>JiNHSV!($J)~+7WP;V&|%fe6Dcid-?(*0*i=!o0c4~Mwf&d-~F|k zR#AQJ*HL32mu-K+hPn>A%ozWrhc%gF>WVNxu^p)h(uTT^0W`h)SvuR&mnC_YWvB!` zwX)YVuxm?-FhjGQ+r(_!0zl<)!k5P0A~j{PdTQlz)x9S{#j3Gp_L<#AAedvd%0((- zSXch4G+~>DV-19KSUbxU-|aOF__iID5NnVwj*fhyi1EI}j)j`L#+ojq7GtSnYy*IV z0oi>HdN}CgtUOWPyUViB|1pU|D$;5pU2QcN!ZM6-%3gbJ6#O(d%a~<&+wK4(qhm9T1llk?H0IhHnkEgv=JDhA^OPbZPspi- zE)C-M0VN_uLNnZl+{svHuCDGEv@EL5baMyIwn?6w2z5j*$PoKFIkV>;Ak?46?kZ!) z2J1_ANTAWS7*X(yq?uaYOK$3gwni+{b^((U9u* zfD*CiDkPC0XvMR*2uE$o{Rq;NThJ*(0KOg@qJn1^jzZF^DIk*CibmUC$+m( z;&QUDV?A*!o;pVerId4Pp1P#s+MI1A%w!rT>PuLjX@b3~>fCw+fUvdq(+D9{{R#EpXq6h;FQy@jiyZ~+hsx4Y~XN7_x<+xDL?$~;vpm2lo z6ie&6E@%u_jddN^9ENeZUM|zr_7#*Uh{gVv1%PlG1PBFEz|+V}{G^KK0iPi8s1pR$ zf$M6dj1fF#AO$M&oiu`~iru#2rD+r7LB@{GTCfk(~ z!9p{3Rgac-uE6P#W^jdQ*FJV69Fx@1weg zp7VFnBiXtN0io(kUmV6&a_%N*o9JVirH)|p*dicQLU~grE4LvK_#o9j`Fvbl9G9VV z#6G8=M)lM>j1DjmpBY=EsZwiOMkxNTyru8}l4G;00IO~3mmb}%acZR@hL4RejqCMu zR{<~t_&Lx@So=B}OjB1}90ZCdB?Kd6N0Q2nxkU*G0L^?%7a*yYj$wXp9qw3HkWvv6 z+AOBEO`}}k13HHm+@Pvzl|PBRfSPUSB@I~MS+_~Xk|3)s0SSDl0))B%$Ybdo-f?K* zy}4PQ!a^^n;5?+9{hk+IH~PMiueh9a-1ry=$^g^InjN=vG9i zdM_G;emm;Dz!cK2qnpLl$@h{57Xa~Iof+Gr?*joYd^Z|vi1EG0jshyC_&N}Qhga#e zKB*~Bf`BQhfcxl}y4bpmih$V1thP+dqic9duq3>>n5GulEaxa|G1IuJuZj^6swT6~ zeS787x!Y0_Qi`^4EJKUWIW{;3imr6^bqwD}FU!Ic1jJ$*PisoNQMYbGL1J z8q7T=7ij=sB3ltf?K=$|M^-#%b}2w-`Pc||ZNKLU!ae8ur>D5~Uj@p@V%>Wot!;XJ z3`iOK$}Wm4a5d^6O?Cb{h;2+rg+)i^m?EOdXTaM+hJCj`3KnWi!U(lxlMvA~(0?~? z6P@zf#;&7j*k2ON&@6F)PK$XiUT=xLmoH0d<{kxEo@?BvE%FoUNtvmyHQ%jnmCJEb z5)sz~?&x*+cl#3t+APz#r_`QZ5niiif~H@GoSe7OU3(x35P~YtsffLf*9ck(_BoNt zv83@O2&f6XhPG3udC%QIKqz@`x(-?G+7)3*wV~(ETc&Nc^GHD@#-X;0@(Rw|6A+e5 z<#T3+Bwmz(@TQ^`G4lc_i?~7H2LW*=dhqs(nks=#c-;jlGq!u*1uv6^w$1C-40Vpq z!cp)L(@>{7WCl3XG%S`;8Hcwz^IC~8I+lrL=!D&A45}rHq@s!zs$*nqI{M1!&};?g zwMJMP?rJr~L~BW4ZEG~N9lXL5L>_NUhuyl@NNLX1XWM-b-sri;kW>YLlHLF--_vF2 z5|Q{h6&>h0>j)wo(_N%U6%l{R!uoy9XJ~w{n7ZUyTp@&HEMvn~QY9Z9v^sW{A(ijm zuq2ef@m?d*EW1~b#x~uhfDic7!U5}u$Txm>`jP({0@%B%vi|FtY|CbuE!yX4h>nBt zlSLQ^ZZcv~GcUkD9wh*TPfa7+24&jjb3l>?Hc<*X0bd#~J)nB1f}RJmng%_0NJogJ zuQ_*%t^K&vxlzw$DN}mx@#i>#G-QRMsuW5C6L`kHMtH`afwzU~Oap}0z4soGd2fe^ zj=<8EyxvGrXZ_N|PzM7d35@R=#}?sfc;37%R1>b$g;P`8Izpw2bHj#uh1s?^F8zJb zWjgcBJWduX)EY1hX^0U+)gU1C*Thq18(O4Dqp`!er3q-pV~tZ9puZbDB>zjyo=aWLFwXUWDPi077r?jE=8z^V-Z=ch3v(UUG6?iybqs*)s+nEoVrS zd~SjiL!Dh^Cxh)%fKZUxwshx)s5CkTjyELcecl+v#Rj?ptXleFcud&=kp?F6nSDYy z6Skq@wPD!WSNA2)HEaUqQ%PuCM;^u4jGD&fJgg+FH6v$qOuTn$)~c7@m&R43TDwIk z570FCn*uybe%Hm+YV$nwO7(Rv5%@Ov1BaPE7GFIOupA#Jjqc&bcw&2Gv)m+<=N&-W}--~deO#961h}6e2cKzHM1SDMI z5m#KBx8QN*+#abU)T*xCm#_<8;pYSGu<6?2Kelg^_$}6wey^Zz^wx6)zP%u&kki&df1gnlrF-5dvIpcu06O^VwYYTvx28}g6S`Zmx2g{J6RWp?Y zd4$f@RK|F}p|Z{EjB8xGmuS0H-jcqLH*bWdtFvsD^RiF{UPSIr2F6-4=e8#X0J=&y1&K$MzAajJ*S%Lm+b*&| zP(%3oTbIuZa2Y=fsp@R^K%d^l^5m^o3!-CsV%K&|-Z%u2foE?NRL0bo#v<*BqOW{e z+=7Z}XGzG`by6s3@*I{cP(9sOd8U=2BeN(_WD*PPZ#NBnU-M3BNeNZa-PjgT5=ymQ zi7wk93XmOu?yAmvZf;8yHHT*0Mub_0pN7&n?%9ry5(LD!>;*_3n72s1fKP^EHjGO*i~SI^4i>7B^KyUB4I&5Y#T<+2E3Jxk%ahDt$UB%uVG+o0>Ux$8diDTO>IUT2k4@Li&(wwLH_8v?G|T~~59SBQ@$406DsT4k>FK^aI+U4#a(o(w%zFPzGIsfW;-icT|nM%o@t}-QiL| zFNU9+FO7F;jLrz9lKd{H9r>R9ECBj#lExSG3LSNxRGgb9VL%_MYI4gqC9t9X#=u+? zi;I(CLPT4b`wA-M`6kNty^_785UQ`-U8S4ZQ7f&)^UriF=kv+DRgfPA)_DaZVCPEEL<+j$gPo`ip zUinB)UL-Yy7|tC@gP{_{b3#LaCzK(ScjdZ_Suc?+IiW%IcsgkYzSqRp6513(|TS^l~6 zMZs)I8d%3J4n&s_JD^vC#d0#0l1pl|Y2;~SLLK|x6-A)1|L2-OFvGZVJ-g>oT*{6( zz~{DadF7t#MUBjamtNTOJ*9X`~T9K#6#_7^$< ziTikNU0+e>4j!}L=(`W|F!wyS0Sv?BI=EW7pP)OiGPp%iPMEs8NTo9GGKLTX6DdQ=h^ehE@7swv z_s!U>^XL(xsQAHJy9(p^k%oSjjJcb(8&FXs@BLxGcsECcxcJY%I%y3Fz7D13%`cbP zrW_g?0Is2zROVf_)`lie8ED8Ao(0f}J(d}7R%TnSc*qYo?~|pgZt|ybT4s*TlG3Q} z;rW<&31!&4Haxk1zx^Sx zjcgV-1a#zy<~h}JrbA3$dlt*;cGo)hC}c(su2Q$;sWKn~%iJ26D9MV0-MGz9nIRWf zBU@?G@L&;#4Lo|A%yXYv=aifYwc^jo(!ki3&aR4gP|w{}OJ-iO)+AEzV{MKbe}wMX=SBud7ah!BcWEBoDvPJ4Ys0OGszG~KMh zIf66IaH^H7cwvnUBiA%pIdhMs$YVc^I8*dw$t`QYo6gvYy!Ri80d}IFv$a&T z$K+&-XcsdR_Z5x^r#;AcX>F*`sK($1FM8Q$+~vV^*V5-hddFeviN~?33b{ zEph>N5O24sOyiCu8t&cno4%{gYsfsdtrL=pIJS@0gmtl$>2ObT0?c(}mFZ($G%&SA zsI2&Fti`zO$~k+VP|FIC<9PywFVeAC<-+-3=5;&1FHoA}<4 zRILf6wh9T&+u!awn4BG^k?`A=Ck`&P?bhj@b95k&4)}L_G{|ZIM3jl2OQFJ5Tyl+3 z5Ky%ara15dUeSsAZTngLhEe@k^Sk8=>CD^zitwS%U?T0;_4jb{UH}F_m&dkcKmbLP zTdVZK)vXFqmE6LmP}!{x>|DX7ZKAVCbFYQ!9h!r69VIkx|HNDwCekPBV7cG2O6N`| zp=})z;M^>YtirhrQRT6%Enm4%;TXZ=bEptg`&3t+I7m-Kw<1=r*P*_v=7}OYFF+yx z&o#lcknU$2we&~|jv`^d{Sh+EJyoT%!G(HVWXW}E<$o8H-LVO^mcG6U-gPaRx(fe2 zG%VFL*}=YjdY|Tr009p($9kS zK@xJ*cw5ZPGIhLDMwU};QCe#5Yk<$OGt??nxXJgrA%x6BL$8D8Sg>Q>?uL5ANz}p<{cE%rAV20TBgnW}vmdu^c9 zuKP02vR2l%PNVb_I;d;6Zjkp15RB0gFMiBVa}LdD)fG~^?#mus$Nw>}GMty*#BFQo z9Y?V6&Oj<3bIb?-hq;8_aMx;tB2>u#b4@TUq-%m#QoMx!xX2?`*nb`HAnhwDoKyGP zzge8Z{_Erk=>S@xV_kg;`>!K0qI19s5$ozxq+p%Z0Ud(p(uZH(8}J;4CHa(|baH1Y zv5~0F5yu>1wl1s!(gz-co zmPnNF2LrahqEH@jC`52vBOCbk(WpDwrKEU8Ck-T6beGyt)mIdU0s}4_vWPCc!o{Dp zl6VpX!$>u}r7R02CpzxsiCS4Gv*H9W@F_81St;Q`6s{ZbXGk=R?18{N^UxoEE1FF`T&z++Uf)E%Cs>RUK)b#-;)loE@Kpz zZ8|!E6S;aajr;)-S=_PiZ~2XXPE0M>wJ{;KxQ@&l_+6rC@DU$K zQwvS2n3AWHqO5@Td}R+LJZ;$CIvT#B?|CcQz9Pql@f_B3Y=%)?0)c0O4)K-EED(ng z4t%U`lMgV%HeH0QDStZN@QQK(+R>JAeRIY4@ms(B|5h#Yo=rONo=qt9k#8|{5f*@Z zezqeRR#1)Jb1!-euU?ljUCDJ!9H&J`b%|t1d?wHKCmf~`nc_rs+ef;3T^^HMIxJK0 z$w)tzHt^<(o!bSigC(ZLyzV+yfA!NMDJ8f(*qvmEpJf_xQRR9AT^SYAA18eLa=mTo zibUHM#5#=hJ)izKjfB)CfM2QBoBEY)l-F^Fk%9M&tCD}BEr09&^e1e~mQ8Q$2fjr2 z{G&f>TP9JefAMbqCHsNmQF-m1xBX4~PW0S!k66`}@E6UWh&*`XhSCwUDu`B0(&pjBOTA!q9b4C1q zy`0BU?&F*U{hCMc5Z{@_R+i15-6m(kOkJfrH}!MqLp&GAp+Z8-Wt{u+WpPBRGz8<{ zZR~67*jk4+5a1=C;dvX6McF7W7m78nZkHX7t2$OWLyW79H>W1QMcsStxD zfl<-_;jjU+7=F%g0z(n8p^o#z_hq%cXf8 zY@+a6)|F(Xj9CZOcXvpM`S&|G&LZv8Oa&<^HSTkE=sQwot%SKP4eRKlp-B`whTR7p z;us8F>ABq7+nQ(bkd?76w)a1tzA@VfGgCUrpa{0`hv;K|~txhkqD; z5v91At2v980~#l$7Sgnh^xVDoZV@%Yod`nChI}tu%4}R&rj9xmYU$@E@a(w&r>Vbo zC(CxgS&Aoe6r4~iRi(868ro@Ph{Cvz2YYteG;)`zb?R(+g3I|j<6>G7L%k8y)Y5Vu zS+Fl1*V>lmzW$c3fY`Ix+?MP<>%RMZre&pJ=!X+v3up1n^tlfsI}RtI&mm++GTRMdIdVYTed~vtEr$a*#uqqHBZ7PG|d*BX3M|>^2 z%~jM@f2QwF@Pj3z`6RGxmPor)=RN4z&=Dpnm|52ZNH&9W@4Nt3+oaA7RyhH|plwTc zZV+e!W<{mUx(qE$02Co)l`?sp&ePCvKL^MpF)!0HcoWvwU>dr}|FF6`bL%K>@2WSj zcsdfn1en<-^lL9kNGOTv+W~57I#ed!gexT2(x*8awJJzii)@xh);OgltHe|8Obtunak0qp1tAUmCdtUwAwHed&l|_T)-Z)S_XO^Lh(fO`%pO7+V zk1yzX9v|)!S9ewQ6$O3l@V)y^aL(B}cSym~$mWhsdd>wn+A?68i59741jLi@_wrP0 z#^$6Gb?sQ!rfGPF&Ep^AL})Hy2o+*_9WI;~^-0t{V{E4_jP%|6LCPeUsvyQPm}^IM z^=;c$RptVI3@`E3*FG>Ig8+sWLBmg@zNDO|5sCP3Kku_k#A2vQD`$b^ynuJJ5#QAc zxO1ARc{f?6kvtS!tw1NLiimX`{HEEwoxwD&o}r;7qP38k`*-=}#5_uX(9Qesd8Q@k z!{4hL&<0RbQ{O5kpZh#)t{XD$E88L6?=g%Fyk}hXR|2s5FDBmZYsS3m#223Cb%d|y zhugJl*VixtpS2O&^?(MRArC`GL@m2*QOW?^_;;)xIm$*r-}#aHyeX*y?NXihV9th) z(4=5yT@wJ=0?n=S0`RFKpL{P~93I$j;gP>jiB9@+yMq1Q!99-2XX>-8kPOSDeh)bP z)uG=zJ7xQ{D%kH~(jiZFd4G1Qv zYu{6MDJI@mx2pO*vJ3;7Q--S*f*JuSNHm%)Oz`G-VP5{0u6!EJGDp7o|`=+nz5n!N=~Vc1z2@6mt|7hNM1V_2lMalUd$N0PkQ(vIn&{HxsHaUbwg-R%6!7~k=B4l?po|9);LXw>mvG5mhg}Ic0EWnUx6nVf4^wJx4pWCvRu@p%}JF^PEIm6hopfO-moKJky-X zzu=zvHuz9m9Xl0QExk7mc!rUk7eH9gxXSMv2IjusyT8}mdDiKE^$!{0cisMzCbkBA zp%z>kohn80Fm%M%vfF$`UG>0m#9jt`p0@mOhhC|42((Le-h(+CIzp2InJZV56;IFa z64tL*5bAH6=l!j-*VTt_ZM|jM5e)Eh+S=&Ot@C&p`_v;e7sNS0Qx;Qm2SdHqIYHdg zfxUPQO-pefaY*nyFAgGhg8lk8){T+#Ov}<$SYO5wUU5$xMuXLL@N1{PN54GV4&BWY zQLZKxOLGIlqnbN^9P)=kz=hwtqmlDkg!l3mX>@E&MgNW?2R-~oG(!z12She`MKpK; zX)dYnu>eQkWK=2joPzv-vvt?9nlyFGI)W3<_R`35tf61x*LJRb^_F4XmyQS_dg|61 zQ9|VuN#skgx9myrDRw227ZFg%tuk((ElANIi`rwQ>N8~I*4qfOT?S_(v9>r>A*J^|)sd)@e`ADD@sIEkno!BTSV^^B{C+oe5{1AMJ0f3EL7;!owc z<;UzSRhpf4UmcicQ4FHRKWz93zF7DWH{){#e4=A$m+HI+b2fB@CIvFCs$Vd}R@B>W zeprCF;7Bl_N~p|pZwROy8%JLQDJbRSF1W1tu7K-t6x+}*2&l;!IQ?inA&w+MR4qJx z9R)xJx+XtB#1NmIyJ)DesEz+Xx+FSw^u`Z`Phftre8q%qZfYC-Ix5CGYJe1!OShhj z4oFq${zd~Dy+1;)nR+fkz3FRjWLcfeZP^hCMR#WD(Vp+=$Ux6@@ID%OZJRvTXaF+! zu*RuFJ7=8xXh1=qY#>7aegQ&|eHp6xljlFvirB&eBtU{O-uX1Lojs?oqjNNX9qvCF z`KxP~Xb_MnK>b@IR4lBw{=IVJ@NSs*hQJl7>#rT*eQA_4$DgqA`Ay?z&MA0Y-Kxc_ zL=^CrG_K^8UkuCJp_C?hrioBbaDjGd^>h zd@!Vh0mm8Ir8@7yoDChJNr8-MQHJd(w zd-5cXEnP#)0#Jcx9@`Wtlxv%!#i2o_63Fa1pEnv1;xjtdyF%AzxON`p+j?qf6&l&Xg@WprK3-FDeiclTrye&%~L28#!KF!e6#@DyA2Jo^$OG z5YM@GkkrBKqU9ltl|Zax>#rj!5E3Cg*ovdFU?286^wwPl=>jJsAf*h*@Pq+>fq6e9nW@I1FEtNU_N+Y~Lunuv^NoRr417m4FX)_jG-vJl~a}U8?gQ^laz|lN8JV874q(J9l1y<#zva@!h`+#l*?q zG`!H_xqs>F|F3cVAC1$0%&Y$+PyF{JgTG!>_b(R9{mc8B;uHr0K&Xz~#ys6Hn#KV_ zfXg(fv4JOG>?=-2NbU^&DO`fU+{u&Go=_JxMd8TDJ3_TVXP)8Q=B~9~KP?V9LV;S2;2h1qBgR24&BWW;Z&CTZM zx&t87H0UIxnJ|=TbqRpr(kN%7V(HHZUBW#fgKbVaz|qo%SqHe*YhJ)w+_!xhJOG+= zovNUEeP8)gR-+s0BaNOoMuUFSQ;m(EMg<|M%41KRdjUx%!0Kcb6MheWZx$o_mnlx#{fnRii$(uoAonjC z{_H7ovAFJE57Q!~XIw?z?sNCodc&76>CeQ*;8z70zI2y-2j4~+qP}I{-s!%&7@+f8 z^qu50^UT4|&+n5bbr?G0YuRn0qOSV1`RxD@_m^1T7xSH^BJEq#F4cJtayE2?NeXCS zm;%VX^8&ow`5o{-$DqNt_xZo?WrOb@2D{z{zg`D_z6|<(8|?Vhv>>fq);3p-4^fPkg-~Rn?jms24(==*i=VZ zEmkDY@&m{w^Hichw0)oo+I5~#>sTxe4P*Ph*?qc$&#SM}L`a5XIpYA~T?sB4C&VKE zAd%BDbD4;e_ymUgL}X0IDOf23z=vpLJH7vAXTd>j$^0JhZkX?Zq6T(&Y;P%+0zk9& zfshu2#HEbdf!api?pZ~Zy85~n8ymsZ!ANyy%XFo7X#Pt{{Aid`Sy|cO%S-GHC?-}j;R4`of4=lt*67=ta(`F}r`aq&{;_mc%I?EkqYfG`Omci(xL^LtQ}-_JuW zFH=}vhFqS<8mrGlLv8Q#zwb@X&wWBxSAQRncZDRV_VQM$>RKCX2q9$RN_B1znXT*O zcm2-~ORa7H|mQx-WzU%Jyc2v}U3sQDtfQ4rUdDByX~RC2k@u?8JSZ=2_X z0EW3MKa{$v=CSxq5zqNWO2GDGhxE(fCcY-7_NhZrgB^>{b&HDwK(P^+wx}2gz-k)} zZM#71(vBgr-*G-@m>q%LfFc`%2g3K>OAFf*f7-X!LsxpzBdn{sm8Id!A+_O_L43Zw&wq zmg%{6R3MDDrN0k!XNGfcvpRY?oAdaA=F%MM_KBQ3h)@DM=AdN-fN(B7t}7?yS_Yb- ztMW928bC9hTcid6RCT4U$+>|i(~vxtXNm97eDiF(Ai)+b|DxaX^Z@$r?k}hfy-z_O zkFbN&hz2Uex^=K?(5@q^j@l4;Y)x6OSf=k7zq?04sMOJ*M>}H{BM@#PUmQZrvxgNL zicZXXxk~Zcw_@U!6*<;2@cY;gBXRTP$NcqOll#jM*V`ClHBq0N-d{u%_MZnMfQc+c zfuYuyLEo=~ecy+YAIJE-%fG&BG1&99iTc#1tjYInu=AC}dXA~i(H>`IF%RBkN8|zc zq>#JO(DZGQaUa&uH-uPE$p55EZZ{1Z%NM9A`H})e&d##qHf+;xItJ_9KT&L#G*jzM zV-!8>H83D~*W$9hJ`)SW8o@#WVd2$)*%Ng{-Z zx%+I%zSwh`LsI7g$gs>;k>%sQC;*W8oc$w&Y-P%*?Gyhd)6!d~&gbJ;cXDiUvIG^R z7zMz{IpBHSbzF8uLS)>v%*QILJ!f4}G>iOjCUGJz0mqo2F1D8PO9ev({-1Ok$Tk&+z% zefwqs)LS*kc#cLW-FN$zaTx&%hDAr_I6zRXLvZa_*gR$nj`S?k-3LM_jt{lf$3ei@ zp^<7`pruURJ|>_M*B%xkBH9;P#+rj=^ISrw5V&hF9Rw_xqz3Ov5mO_*`>>P zb|k1T#bwv3t;i{ORFtQ=sH`#>h$y)=i2W~?EWm%*3keEA{&`7*@)WjjO^`oE46!LOCf^jlqj zo@Jw<<_}`ZCjWhFdcBoR{`nG&FhZszymzSO^5^p!0$i=RQBZS_jWvW4*wT$(tzqI6 zfJ`*|WKJ1mw&6e{_G7j8F}(J>wZ8}Td$4NVmpt1>(IdChF|pWnR!W)rtdY2efQskz z(_{L)O&ph^7ptJ=DlvX zuXr+}iM$4scX8(#lz z%lr3ZdH;VL&fxF2DIA~5%7ne_`Q4I0EA0QdCJ@?me7$tTN!Oic*{}R%%%+heW1n~GU||-d)|usXqFC6+Ca01 z5#P;Qqyrd6e;|?Qk*$E*lHc4CL0gjtM&X2#(m?1Z)cZIbD%G6Z1{wprTI4>3!sL6S z%S^_0m)J+LNE+*U#CK~D`n=N#6cjJu6T;Y*z5{SN6)HKqy1F?zci@g$OC!l^$JKfS z6cYzxd`{Xnum6rI&z?lVBfnc$-oO{61Pv`?YJG87v#Qo@5^L4elsd;Y0Cd*Qp+!;g z6mwdsX-_z2=-71yB&AS`o2&Gef)5nGq1$u=lH1_y8{SZ`ef7R}NgsGuibA4rw+$S~ zfMuZ&Gt~S6752XlUxCjB- zY9u6W<4(AhD4>@fQYHVEpxT|h&Ml_9GIIS8iZ0m>pv_}PTlxVUc-s8%GWz8G(!bL! z3RJ1Sj~vs?)ljyNfk+_e*Y!7O_dQᯒES%HeH*5QlR;!J>zT;dl6LRv*0OEvE@ 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 literal 11716 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 z zRI6L+m^|p+@7+M!2O>zLQr~{fW!9ET#$ym^3_dV!UVTz^<)_>%SWqRa4evZ;t{D&V zXXu%Bq!|XXsTqR~s}?;~?;C4J^$vikiC3BozgTSr)ccCQUbdEunXVt!)_A)}9*7u@ zs!rZfH1|F~oSccab|E#7YEfMD-p!D-Qy17YK#_Xq@ z)Fbp=#207oO!YSzo=F=TH`rtx*ySG&G5s~90V*jaC|C#vj~P}+P?5^*PAPe)Sn@Ks zsNDZz+Q;J-ADi!16pd^ozXKOiZ11C4N!%9TrC1vyM%vVKRqy?HKBdyp`{}Xd5+%@= zS;r-}VGzI;sPC+A$26|LrvwW&?DaDqL1RM^dGuxs*epH@Af z(UGgilON`$MW2eoXzh^)&#TG@ob_a9?rEKqhv~#3C(@6MsJB-ynNxdb;Cf3u)#LL0;@gS?9a;22w3pt%#=%G9=cNtFAe#3=^+AX6#`E+ zYa91<7|!P^6G+SHfEA4EPj*_%(aLW2&{Dc`(jEUYo~3+jtpETyIR?O&e-bPJ0|E*F z@elo{5p{^e2sw}%A) zQs4m4|K`{@*x4Xb{})62(*P*??>IXf8${9nYW)2C7upp4uf~{vrl9_doBz@OQ~nR6 Cz*@@y