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..281d6b2af --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/builder.py @@ -0,0 +1,187 @@ +""" +Turns a :class:`CalibrationLayout` into workpieces, steps and assets. + +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 +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..cce79a67d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/focus_test.py @@ -0,0 +1,400 @@ +""" +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: + 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=[ + 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 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..5a4ab2134 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/generator.py @@ -0,0 +1,156 @@ +""" +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.step import Step +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] + + #: 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: + """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 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() + + 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 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 +) -> 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..7188f2a48 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/lookup.py @@ -0,0 +1,111 @@ +"""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 +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.asset import IAsset + 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, + ) + + +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/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..8670febfd --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/material_test_grid.py @@ -0,0 +1,268 @@ +""" +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" + keeps_steps = True + + @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 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) + 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..8e46d1e9c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/calibration/record.py @@ -0,0 +1,106 @@ +""" +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 +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 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/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/__init__.py index bea20ae93..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 @@ -4,8 +4,10 @@ Provides command implementations for laser operations. """ -from .material_test_cmd import MaterialTestCmd +from .calibration_cmd import CalibrationCmd +from .calibration_sync import CalibrationSync __all__ = [ - "MaterialTestCmd", + "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 new file mode 100644 index 000000000..6930749ef --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_cmd.py @@ -0,0 +1,327 @@ +""" +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 collections import defaultdict +from dataclasses import dataclass +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 Command, ListItemCommand +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, unused_assets +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] + + 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 + + @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) + + 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 == 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) + 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) + old_steps = self._steps(record) + changes = _reuse_steps(generator, old_steps, built) + old = GeneratedItems( + old_wps, + old_steps, + unused_assets(self._doc, record, (wp.uid for wp in 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)) + t.execute( + RebuildCalibrationCommand( + self._editor, layer, record, (old, new), changes, label + ) + ) + return built.workpieces + + 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.""" + 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 _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 _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), + ) + + +@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.""" + + workpieces: list[WorkPiece] + steps: list[Step] + assets: list[IAsset] + state: CalibrationState + + +class RebuildCalibrationCommand(Command): + """ + 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. + """ + + def __init__( + self, + editor: "DocEditor", + layer: Layer, + record: CalibrationRecord, + items: tuple[GeneratedItems, GeneratedItems], + changes: list[StepChange], + name: str, + ): + super().__init__(name) + self._editor = editor + self._layer = layer + self._record = record + 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, forward=True) + + def undo(self) -> None: + self._swap(self._new, self._old, forward=False) + + 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) + 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]): + 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/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..38cb23950 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/commands/calibration_sync.py @@ -0,0 +1,129 @@ +""" +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.lookup import unused_assets +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, *unused_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 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/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 2cf40a2ec..c25072cf8 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 CalibrationCmd, CalibrationSync from .widgets import ASSEMBLER_WIDGETS +from .widgets.calibration_provider import ( + register_test_settings, + show_new_test, +) ADDON_NAME = "laser_essentials" _ICONS_DIR = Path(__file__).parent / "resources" / "icons" @@ -31,27 +35,57 @@ 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.""" - command_registry.register("material_test", MaterialTestCmd, ADDON_NAME) + command_registry.register("calibration", CalibrationCmd, ADDON_NAME) + command_registry.register("calibration_sync", CalibrationSync, 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 + 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), + 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..8ce34f3d5 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/calibration_provider.py @@ -0,0 +1,280 @@ +""" +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, GObject, Graphene, Gtk + +from rayforge.core.item import DocItem +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 +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 + + +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 = _text_row("dim-label") + self.varset_widget = VarSetRowList() + self.varset_widget.data_changed.connect(self._on_data_changed) + 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[GObject.Object, int]] = [] + return [ + self.help_row, + self.warning_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 + previous = self._record + 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 + if generator.id != self._generator_id: + self.varset_widget.clear_dynamic_rows() + self._generator_id = generator.id + 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): + """ + 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. While the panel + is still being laid out, the card is followed each time the + panel's size changes, until a setting is edited, another test is + selected or the card is hidden. + """ + scrolled = self._scrolled_window() + 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)), + (card, card.connect("notify::visible", self._on_card_shown)), + ] + + 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 _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 source, handler in self._scroll_handlers: + source.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()) + return point.y if ok else None + + 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): + 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))) + + 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) + _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: + 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() + self.stop_following() + record, generator = self._record, self.generator + if not keys or record is None or generator is None: + return + 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: + self._show_params(params) + finally: + self._in_update = False + offset = self._scroll_offset() + try: + 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) + + 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; it is + restored before the panel is drawn again. + """ + root = self.varset_widget.get_root() + if not isinstance(root, MainWindow): + return + root.surface.select_items(workpieces) + 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) + for provider in window.item_props_widget.providers: + if isinstance(provider, CalibrationPropertyProvider): + 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/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..c9dbbc1e7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/test_calibration_generators.py @@ -0,0 +1,411 @@ +"""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.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(CalibrationState({"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 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/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..1d4891a39 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_calibration_provider.py @@ -0,0 +1,398 @@ +# 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 + +import gi +import pytest + +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 ( + CalibrationPropertyProvider, +) + +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 + + +@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() + provider.scroll_into_view() + assert provider._scroll_handlers == [] + + +@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 + + +@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() + + +@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) + + +@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) 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/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/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..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.""" - win.doc_editor.material_test.create_test_grid() + 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") 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( 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() 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..074496c9a --- /dev/null +++ b/tests/doceditor/test_calibration_tests.py @@ -0,0 +1,939 @@ +""" +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 +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 +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") +_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 +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 + + +@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 + 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 + + +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_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) + 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_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_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) + 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) + 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() + + +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 + 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 = { + "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 = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", {"count": 3}) + layer = _layer(doc_editor, record) + + steps = _steps(layer) + # One label step, then one engrave step per cell. + assert [s.typelabel for s in steps] == [ + "Contour", + "Engrave", + "Engrave", + "Engrave", + ] + 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_scan_line_pitch(doc_editor, machine): + cmd = CalibrationCmd(doc_editor) + record = cmd.create("interval_test", INTERVAL_PARAMS) + cells = sorted( + ( + 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) + + 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) -> dict: + values = { + "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, + "include_labels": False, + } + values.update(kwargs) + 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, 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") + 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 = 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" + 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 = 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 = [ + 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 = CalibrationCmd(doc_editor) + record = cmd.create( + "focus_test", + _focus(FocusMode.RAMP, ramp_length=30.0, include_labels=True), + ) + 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_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) + 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..180ecfc93 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_calibration_test_settings.py @@ -0,0 +1,171 @@ +# 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) == [] + 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 +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 + + +@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 == [] 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..be8e48cb3 --- /dev/null +++ b/website/docs/features/operations/interval-and-focus-tests.md @@ -0,0 +1,55 @@ +--- +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. 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 + +**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). + +![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. + +## 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. + +![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 | +| **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. 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 +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/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 new file mode 100644 index 000000000..122b35eec --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,61 @@ +--- +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. 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 + +**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). + +![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. + +## 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. + +![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 | +| **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. 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 +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/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 new file mode 100644 index 000000000..eda3133d6 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,58 @@ +--- +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. 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 + +**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). + +![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. + +## 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. + +![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 | +| **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. 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, +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/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 new file mode 100644 index 000000000..69b4973e9 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,61 @@ +--- +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 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 + +**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). + +![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. + +## 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. + +![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 | +| **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. 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 +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/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..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 @@ -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,36 @@ comparables. ## Configuration de la Grille -La boîte de dialogue des paramètres regroupe les paramètres en trois sections. - -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. - -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. - -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 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 +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 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. + +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 +146,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 new file mode 100644 index 000000000..a53c48c38 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,55 @@ +--- +description: + "इंटरवल टेस्ट और फ़ोकस टेस्ट से उकेरने के लिए सबसे अच्छा रेखा अंतराल और अपने लेज़र का फ़ोकल बिंदु + खोजें।" +--- + +# इंटरवल और फ़ोकस टेस्ट + +[मटेरियल टेस्ट ग्रिड](material-test-grid.md) के अलावा, टूल्स मेनू दो और कैलिब्रेशन जॉब देता है। +तीनों एक ही तरह काम करते हैं: **टूल्स → … बनाएँ** टेस्ट को डिफ़ॉल्ट सेटिंग के साथ एक नई लेयर पर +जोड़ता है और उसे चुन लेता है, और उसकी सेटिंग दाईं ओर के आइटम गुण पैनल में **Test Settings** के +अंतर्गत दिखती हैं। वहाँ हर बदलाव टेस्ट को तुरंत फिर से बनाता है, एक ऐसे चरण के रूप में जिसे आप +पूर्ववत कर सकते हैं, ताकि समायोजन करते समय आप परिणाम देख सकें। टेस्ट सामान्य सामग्री है: आप इसे किसी +भी अन्य सामग्री की तरह खिसका, फ़्रेम और पूर्वावलोकन कर सकते हैं। हर सेल या रेखा का अपना ऑपरेशन है, +जिसे आप हाथ से भी बारीकी से समायोजित कर सकते हैं, लेकिन बाद में Test Settings में किया गया बदलाव उन +ऑपरेशनों को फिर से बनाता है। + +## इंटरवल टेस्ट + +**टूल्स → इंटरवल टेस्ट बनाएँ** भरे हुए वर्गों की एक पंक्ति उकेरता है। हर वर्ग का अपना **उकेरें** +ऑपरेशन होता है जिसका अपना रेखा अंतराल होता है, जो आपके दिए सबसे छोटे से सबसे बड़े अंतराल तक समान रूप +से फैला होता है, जबकि पावर और गति सभी के लिए समान रहती है। हर वर्ग के नीचे के लेबल मिलीमीटर में +अंतराल और उससे मेल खाती रेखा घनत्व को लाइन प्रति इंच (LPI) में दिखाते हैं। + +![इंटरवल टेस्ट](/screenshots/interval-test.webp) + +वह वर्ग चुनें जो बिना दिखने वाली रेखाओं और बिना ज़्यादा गहरे जलाव के समान रूप से भरा हो, और उस +सामग्री पर उकेरने के लिए उसका अंतराल इस्तेमाल करें। लेबल वर्गों से पहले कम पावर वाले अलग ऑपरेशन से +काटे जाते हैं। + +## फ़ोकस टेस्ट + +**टूल्स → फ़ोकस टेस्ट बनाएँ** वह ऊँचाई खोजता है जिस पर बीम सबसे तीक्ष्ण होती है। धनात्मक ऑफ़सेट का +अर्थ है हेड और सामग्री के बीच अधिक दूरी। सबसे पतली रेखा सबसे अच्छा फ़ोकस दिखाती है। + +![फ़ोकस टेस्ट](/screenshots/focus-test.webp) + +| विधि | ऊँचाई कैसे बदलती है | +| -------------------------------- | ------------------------------------------------------------------------------------------ | +| **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/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 new file mode 100644 index 000000000..57b8704c2 --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,58 @@ +--- +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. 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 + +**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). + +![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. + +## 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. + +![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 | +| **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. 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. +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/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 new file mode 100644 index 000000000..9f9e82f0f --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,56 @@ +--- +description: + "Знайдіть найкращий інтервал ліній для гравіювання та фокусну точку лазера за допомогою тесту + інтервалу і тесту фокусування." +--- + +# Тести інтервалу та фокусування + +Поруч із [сіткою тесту матеріалу](material-test-grid.md) меню «Інструменти» пропонує ще два +калібрувальні завдання. Усі три працюють однаково: **Інструменти → Створити …** додає тест із +типовими налаштуваннями на новий шар і виділяє його, а його параметри з’являються в розділі **Test +Settings** на панелі властивостей праворуч. Кожна зміна там одразу перебудовує тест одним кроком, +який можна скасувати, тож результат видно під час налаштування. Тест є звичайним вмістом: його можна +переміщати, обводити рамкою й переглядати, як будь-який інший вміст. Кожна комірка чи лінія має +власну операцію, яку також можна підлаштувати вручну, але подальша зміна в Test Settings перебудовує +ці операції. + +## Тест інтервалу + +**Інструменти → Створити тест інтервалу** гравіює ряд заповнених квадратів. Кожен квадрат має власну +операцію **Гравіювання** з власним інтервалом ліній, рівномірно розподіленим від найменшого до +найбільшого введеного інтервалу, тоді як потужність і швидкість однакові для всіх. Підписи під +кожним квадратом показують інтервал у міліметрах і відповідну щільність ліній у лініях на дюйм +(LPI). + +![Тест інтервалу](/screenshots/interval-test.webp) + +Виберіть квадрат, заповнений рівномірно, без видимих ліній і без надто глибокого пропалу, і +використовуйте його інтервал для гравіювання на цьому матеріалі. Підписи вирізаються перед +квадратами окремою операцією з низькою потужністю. + +## Тест фокусування + +**Інструменти → Створити тест фокусування** знаходить висоту, на якій промінь найрізкіший. Додатне +зміщення означає більшу відстань між голівкою і матеріалом. Найтонша лінія позначає найкращий фокус. + +![Тест фокусування](/screenshots/focus-test.webp) + +| Метод | Як змінюється висота | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| **Кроки осі Z** | Голівка переходить до кожного зміщення відносними рухами Z і наприкінці повертається назад | +| **Вручну (пауза між лініями)** | Завдання ставиться на паузу (`M0`) перед кожною лінією; ви зсуваєте голівку вручну | +| **Рампа (нахилений матеріал)** | Одна довга лінія з позначками відстані; ви підпираєте один кінець пласкої смуги | + +Кроки осі Z пропонуються лише для верстатів з віссю Z, а кроки осі Z і ручний метод потребують +G-code-контролера, бо між лініями використовують операції [Команда](command.md). Зміщення обмежені +±10 мм від початкової висоти. Новий тест фокусування використовує перший метод, який підтримує +машина, у порядку таблиці вище. + +Для ручного методу спочатку сфокусуйте лазер як зазвичай. Підписи гравіюються на цій висоті. На +першій паузі встановіть голівку на перше зміщення, на кожній наступній — зсуньте її на один крок. +Перш ніж покладатися на це, перевірте, що ваш контролер зупиняється на `M0` і кнопка «Продовжити» +відновлює завдання, наприклад пробним запуском із потужністю 0 %. + +Для рампи висота під будь-якою точкою лінії випливає з підйому смуги: на відстані _d_ уздовж лінії +довжиною _L_ на смузі, що піднімається на _h_, матеріал вищий на _h_ × _d_ / _L_, ніж на початку. 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 new file mode 100644 index 000000000..a551a7064 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/interval-and-focus-tests.md @@ -0,0 +1,36 @@ +--- +description: "使用线间距测试和焦距测试,找到最适合雕刻的线间距以及激光的焦点。" +--- + +# 线间距与焦距测试 + +除了[材料测试网格](material-test-grid.md),工具菜单还提供另外两种校准作业。三者的用法相同:**工具 → 创建 …** 会以默认设置在新图层上添加测试并选中它,其设置显示在右侧项目属性面板的 **Test Settings** 下。在那里的每次修改都会立即重建测试,并作为一个可撤销的步骤,因此你可以边调整边查看结果。测试是普通内容:你可以像其他内容一样移动、走边框和预览。每个单元格或每条线都有自己的操作,你也可以手动微调,但之后在 Test Settings 中的修改会重建这些操作。 + +## 线间距测试 + +**工具 → 创建线间距测试**会雕刻一排填充方块。每个方块都有自己的**雕刻**操作和自己的线间距,从你输入的最小值到最大值均匀分布,而所有方块的功率和速度都相同。每个方块下方的标签显示以毫米为单位的线间距,以及对应的每英寸线数(LPI)。 + +![线间距测试](/screenshots/interval-test.webp) + +选择填充均匀、看不到线条且没有烧得过深的方块,在该材料上雕刻时使用它的线间距。标签会在方块之前用单独的低功率操作切割。 + +## 焦距测试 + +**工具 → 创建焦距测试**用于找到光束最锐利的高度。正偏移表示激光头与材料之间的距离更大。最细的线即为最佳焦点。 + +![焦距测试](/screenshots/focus-test.webp) + +| 方法 | 高度如何变化 | +| ---------------------- | ------------------------------------------------------ | +| **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/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 的后续网格 ## 高级用法 -材料测试网格是普通的工件,因此可以与其他操作自由组合。一种常见的做法是在完成的网格周围添加轮廓操作,在雕刻完成后将测试件从原材料上切下。 +材料测试网格是普通的工件,因此可以与其他操作自由组合。一种常见的做法是在完成的网格周围添加轮廓操作,在雕刻完成后将测试件从原材料上切下。添加到测试图层上的操作在网格重建时会保留。 在不同的材料上运行相同的网格配置,是快速建立可靠设置库的好方法——而配方能让这个库日后按材料和厚度进行搜索。 diff --git a/website/sidebars.js b/website/sidebars.js index 865677101..c0a394e02 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/offset-fill', 'features/operations/command', diff --git a/website/static/screenshots/focus-test.webp b/website/static/screenshots/focus-test.webp new file mode 100644 index 000000000..bdd421761 Binary files /dev/null and b/website/static/screenshots/focus-test.webp differ diff --git a/website/static/screenshots/interval-test.webp b/website/static/screenshots/interval-test.webp new file mode 100644 index 000000000..df237feea Binary files /dev/null and b/website/static/screenshots/interval-test.webp differ diff --git a/website/static/screenshots/material-test.webp b/website/static/screenshots/material-test.webp index ea3e058c9..707da0f3d 100644 Binary files a/website/static/screenshots/material-test.webp and b/website/static/screenshots/material-test.webp differ diff --git a/website/static/screenshots/step-settings-material-test-grid-workpieces.webp b/website/static/screenshots/step-settings-material-test-grid-workpieces.webp index 22202f026..8f3fc9e28 100644 Binary files a/website/static/screenshots/step-settings-material-test-grid-workpieces.webp and b/website/static/screenshots/step-settings-material-test-grid-workpieces.webp differ